Skip to content

Commit 36daec1

Browse files
committed
feat: add expo-audio support for voice recordings as well
1 parent c5620f7 commit 36daec1

File tree

5 files changed

+203
-10
lines changed

5 files changed

+203
-10
lines changed

examples/ExpoMessaging/app.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@
6363
"supportsPictureInPicture": true
6464
}
6565
],
66-
"expo-audio",
67-
{
68-
"microphonePermission": "$(PRODUCT_NAME) would like to use your microphone for voice recording."
69-
}
66+
[
67+
"expo-audio",
68+
{
69+
"microphonePermission": "$(PRODUCT_NAME) would like to use your microphone for voice recording."
70+
}
71+
]
7072
]
7173
}
7274
}

package/expo-package/src/optionalDependencies/Audio.ts

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
1+
import { Platform } from 'react-native';
2+
13
import {
24
AndroidAudioEncoder,
35
AndroidOutputFormat,
46
ExpoAudioRecordingConfiguration as AudioRecordingConfiguration,
57
IOSAudioQuality,
68
IOSOutputFormat,
79
ExpoRecordingOptions as RecordingOptions,
10+
RecordingStatus,
811
} from 'stream-chat-react-native-core';
912

1013
import { AudioComponent, RecordingObject } from './AudioVideo';
1114

15+
let ExpoAudioComponent;
16+
let ExpoRecordingComponent;
17+
18+
try {
19+
const { AudioModule } = require('expo-audio');
20+
ExpoAudioComponent = AudioModule;
21+
ExpoRecordingComponent = AudioModule.AudioRecorder;
22+
} catch (e) {
23+
// do nothing
24+
}
25+
1226
const sleep = (ms: number) =>
1327
new Promise<void>((resolve) => {
1428
setTimeout(() => {
1529
resolve();
1630
}, ms);
1731
});
1832

19-
class _Audio {
33+
class _AudioExpoAV {
2034
recording: typeof RecordingObject | null = null;
2135
audioRecordingConfiguration: AudioRecordingConfiguration = {
2236
mode: {
@@ -105,8 +119,185 @@ class _Audio {
105119
};
106120
}
107121

122+
class _AudioExpoAudio {
123+
recording: typeof RecordingObject | null = null;
124+
audioRecordingConfiguration: AudioRecordingConfiguration = {
125+
mode: {
126+
allowsRecordingIOS: true,
127+
playsInSilentModeIOS: true,
128+
},
129+
options: {
130+
android: {
131+
audioEncoder: AndroidAudioEncoder.AAC,
132+
extension: '.aac',
133+
outputFormat: AndroidOutputFormat.AAC_ADTS,
134+
},
135+
ios: {
136+
audioQuality: IOSAudioQuality.HIGH,
137+
bitRate: 128000,
138+
extension: '.aac',
139+
numberOfChannels: 2,
140+
outputFormat: IOSOutputFormat.MPEG4AAC,
141+
sampleRate: 44100,
142+
},
143+
isMeteringEnabled: true,
144+
web: {},
145+
},
146+
};
147+
148+
startRecording = async (recordingOptions: RecordingOptions, onRecordingStatusUpdate) => {
149+
try {
150+
const permissions = await ExpoAudioComponent.getRecordingPermissionsAsync();
151+
const permissionsStatus = permissions.status;
152+
let permissionsGranted = permissions.granted;
153+
154+
// If permissions have not been determined yet, ask the user for permissions.
155+
if (permissionsStatus === 'undetermined') {
156+
const newPermissions = await ExpoAudioComponent.requestRecordingPermissionsAsync();
157+
permissionsGranted = newPermissions.granted;
158+
}
159+
160+
// If they are explicitly denied after this, exit early by throwing an error
161+
// that will be caught in the catch block below (as a single source of not
162+
// starting the player). The player would error itself anyway if we did not do
163+
// this, but there's no reason to run the asynchronous calls when we know
164+
// immediately that the player will not be run.
165+
if (!permissionsGranted) {
166+
throw new Error('Missing audio recording permission.');
167+
}
168+
await ExpoAudioComponent.setAudioModeAsync(
169+
expoAvToExpoAudioModeAdapter(this.audioRecordingConfiguration.mode),
170+
);
171+
const options = {
172+
...recordingOptions,
173+
...this.audioRecordingConfiguration.options,
174+
};
175+
176+
this.recording = new ExpoAudioRecordingAdapter(options);
177+
await this.recording.createAsync(
178+
Platform.OS === 'android' ? 100 : 60,
179+
onRecordingStatusUpdate,
180+
);
181+
return { accessGranted: true, recording: this.recording };
182+
} catch (error) {
183+
console.error('Failed to start recording', error);
184+
this.recording = null;
185+
return { accessGranted: false, recording: null };
186+
}
187+
};
188+
stopRecording = async () => {
189+
try {
190+
if (this.recording) {
191+
await this.recording.stopAndUnloadAsync();
192+
}
193+
this.recording = null;
194+
} catch (error) {
195+
console.log('Error stopping recoding', error);
196+
}
197+
};
198+
}
199+
200+
class ExpoAudioRecordingAdapter {
201+
private recording;
202+
private recordingStateInterval;
203+
private uri;
204+
private options;
205+
206+
constructor(options: RecordingOptions) {
207+
// Currently, expo-audio has a bug where isMeteringEnabled is not respected
208+
// whenever we pass it to the Recording class constructor - but rather it is
209+
// only respected whenever you pass it to prepareToRecordAsync. That in turn
210+
// however, means that all other audio related configuration will be overwritten
211+
// and forgotten. So, we snapshot the configuration whenever we create an instance
212+
// of a recorder and pass it to both places. Furthermore, the type of the options
213+
// in prepareToRecordAsync is wrong - it's supposed to be the flattened config;
214+
// otherwise none of the quality properties get respected either (only the top level
215+
// ones).
216+
this.options = flattenExpoAudioRecordingOptions(options);
217+
this.recording = new ExpoRecordingComponent(this.options);
218+
this.uri = null;
219+
}
220+
221+
createAsync = async (
222+
progressUpdateInterval: number = 500,
223+
onRecordingStatusUpdate: (status: RecordingStatus) => void,
224+
) => {
225+
this.recordingStateInterval = setInterval(() => {
226+
const status = this.recording.getStatus();
227+
onRecordingStatusUpdate(status);
228+
}, progressUpdateInterval);
229+
this.uri = null;
230+
await this.recording.prepareToRecordAsync(this.options);
231+
this.recording.record();
232+
};
233+
234+
stopAndUnloadAsync = async () => {
235+
await this.recording.stop();
236+
this.uri = this.recording.uri;
237+
clearInterval(this.recordingStateInterval);
238+
this.recording.release();
239+
};
240+
241+
getURI = () => this.uri;
242+
}
243+
108244
export const overrideAudioRecordingConfiguration = (
109245
audioRecordingConfiguration: AudioRecordingConfiguration,
110246
) => audioRecordingConfiguration;
111247

112-
export const Audio = AudioComponent ? new _Audio() : null;
248+
const flattenExpoAudioRecordingOptions = (
249+
options: RecordingOptions & {
250+
bitRate?: number;
251+
extension?: string;
252+
numberOfChannels?: number;
253+
sampleRate?: number;
254+
},
255+
) => {
256+
let commonOptions = {
257+
bitRate: options.bitRate,
258+
extension: options.extension,
259+
isMeteringEnabled: options.isMeteringEnabled ?? false,
260+
numberOfChannels: options.numberOfChannels,
261+
sampleRate: options.sampleRate,
262+
};
263+
264+
if (Platform.OS === 'ios') {
265+
commonOptions = {
266+
...commonOptions,
267+
...options.ios,
268+
};
269+
} else if (Platform.OS === 'android') {
270+
commonOptions = {
271+
...commonOptions,
272+
...options.android,
273+
};
274+
}
275+
return commonOptions;
276+
};
277+
278+
const expoAvToExpoAudioModeAdapter = (mode: AudioRecordingConfiguration['mode']) => {
279+
const {
280+
allowsRecordingIOS,
281+
interruptionModeAndroid,
282+
interruptionModeIOS,
283+
playsInSilentModeIOS,
284+
playThroughEarpieceAndroid,
285+
staysActiveInBackground,
286+
} = mode;
287+
288+
return {
289+
allowsRecording: allowsRecordingIOS,
290+
interruptionMode: interruptionModeIOS,
291+
interruptionModeAndroid,
292+
playsInSilentMode: playsInSilentModeIOS,
293+
shouldPlayInBackground: staysActiveInBackground,
294+
shouldRouteThroughEarpiece: playThroughEarpieceAndroid,
295+
};
296+
};
297+
298+
// Always try to prioritize expo-audio if it's there.
299+
export const Audio = ExpoRecordingComponent
300+
? new _AudioExpoAudio()
301+
: AudioComponent
302+
? new _AudioExpoAV()
303+
: null;

package/expo-package/src/optionalDependencies/Sound.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ try {
1414
}
1515

1616
export const Sound = {
17+
// Always try to prioritize expo-audio if it's there.
1718
initializeSound: ExpoAudioComponent
1819
? async (source, initialStatus, onPlaybackStatusUpdate: (playbackStatus) => void) => {
1920
await ExpoAudioComponent.setAudioModeAsync({
@@ -92,7 +93,7 @@ class ExpoAudioSoundAdapter {
9293
// eslint-disable-next-line require-await
9394
unloadAsync: SoundReturnType['unloadAsync'] = async () => {
9495
this.statusEventListener.remove();
95-
this.player.remove();
96+
this.player.release();
9697
};
9798

9899
// eslint-disable-next-line require-await

package/src/components/MessageInput/hooks/useAudioController.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export const useAudioController = () => {
184184
if (accessGranted) {
185185
setPermissionsGranted(true);
186186
const recording = recordingInfo.recording;
187-
if (recording && typeof recording !== 'string') {
187+
if (recording && typeof recording !== 'string' && recording.setProgressUpdateInterval) {
188188
recording.setProgressUpdateInterval(Platform.OS === 'android' ? 100 : 60);
189189
}
190190
setRecording(recording);
@@ -256,7 +256,6 @@ export const useAudioController = () => {
256256
await stopVoiceRecording();
257257
}
258258

259-
console.log('DURATION?', recordingDuration);
260259
const durationInSeconds = parseFloat((recordingDuration / 1000).toFixed(3));
261260

262261
const resampledWaveformData = resampleWaveformData(waveformData, 100);

package/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import './polyfills';
44

55
export * from './components';
66
export * from './hooks';
7-
export { registerNativeHandlers, SoundReturnType, PlaybackStatus } from './native';
7+
export { registerNativeHandlers, SoundReturnType, PlaybackStatus, RecordingStatus } from './native';
88
export * from './contexts';
99
export * from './emoji-data';
1010

0 commit comments

Comments
 (0)