diff --git a/apps/fabric-example/.eslintrc.js b/apps/common-app/.eslintrc.js similarity index 100% rename from apps/fabric-example/.eslintrc.js rename to apps/common-app/.eslintrc.js diff --git a/apps/common-app/package.json b/apps/common-app/package.json index 14088516d..8da3d1787 100644 --- a/apps/common-app/package.json +++ b/apps/common-app/package.json @@ -7,16 +7,18 @@ "react-native": "*" }, "dependencies": { - "@react-navigation/native": "7.1.18", + "@react-navigation/bottom-tabs": "^7.8.7", + "@react-navigation/native": "7.1.22", "@react-navigation/native-stack": "7.3.28", "@react-navigation/stack": "7.4.10", "@shopify/react-native-skia": "2.3.0", + "lucide-react-native": "^0.555.0", "react-native-audio-api": "workspace:*", "react-native-background-timer": "2.4.1", "react-native-gesture-handler": "2.28.0", "react-native-reanimated": "4.1.3", "react-native-safe-area-context": "5.6.1", - "react-native-screens": "4.17.1", + "react-native-screens": "4.18.0", "react-native-svg": "15.14.0", "react-native-worklets": "0.6.1" }, @@ -41,6 +43,6 @@ "react": "19.1.1", "react-native": "0.82.0", "react-test-renderer": "19.1.1", - "typescript": "5.8.3" + "typescript": "~5.8.3" } } diff --git a/apps/common-app/prettier.config.js b/apps/common-app/prettier.config.js new file mode 100644 index 000000000..4e6d1badd --- /dev/null +++ b/apps/common-app/prettier.config.js @@ -0,0 +1,10 @@ +/** @type {import('prettier').Config} */ +module.exports = { + plugins: ["prettier-plugin-jsdoc"], + bracketSameLine: false, + printWidth: 80, + singleQuote: true, + trailingComma: "es5", + tabWidth: 2, + arrowParens: "always", +}; diff --git a/apps/common-app/src/App.tsx b/apps/common-app/src/App.tsx index 4204e65ad..c877dc619 100644 --- a/apps/common-app/src/App.tsx +++ b/apps/common-app/src/App.tsx @@ -1,5 +1,6 @@ import { NavigationContainer, useNavigation } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; +import { Gauge, ListCheck, Waves } from 'lucide-react-native'; import type { FC } from 'react'; import React from 'react'; import { @@ -8,22 +9,53 @@ import { Pressable, StyleSheet, Text, + View, } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { Spacer } from './components'; import Container from './components/Container'; +import { demos, DemoScreen } from './demos'; import { Example, Examples, MainStackProps } from './examples'; import { colors, layout } from './styles'; const Stack = createStackNavigator(); -const ItemSeparatorComponent = () => ; +const TestsScreen: FC = () => { + const navigation = useNavigation(); + + const renderItem: ListRenderItem = ({ + item: { Icon, key, title }, + }) => ( + navigation.navigate(key)} + > + + {title} + + ); + + return ( + + item.key} + contentContainerStyle={styles.scrollView} + ItemSeparatorComponent={ItemSeparatorComponentSmall} + numColumns={2} + /> + + ); +}; -const HomeScreen: FC = () => { +const DemoAppsScreen: FC = () => { const navigation = useNavigation(); - const renderItem: ListRenderItem = ({ item }) => ( + const renderItem: ListRenderItem = ({ item }) => ( navigation.navigate(item.key)} key={item.key} @@ -32,15 +64,18 @@ const HomeScreen: FC = () => { { borderStyle: pressed ? 'solid' : 'dashed' }, ]} > - {item.title} - {item.subtitle} + + + {item.title} + {item.subtitle} + ); return ( - + item.key} contentContainerStyle={styles.scrollView} @@ -50,6 +85,77 @@ const HomeScreen: FC = () => { ); }; +const OtherScreen: FC = () => { + return ; +}; + +const MainTabs = createBottomTabNavigator({ + screens: { + Tests: TestsScreen, + DemoApps: DemoAppsScreen, + Other: OtherScreen, + }, +}); + +const tabBarIcon = ({ + routeName, + color, + size, +}: { + routeName: string; + color: string; + size: number; +}) => { + if (routeName === 'Tests') { + return ; + } else if (routeName === 'DemoApps') { + return ; + } else if (routeName === 'Other') { + return ; + } + return null; +}; + +const MainTabsScreen: FC = () => { + return ( + ({ + tabBarIcon: ({ color, size }: { color: string; size: number }) => + tabBarIcon({ routeName: route.name, color, size }), + tabBarActiveTintColor: '#ff7774', + tabBarInactiveTintColor: colors.border, + tabBarTransparent: true, + tabBarStyle: { + backgroundColor: colors.background, + }, + headerShown: false, + })} + > + + + + + ); +}; + const App: FC = () => { return ( @@ -62,14 +168,14 @@ const App: FC = () => { backgroundColor: 'transparent', }, headerTintColor: colors.white, - headerBackTitle: ' ', + headerBackTitle: 'Back', headerBackAccessibilityLabel: 'Go back', }} > {Examples.map((item) => ( { options={{ title: item.title }} /> ))} + {demos.map((item) => ( + + ))} ); }; +export default App; + +const ItemSeparatorComponent = () => ; + +const ItemSeparatorComponentSmall = () => ( + + + + + +); + const styles = StyleSheet.create({ container: { flex: 1, @@ -93,6 +219,7 @@ const styles = StyleSheet.create({ fontSize: 24, fontWeight: '700', color: colors.white, + lineHeight: 24, }, subtitle: { opacity: 0.6, @@ -104,10 +231,45 @@ const styles = StyleSheet.create({ borderRadius: layout.radius, paddingVertical: layout.spacing * 2, paddingHorizontal: layout.spacing * 2, + flexDirection: 'row', + alignItems: 'flex-start', + gap: layout.spacing, + }, + buttonInner: { + flexShrink: 1, + }, + buttonSmall: { + padding: layout.spacing, + flexDirection: 'row', + alignItems: 'center', + gap: layout.spacing, + width: '48%', + margin: '1%', + height: 40, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.white, + }, + titleSmall: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginLeft: 4, + flexShrink: 1, + }, + subtitleSmall: { + opacity: 0.6, + color: colors.white, }, - scrollView: { - padding: layout.spacing * 2, + scrollView: {}, + hr: { + // height: StyleSheet.hairlineWidth, + // // backgroundColor: colors.border, + // marginLeft: 60, + // marginRight: '35%', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: layout.spacing, }, }); - -export default App; diff --git a/apps/common-app/src/components/BGGradient.tsx b/apps/common-app/src/components/BGGradient.tsx index 15962e358..f3079587f 100644 --- a/apps/common-app/src/components/BGGradient.tsx +++ b/apps/common-app/src/components/BGGradient.tsx @@ -1,6 +1,6 @@ +import { Canvas, RadialGradient, Rect, vec } from '@shopify/react-native-skia'; import React, { useCallback, useState } from 'react'; import { LayoutChangeEvent, StyleSheet, View } from 'react-native'; -import { vec, Rect, Canvas, RadialGradient } from '@shopify/react-native-skia'; import { colors } from '../styles'; @@ -19,7 +19,7 @@ const BGGradient = () => { diff --git a/apps/common-app/src/components/Container.tsx b/apps/common-app/src/components/Container.tsx index 19797d5f4..c777b6793 100644 --- a/apps/common-app/src/components/Container.tsx +++ b/apps/common-app/src/components/Container.tsx @@ -1,26 +1,35 @@ import React, { PropsWithChildren } from 'react'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; -import BGGradient from './BGGradient'; import { colors } from '../styles'; type ContainerProps = PropsWithChildren<{ style?: StyleProp; centered?: boolean; disablePadding?: boolean; + headless?: boolean; }>; const headerPadding = 120; // eyeballed const Container: React.FC = (props) => { - const { children, style, centered, disablePadding } = props; + const { children, style, centered, disablePadding, headless } = props; return ( - + edges={ + headless + ? ['bottom', 'left', 'right', 'top'] + : ['bottom', 'left', 'right'] + } + style={[ + headless ? styles.basicHeadless : styles.basic, + centered && styles.centered, + !disablePadding && styles.padding, + style, + ]} + > {children} ); @@ -34,8 +43,13 @@ const styles = StyleSheet.create({ paddingTop: headerPadding, backgroundColor: colors.background, }, + basicHeadless: { + flex: 1, + backgroundColor: colors.background, + paddingTop: 20, + }, padding: { - padding: 24, + paddingHorizontal: 18, }, centered: { alignItems: 'center', diff --git a/apps/common-app/src/components/icons/PlayPauseIcon.tsx b/apps/common-app/src/components/icons/PlayPauseIcon.tsx index 9bc65eefa..7259ad01e 100644 --- a/apps/common-app/src/components/icons/PlayPauseIcon.tsx +++ b/apps/common-app/src/components/icons/PlayPauseIcon.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Svg, { Path, Rect } from 'react-native-svg'; import { View } from 'react-native'; +import Svg, { Path, Rect } from 'react-native-svg'; type Props = { isPlaying: boolean; @@ -8,7 +8,11 @@ type Props = { color?: string; }; -const PlayPauseIcon: React.FC = ({ isPlaying = true, size = 48, color = '#FFFFFF' }) => { +const PlayPauseIcon: React.FC = ({ + isPlaying = true, + size = 48, + color = '#FFFFFF', +}) => { return ( @@ -18,10 +22,7 @@ const PlayPauseIcon: React.FC = ({ isPlaying = true, size = 48, color = ' ) : ( - + )} diff --git a/apps/common-app/src/demos/Record/ControlPanel.tsx b/apps/common-app/src/demos/Record/ControlPanel.tsx new file mode 100644 index 000000000..7f033b82a --- /dev/null +++ b/apps/common-app/src/demos/Record/ControlPanel.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated from 'react-native-reanimated'; +import PauseButton from './PauseButton'; +import RecordButton from './RecordButton'; +import { RecordingState } from './types'; + +interface ControlPanelProps { + state: RecordingState; + onToggleState: (action: RecordingState) => void; +} + +const ControlPanel: FC = ({ state, onToggleState }) => { + return ( + + { + onToggleState(RecordingState.Paused); + }} + /> + + + ); +}; + +export default ControlPanel; + +const styles = StyleSheet.create({ + controlPanelView: { + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 12, + }, +}); diff --git a/apps/common-app/src/demos/Record/PauseButton.tsx b/apps/common-app/src/demos/Record/PauseButton.tsx new file mode 100644 index 000000000..af5d79e5a --- /dev/null +++ b/apps/common-app/src/demos/Record/PauseButton.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedProps, + withSpring, +} from 'react-native-reanimated'; +import { RecordingState } from './types'; + +interface PauseButtonProps { + state: RecordingState; + onPress: () => void; +} + +const PauseButton: FC = ({ state, onPress }) => ( + + + {({ pressed }) => } + + +); + +export default PauseButton; + +const size = 24; +const innerSize = size * 0.3; + +const PauseButtonInner: FC<{ + pressed: boolean; + state: RecordingState; +}> = ({ pressed, state }) => { + const leftViewStyle = useAnimatedProps(() => { + return { + transform: [ + { + translateX: withSpring(pressed ? 6 : 0), + }, + ], + }; + }); + + const rightViewStyle = useAnimatedProps(() => { + return { + transform: [ + { + translateX: withSpring(pressed ? -6 : 0), + }, + ], + }; + }); + + const containerStyle = useAnimatedProps(() => { + return { + transform: [ + { + scale: withSpring(state === RecordingState.Recording ? 1 : 0), + }, + ], + }; + }); + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + pressable: { + position: 'absolute', + left: -size, + top: -size / 2, + }, + container: { + width: size, + height: size, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: innerSize - 2, + }, + bar: { + width: innerSize, + height: size, + backgroundColor: '#d4d4d4', + borderRadius: 2, + }, +}); diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx new file mode 100644 index 000000000..b07cc209d --- /dev/null +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -0,0 +1,168 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { AudioManager } from 'react-native-audio-api'; + +import { Alert, StyleSheet, View } from 'react-native'; +import { Container } from '../../components'; + +import { audioRecorder as Recorder } from '../../singletons'; +import ControlPanel from './ControlPanel'; +import RecordingTime from './RecordingTime'; +import RecordingVisualization from './RecordingVisualization'; +import Status from './Status'; +import { RecordingState } from './types'; + +AudioManager.setAudioSessionOptions({ + iosCategory: 'playAndRecord', + iosMode: 'default', + iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], +}); + +const Record: FC = () => { + const [state, setState] = useState(RecordingState.Idle); + const [hasPermissions, setHasPermissions] = useState(false); + + const onStartRecording = useCallback(async () => { + if (state !== RecordingState.Idle) { + return; + } + + setState(RecordingState.Loading); + + if (!hasPermissions) { + const permissionStatus = await AudioManager.requestRecordingPermissions(); + + if (permissionStatus !== 'Granted') { + Alert.alert('Error', "Recording permissions are no't granted"); + return; + } + + setHasPermissions(true); + } + + const success = await AudioManager.setAudioSessionActivity(true); + + if (!success) { + Alert.alert('Error', 'Failed to activate audio session for recording.'); + return; + } + + const result = Recorder.start(); + + if (result.status === 'success') { + console.log('Recording started, file path:', result.path); + setState(RecordingState.Recording); + return; + } + + console.log('Recording start error:', result); + Alert.alert('Error', `Failed to start recording: ${result.message}`); + setState(RecordingState.Idle); + }, [state, hasPermissions]); + + const onPauseRecording = useCallback(() => { + Recorder.pause(); + setState(RecordingState.Paused); + }, []); + + const onResumeRecording = useCallback(() => { + Recorder.resume(); + setState(RecordingState.Recording); + }, []); + + const onStopRecording = useCallback(() => { + Recorder.stop(); + setState(RecordingState.ReadyToPlay); + }, []); + + const onPlayRecording = useCallback(() => { + if (state !== RecordingState.ReadyToPlay) { + return; + } + + setState(RecordingState.Playing); + }, [state]); + + const onToggleState = useCallback( + (action: RecordingState) => { + if (state === RecordingState.Paused) { + if (action === RecordingState.Recording) { + onResumeRecording(); + return; + } + } + + if (action === RecordingState.Recording) { + onStartRecording(); + return; + } + + if (action === RecordingState.Paused) { + onPauseRecording(); + return; + } + + if (action === RecordingState.Idle) { + if (state === RecordingState.Recording) { + onStopRecording(); + } else if (state === RecordingState.Playing) { + setState(RecordingState.Idle); + } + return; + } + + if (action === RecordingState.ReadyToPlay) { + onStopRecording(); + return; + } + + if (action === RecordingState.Playing) { + onPlayRecording(); + } + }, + [ + state, + onStartRecording, + onPauseRecording, + onStopRecording, + onResumeRecording, + onPlayRecording, + ] + ); + + useEffect(() => { + (async () => { + const permissionStatus = await AudioManager.checkRecordingPermissions(); + + if (permissionStatus === 'Granted') { + setHasPermissions(true); + } + })(); + }, []); + + useEffect(() => { + Recorder.enableFileOutput(); + + return () => { + Recorder.disableFileOutput(); + }; + }, []); + + return ( + + + + + + + + + + ); +}; + +export default Record; + +const styles = StyleSheet.create({ + spacerM: { height: 24 }, + spacerS: { height: 12 }, +}); diff --git a/apps/common-app/src/demos/Record/RecordButton.tsx b/apps/common-app/src/demos/Record/RecordButton.tsx new file mode 100644 index 000000000..51ce84c71 --- /dev/null +++ b/apps/common-app/src/demos/Record/RecordButton.tsx @@ -0,0 +1,193 @@ +import React, { FC, useCallback } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import Animated, { + useAnimatedProps, + withRepeat, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import Svg, { Path } from 'react-native-svg'; + +import { RecordingState } from './types'; + +interface RecordButtonProps { + state: RecordingState; + onToggleState: (action: RecordingState) => void; +} + +const RecordButton: FC = ({ state, onToggleState }) => { + const onPress = useCallback(() => { + if ([RecordingState.Idle, RecordingState.Paused].includes(state)) { + onToggleState(RecordingState.Recording); + return; + } + + if (state === RecordingState.Recording) { + onToggleState(RecordingState.ReadyToPlay); + return; + } + + if (state === RecordingState.ReadyToPlay) { + onToggleState(RecordingState.Playing); + return; + } + + if (state === RecordingState.Playing) { + onToggleState(RecordingState.Idle); + } + }, [onToggleState, state]); + + return ( + + {({ pressed }) => } + + ); +}; + +export default RecordButton; + +type OuterStyle = { + borderColor: string; + transform: { + scale: number; + }[]; +}; + +const RecordButtonInner: FC<{ + pressed: boolean; + state: RecordingState; +}> = ({ pressed, state }) => { + const outerViewStyle = useAnimatedProps((): OuterStyle => { + if (state === RecordingState.Loading) { + return { + borderColor: '#d4d4d4', + transform: [ + { + scale: withRepeat( + withSequence(withSpring(1), withSpring(0.3)), + -1, + true + ), + }, + ], + }; + } + + return { + borderColor: withTiming( + state === RecordingState.ReadyToPlay ? '#ff6259' : 'white' + ), + transform: [ + { + scale: pressed ? withSpring(0.9) : withSpring(1), + }, + ], + }; + }); + + const innerViewStyle = useAnimatedProps(() => { + let size = 40; + + if ( + state === RecordingState.Recording || + state === RecordingState.Playing + ) { + size = 32; + } + + if (state === RecordingState.ReadyToPlay) { + size = 0; + } + + const transform = (() => { + if (state === RecordingState.Loading) { + return [ + { + scale: withRepeat( + withSequence(withSpring(1), withSpring(5)), + -1, + true + ), + }, + ]; + } + + if (pressed) { + return [ + { + scale: withSpring(1.6), + }, + ]; + } + + return [ + { + scale: withSpring(1), + }, + ]; + })(); + + return { + borderRadius: + state === RecordingState.Recording || state === RecordingState.Playing + ? withTiming(4) + : withTiming(20), + width: withTiming(size), + height: withTiming(size), + transform, + }; + }); + + const playStyle = useAnimatedProps(() => { + const scale = state === RecordingState.ReadyToPlay ? 1 : 0; + return { + transform: [ + { + scale: withTiming(scale), + }, + ], + }; + }); + + return ( + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + innerContainer: { + width: 60, + height: 60, + borderRadius: 30, + borderWidth: 2, + alignItems: 'center', + justifyContent: 'center', + }, + mainIcon: { + position: 'absolute', + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#ff6259', + }, + playIconContainer: { + position: 'absolute', + top: -8, + left: -8, + overflow: 'hidden', + }, +}); diff --git a/apps/common-app/src/demos/Record/RecordingTime.tsx b/apps/common-app/src/demos/Record/RecordingTime.tsx new file mode 100644 index 000000000..d354b1b08 --- /dev/null +++ b/apps/common-app/src/demos/Record/RecordingTime.tsx @@ -0,0 +1,83 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, TextInput } from 'react-native'; +import Animated, { + useAnimatedProps, + useSharedValue, +} from 'react-native-reanimated'; + +import { audioRecorder as Recorder } from '../../singletons'; +import { colors } from '../../styles'; +import { RecordingState } from './types'; + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); + +interface RecordingTimeProps { + state: RecordingState; +} + +const RecordingTime: React.FC = ({ state }) => { + const durationStringSV = useSharedValue('00:00:000'); + const isMountedSV = useSharedValue(true); + + useEffect(() => { + isMountedSV.value = true; + if (![RecordingState.Recording, RecordingState.Paused].includes(state)) { + durationStringSV.value = '00:00:00'; + return; + } + + const interval = setInterval(() => { + if (!isMountedSV.value) { + return; + } + + const elapsedSeconds = Recorder.getCurrentDuration(); + + const minutes = Math.floor((elapsedSeconds % 3600) / 60) + .toString() + .padStart(2, '0'); + const seconds = Math.floor(elapsedSeconds % 60) + .toString() + .padStart(2, '0'); + const milliseconds = Math.floor((elapsedSeconds % 1) * 1000) + .toString() + .padStart(3, '0'); + + durationStringSV.value = `${minutes}:${seconds}:${milliseconds}`; + }, 100); + + return () => { + isMountedSV.value = false; + clearInterval(interval); + }; + }, [state, durationStringSV, isMountedSV]); + + const animatedText = useAnimatedProps(() => { + return { + text: durationStringSV.value, + defaultValue: '00:00:000', + }; + }); + + return ( + + ); +}; + +export default RecordingTime; + +const styles = StyleSheet.create({ + text: { + color: colors.gray, + fontSize: 48, + width: '100%', + textAlign: 'center', + fontFamily: 'courier-new', + fontWeight: 'bold', + fontVariant: ['tabular-nums'], + }, +}); diff --git a/apps/common-app/src/demos/Record/RecordingVisualization.tsx b/apps/common-app/src/demos/Record/RecordingVisualization.tsx new file mode 100644 index 000000000..69fc7a17d --- /dev/null +++ b/apps/common-app/src/demos/Record/RecordingVisualization.tsx @@ -0,0 +1,185 @@ +import { + Canvas, + Path, + Skia, + useCanvasRef, + useCanvasSize, +} from '@shopify/react-native-skia'; +import React, { useEffect, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { useDerivedValue, useSharedValue } from 'react-native-reanimated'; + +import { audioRecorder as Recorder } from '../../singletons'; +import { RecordingState } from './types'; + +interface RecordingVisualizationProps { + state: RecordingState; +} + +const constants = { + sampleRate: 3125, + updateIntervalMS: 42, + barWidth: 2, + barGap: 2, + minDb: -40, + maxDb: 0, +} as const; + +class RingBuffer { + private data: Float64Array; + private head = 0; + private count = 0; + + constructor(private readonly capacity: number) { + this.data = new Float64Array(capacity); + this.data.fill(-1); + this.head = capacity - 1; + this.count = capacity; + } + + push(v: number) { + this.head = (this.head + 1) % this.capacity; + this.data[this.head] = v; + + if (this.count < this.capacity) { + this.count++; + } + } + + toArray(): number[] { + const out = new Array(this.count); + + for (let i = 0; i < this.count; i++) { + const idx = + (this.head - this.count + i + this.capacity + 1) % this.capacity; + out[i] = this.data[idx]; + } + + return out; + } + + clear() { + this.data.fill(-1); + this.head = 0; + this.count = 0; + } + + size() { + return this.count; + } +} + +const RecordingVisualization: React.FC = ({ + state, +}) => { + const canvasRef = useCanvasRef(); + const { size } = useCanvasSize(canvasRef); + const barHeights = useSharedValue([]); + + const numBars = useMemo(() => { + if (size.width === 0) { + return 0; + } + + return Math.floor(size.width / (constants.barWidth + constants.barGap)); + }, [size.width]); + + const waveformPath = useDerivedValue(() => { + const path = Skia.Path.Make(); + const currentHeights = barHeights.value; + const canvasHeight = size.height; + + if (currentHeights.length === 0 || canvasHeight === 0) { + return path; + } + + currentHeights.forEach((height, index) => { + if (height < 0) { + return; + } + + const x = + index * (constants.barWidth + constants.barGap) + + constants.barWidth / 2; + const y1 = (canvasHeight - height) / 2; + const y2 = (canvasHeight + height) / 2; + + path.moveTo(x, y1); + path.lineTo(x, y2); + }); + + return path; + }, [size, numBars]); + + useEffect(() => { + if (numBars <= 0) { + return; + } + + const ringBuffer = new RingBuffer(numBars); + + Recorder.onAudioReady( + { + sampleRate: constants.sampleRate, + channelCount: 1, + bufferLength: + (constants.updateIntervalMS / 1000.0) * constants.sampleRate, + }, + (event) => { + const audioData = event.buffer.getChannelData(0); + + let maxValue = 0; + for (let i = 0; i < audioData.length; i++) { + if (Math.abs(audioData[i]) > maxValue) { + maxValue = Math.abs(audioData[i]); + } + } + + const db = maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; + let normalized = + (db - constants.minDb) / (constants.maxDb - constants.minDb); + normalized = Math.max(0, Math.min(1, normalized)); + + ringBuffer.push(Math.pow(normalized, 2) * size.height * 0.8); + barHeights.value = ringBuffer.toArray(); + } + ); + + return () => { + Recorder.clearOnAudioReady(); + }; + }, [numBars, size.height, barHeights]); + + useEffect(() => { + if (![RecordingState.Recording, RecordingState.Paused].includes(state)) { + barHeights.value = Array(numBars).fill(-1); + return; + } + }, [state, numBars, barHeights]); + + return ( + + + + + + ); +}; +export default RecordingVisualization; + +const styles = StyleSheet.create({ + container: { + height: 400, + width: '100%', + backgroundColor: 'rgba(0, 0, 0, 0.15)', + }, + canvas: { + flex: 1, + }, +}); diff --git a/apps/common-app/src/demos/Record/Status.tsx b/apps/common-app/src/demos/Record/Status.tsx new file mode 100644 index 000000000..afde28875 --- /dev/null +++ b/apps/common-app/src/demos/Record/Status.tsx @@ -0,0 +1,45 @@ +import React, { FC } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { RecordingState } from './types'; + +interface StatusProps { + state: RecordingState; +} + +const Status: FC = ({ state }) => { + let statusText = ''; + + if (state === RecordingState.Idle) { + statusText = 'Idle'; + } else if (state === RecordingState.Loading) { + statusText = 'Loading...'; + } else if (state === RecordingState.Recording) { + statusText = 'Recording...'; + } else if (state === RecordingState.Paused) { + statusText = 'Paused'; + } else if (state === RecordingState.ReadyToPlay) { + statusText = 'Ready to Play'; + } else if (state === RecordingState.Playing) { + statusText = 'Playing...'; + } + + return ( + + {statusText} + + ); +}; + +export default Status; + +const styles = StyleSheet.create({ + statusView: { + marginTop: 40, + alignItems: 'center', + }, + statusText: { + fontSize: 18, + color: 'white', + }, +}); diff --git a/apps/common-app/src/demos/Record/types.ts b/apps/common-app/src/demos/Record/types.ts new file mode 100644 index 000000000..edcf19433 --- /dev/null +++ b/apps/common-app/src/demos/Record/types.ts @@ -0,0 +1,8 @@ +export enum RecordingState { + Idle = 'Idle', + Loading = 'Loading', + Recording = 'Recording', + Paused = 'Paused', + ReadyToPlay = 'ReadyToPlay', + Playing = 'Playing', +} diff --git a/apps/common-app/src/demos/index.ts b/apps/common-app/src/demos/index.ts new file mode 100644 index 000000000..2742281dd --- /dev/null +++ b/apps/common-app/src/demos/index.ts @@ -0,0 +1,27 @@ +import { icons } from 'lucide-react-native'; + +import Record from './Record/Record'; + +interface SimplifiedIconProps { + color?: string; + size?: number; +} + +export interface DemoScreen { + key: string; + title: string; + subtitle: string; + icon: React.FC; + screen: React.FC; +} + +export const demos: DemoScreen[] = [ + { + key: 'RecordDemo', + title: 'Recorder', + subtitle: + 'Demonstrates microphone permissions, capture, and playback similar to voice memos app.', + icon: icons.Mic, + screen: Record, + }, +] as const; diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index b5a741fc3..8679b760e 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -1,11 +1,15 @@ -import React, { useCallback, useEffect, useState, FC } from 'react'; -import { ActivityIndicator, View, StyleSheet } from 'react-native'; -import { AudioManager, PlaybackNotificationManager } from 'react-native-audio-api'; -import { Container, Button, Spacer } from '../../components'; -import AudioPlayer from './AudioPlayer'; -import { colors } from '../../styles'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Alert, StyleSheet, View } from 'react-native'; +import { + AudioManager, + PlaybackNotificationManager, +} from 'react-native-audio-api'; import BackgroundTimer from 'react-native-background-timer'; +import { Button, Container, Spacer } from '../../components'; +import { colors } from '../../styles'; +import AudioPlayer from './AudioPlayer'; + const URL = 'https://software-mansion.github.io/react-native-audio-api/audio/voice/example-voice-01.mp3'; @@ -13,7 +17,7 @@ const AudioFile: FC = () => { const [isPlaying, setIsPlaying] = useState(false); const [isLoading, setIsLoading] = useState(false); const [positionPercentage, setPositionPercentage] = useState(0); - const [shouldResume, setShouldResume] = useState(false); + const [wasPlaying, setWasPlaying] = useState(false); const togglePlayPause = async () => { if (isPlaying) { @@ -23,6 +27,22 @@ const AudioFile: FC = () => { setPositionPercentage(offset); }); + AudioManager.setAudioSessionOptions({ + iosCategory: 'playback', + iosMode: 'default', + iosOptions: [], + }); + + const success = await AudioManager.setAudioSessionActivity(true); + + if (!success) { + Alert.alert( + 'Audio Session Error', + 'Failed to activate audio session for playback.' + ); + return; + } + await AudioPlayer.play(); AudioManager.observeAudioInterruptions(true); @@ -69,7 +89,6 @@ const AudioFile: FC = () => { setupNotification(); AudioManager.observeAudioInterruptions(true); - AudioManager.activelyReclaimSession(true); // Listen to notification control events const playListener = PlaybackNotificationManager.addEventListener( @@ -106,27 +125,21 @@ const AudioFile: FC = () => { const interruptionSubscription = AudioManager.addSystemEventListener( 'interruption', async (event) => { - if (event.type === 'began') { - // Store whether we were playing before interruption - setShouldResume(isPlaying && event.isTransient); - - if (isPlaying) { - await AudioPlayer.pause(); - setIsPlaying(false); - } - } else if (event.type === 'ended') { - - if (shouldResume) { - BackgroundTimer.setTimeout(async () => { - AudioManager.setAudioSessionActivity(true); - await AudioPlayer.play(); - setIsPlaying(true); - console.log('Auto-resumed after transient interruption'); - }, 500); - } - - // Reset the flag - setShouldResume(false); + if (event.type === 'began' && isPlaying) { + await AudioPlayer.pause(); + setIsPlaying(false); + setWasPlaying(true); + return; + } + + if (event.type === 'ended' && wasPlaying) { + BackgroundTimer.setTimeout(async () => { + AudioManager.setAudioSessionActivity(true); + await AudioPlayer.play(); + setIsPlaying(true); + setWasPlaying(false); + console.log('Auto-resumed after transient interruption'); + }, 500); } } ); @@ -141,7 +154,7 @@ const AudioFile: FC = () => { AudioPlayer.reset(); console.log('Cleanup AudioFile component'); }; - }, [fetchAudioBuffer]); + }, [fetchAudioBuffer, isPlaying, wasPlaying]); return ( diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index 91ef3695b..80240f192 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -1,7 +1,10 @@ -import { AudioContext, PlaybackNotificationManager } from 'react-native-audio-api'; import type { - AudioBufferSourceNode, AudioBuffer, + AudioBufferSourceNode, +} from 'react-native-audio-api'; +import { + AudioContext, + PlaybackNotificationManager, } from 'react-native-audio-api'; class AudioPlayer { diff --git a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx index 638239b00..ce4043225 100644 --- a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx +++ b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx @@ -1,15 +1,15 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; import { - AudioContext, AnalyserNode, AudioBuffer, AudioBufferSourceNode, + AudioContext, } from 'react-native-audio-api'; -import { ActivityIndicator, View } from 'react-native'; -import FreqTimeChart from './FreqTimeChart'; -import { Container, Button } from '../../components'; +import { Button, Container } from '../../components'; import { layout } from '../../styles'; +import FreqTimeChart from './FreqTimeChart'; const FFT_SIZE = 512; diff --git a/apps/common-app/src/examples/Record/Record.tsx b/apps/common-app/src/examples/Record/Record.tsx index 3cc8c60c3..a35b0d9fe 100644 --- a/apps/common-app/src/examples/Record/Record.tsx +++ b/apps/common-app/src/examples/Record/Record.tsx @@ -1,178 +1,243 @@ -import React, { useRef, FC, useEffect } from 'react'; +import React, { FC, useEffect, useState } from 'react'; +import { Alert, Text, View } from 'react-native'; import { - AudioContext, - AudioManager, - AudioRecorder, - RecorderAdapterNode, - AudioBufferSourceNode, AudioBuffer, + AudioManager, RecordingNotificationManager, } from 'react-native-audio-api'; -import { Container, Button } from '../../components'; -import { View, Text } from 'react-native'; +import { Button, Container } from '../../components'; import { colors } from '../../styles'; -const SAMPLE_RATE = 16000; +import { audioContext, audioRecorder } from '../../singletons'; + +enum Status { + Idle = 'Idle', + LiveEcho = 'LiveEcho', + Recording = 'Recording', + Playback = 'Playback', +} const Record: FC = () => { - const recorderRef = useRef(null); - const aCtxRef = useRef(null); - const recorderAdapterRef = useRef(null); - const audioBuffersRef = useRef([]); - const sourcesRef = useRef([]); + const [status, setStatus] = useState(Status.Idle); + const [capturedBuffers, setCapturedBuffers] = useState([]); - useEffect(() => { - const setup = async () => { - try { - await AudioManager.requestNotificationPermissions(); - await AudioManager.requestRecordingPermissions(); - } catch (err) { - console.log(err); - console.error('Recording permission denied', err); - return; - } - recorderRef.current = new AudioRecorder({ - sampleRate: SAMPLE_RATE, - bufferLengthInSamples: SAMPLE_RATE, - }); - }; + const verifyPermissions = async () => { + const recPerm = await AudioManager.requestRecordingPermissions(); + const notPerm = await AudioManager.requestNotificationPermissions(); - setup(); - return () => { - aCtxRef.current?.close(); - stopRecorder(); - }; - }, []); + return recPerm === 'Granted' && notPerm === 'Granted'; + }; + + const startEcho = async () => { + const hasPermission = await verifyPermissions(); + if (!hasPermission) { + Alert.alert( + 'Insufficient permissions!', + 'You need to grant audio recording permissions to use this feature.' + ); + return; + } - const setupRecording = () => { AudioManager.setAudioSessionOptions({ iosCategory: 'playAndRecord', - iosMode: 'spokenAudio', + iosMode: 'default', iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], }); - }; - const stopRecorder = () => { - if (recorderRef.current) { - RecordingNotificationManager.unregister(); - recorderRef.current.stop(); - console.log('Recording stopped'); - // advised, but not required - AudioManager.setAudioSessionOptions({ - iosCategory: 'playback', - iosMode: 'default', - }); - } else { - console.error('AudioRecorder is not initialized'); - } - }; + const success = await AudioManager.setAudioSessionActivity(true); - const startEcho = () => { - if (!recorderRef.current) { - console.error('AudioContext or AudioRecorder is not initialized'); + if (!success) { + Alert.alert( + 'Audio Session Error', + 'Failed to activate audio session for recording.' + ); return; } - setupRecording(); - aCtxRef.current = new AudioContext({ sampleRate: SAMPLE_RATE }); - recorderAdapterRef.current = aCtxRef.current.createRecorderAdapter(); - recorderAdapterRef.current.connect(aCtxRef.current.destination); - recorderRef.current.connect(recorderAdapterRef.current); + if (audioContext.state === 'suspended') { + await audioContext.resume(); + } - recorderRef.current.start(); + if (audioContext.state !== 'running') { + Alert.alert( + 'Audio Context Error', + `Audio context is not running. Current state: ${audioContext.state}` + ); + return; + } + + const adapter = audioContext.createRecorderAdapter(); + adapter.connect(audioContext.destination); + audioRecorder.connect(adapter); // This is not a proper way to do it, but sufficient for this example - RecordingNotificationManager.register().then(() => RecordingNotificationManager.show({ - title: 'Recording...', - state: 'recording', - enabled: true, - })); - console.log('Recording started'); - console.log('Audio context state:', aCtxRef.current.state); - if (aCtxRef.current.state === 'suspended') { - console.log('Resuming audio context'); - aCtxRef.current.resume(); + RecordingNotificationManager.register().then(() => + RecordingNotificationManager.show({ + title: 'Recording...', + state: 'recording', + enabled: true, + }) + ); + + const result = audioRecorder.start(); + + if (result.status === 'error') { + Alert.alert( + 'Recording Error', + `Failed to start recording: ${result.message}` + ); + return; } + + setStatus(Status.LiveEcho); }; /// This stops only the recording, not the audio context const stopEcho = () => { - stopRecorder(); - aCtxRef.current = null; - recorderAdapterRef.current = null; + audioRecorder.stop(); + audioRecorder.disconnect(); + setStatus(Status.Idle); + AudioManager.setAudioSessionActivity(false); }; - const startRecordReplay = () => { - if (!recorderRef.current) { - console.error('AudioRecorder is not initialized'); + const startRecordForReplay = async () => { + const hasPermission = await verifyPermissions(); + + if (!hasPermission) { + Alert.alert( + 'Insufficient permissions!', + 'You need to grant audio recording permissions to use this feature.' + ); return; } - setupRecording(); - audioBuffersRef.current = []; - - recorderRef.current.onAudioReady((event) => { - const { buffer, numFrames } = event; - console.log('Audio recorder buffer ready:', buffer.duration, numFrames); - audioBuffersRef.current.push(buffer); + AudioManager.setAudioSessionOptions({ + iosCategory: 'playAndRecord', + iosMode: 'default', + iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], }); + const success = await AudioManager.setAudioSessionActivity(true); + + if (!success) { + Alert.alert( + 'Audio Session Error', + 'Failed to activate audio session for recording.' + ); + return; + } + + setCapturedBuffers([]); + + const callbackResult = audioRecorder.onAudioReady( + { + sampleRate: audioContext.sampleRate, + channelCount: 1, + bufferLength: 4096, + }, + (event) => { + const { buffer, numFrames } = event; + + console.log('Audio recorder buffer ready:', buffer.duration, numFrames); + setCapturedBuffers((prevBuffers) => [...prevBuffers, buffer]); + } + ); + + if (callbackResult.status === 'error') { + Alert.alert( + 'Recorder Error', + `Failed to set audio ready callback: ${callbackResult.message}` + ); + return; + } + // This is not a proper way to do it, but sufficient for this example - RecordingNotificationManager.register().then(() => RecordingNotificationManager.show({ - title: 'Recording for replay...', - state: 'recording', - enabled: true, - })); - recorderRef.current.start(); - - setTimeout(() => { - stopRecorder(); - }, 5000); - }; + RecordingNotificationManager.register().then(() => + RecordingNotificationManager.show({ + title: 'Recording for replay...', + state: 'recording', + enabled: true, + }) + ); - const stopRecordReplay = () => { - RecordingNotificationManager.unregister(); - const aCtx = new AudioContext({ sampleRate: SAMPLE_RATE }); - aCtxRef.current = aCtx; + const startResult = audioRecorder.start(); - if (aCtx.state === 'suspended') { - aCtx.resume(); + if (startResult.status === 'error') { + Alert.alert( + 'Recording Error', + `Failed to start recording: ${startResult.message}` + ); + return; } - const tNow = aCtx.currentTime; - let nextStartAt = tNow + 1; - const buffers = audioBuffersRef.current; + setStatus(Status.Recording); + + setTimeout(async () => { + audioRecorder.stop(); + audioRecorder.clearOnAudioReady(); + await AudioManager.setAudioSessionActivity(false); + await RecordingNotificationManager.unregister(); + setStatus(Status.Idle); + }, 5000); + }; + + const onStartReplay = async () => { + AudioManager.setAudioSessionOptions({ + iosCategory: 'playback', + iosMode: 'default', + iosOptions: [], + }); - console.log(tNow, nextStartAt, buffers.length); + const success = await AudioManager.setAudioSessionActivity(true); - for (let i = 0; i < buffers.length; i++) { - const source = aCtx.createBufferSource(); - source.buffer = buffers[i]; + if (!success) { + Alert.alert( + 'Audio Session Error', + 'Failed to activate audio session for playback.' + ); + return; + } - source.connect(aCtx.destination); - sourcesRef.current.push(source); + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + const tNow = audioContext.currentTime; + let nextStartAt = tNow + 1; + + capturedBuffers.forEach((buffer) => { + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.connect(audioContext.destination); source.start(nextStartAt); - nextStartAt += buffers[i].duration; - } + nextStartAt += buffer.duration; + }); + + setStatus(Status.Playback); setTimeout( () => { - console.log('clearing data'); - audioBuffersRef.current = []; - sourcesRef.current = []; + setStatus(Status.Idle); }, (nextStartAt - tNow) * 1000 ); }; + useEffect(() => { + return () => { + audioRecorder.stop(); + }; + }, []); + return ( - - Sample rate: {SAMPLE_RATE} - - + + + Status: {status} + + + Echo