Skip to content

Commit c034100

Browse files
feat: solve for transcription not working on iOS
1 parent fb8f083 commit c034100

4 files changed

Lines changed: 179 additions & 23 deletions

File tree

__tests__/unit/services/whisperService.test.ts

Lines changed: 151 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
* Priority: P1 - Voice input support.
66
*/
77

8-
import { initWhisper } from 'whisper.rn';
8+
import { initWhisper, AudioSessionIos } from 'whisper.rn';
99
import { Platform, PermissionsAndroid } from 'react-native';
1010
import RNFS from 'react-native-fs';
1111
import { whisperService, WHISPER_MODELS } from '../../../src/services/whisperService';
1212

13+
const mockedAudioSessionIos = AudioSessionIos as jest.Mocked<typeof AudioSessionIos>;
14+
1315
const mockedRNFS = RNFS as jest.Mocked<typeof RNFS>;
1416
const mockedInitWhisper = initWhisper as jest.MockedFunction<typeof initWhisper>;
1517

@@ -256,35 +258,108 @@ describe('WhisperService', () => {
256258
Object.defineProperty(Platform, 'OS', { get: () => originalOS });
257259
});
258260

259-
it('returns true on Android when granted', async () => {
260-
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
261-
jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
262-
PermissionsAndroid.RESULTS.GRANTED
263-
);
261+
describe('Android', () => {
262+
beforeEach(() => {
263+
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
264+
});
264265

265-
expect(await whisperService.requestPermissions()).toBe(true);
266-
});
266+
it('returns true when granted', async () => {
267+
jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
268+
PermissionsAndroid.RESULTS.GRANTED
269+
);
267270

268-
it('returns false on Android when denied', async () => {
269-
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
270-
jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
271-
PermissionsAndroid.RESULTS.DENIED
272-
);
271+
expect(await whisperService.requestPermissions()).toBe(true);
272+
});
273273

274-
expect(await whisperService.requestPermissions()).toBe(false);
275-
});
274+
it('returns false when denied', async () => {
275+
jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
276+
PermissionsAndroid.RESULTS.DENIED
277+
);
276278

277-
it('returns true on iOS without requesting', async () => {
278-
Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
279+
expect(await whisperService.requestPermissions()).toBe(false);
280+
});
281+
282+
it('returns false on permission error', async () => {
283+
jest.spyOn(PermissionsAndroid, 'request').mockRejectedValue(new Error('Permission error'));
284+
285+
expect(await whisperService.requestPermissions()).toBe(false);
286+
});
287+
288+
it('does not call AudioSessionIos', async () => {
289+
jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
290+
PermissionsAndroid.RESULTS.GRANTED
291+
);
292+
293+
await whisperService.requestPermissions();
294+
295+
expect(mockedAudioSessionIos.setCategory).not.toHaveBeenCalled();
296+
expect(mockedAudioSessionIos.setMode).not.toHaveBeenCalled();
297+
expect(mockedAudioSessionIos.setActive).not.toHaveBeenCalled();
298+
});
279299

280-
expect(await whisperService.requestPermissions()).toBe(true);
300+
it('requests RECORD_AUDIO permission with correct message', async () => {
301+
const requestSpy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
302+
PermissionsAndroid.RESULTS.GRANTED
303+
);
304+
305+
await whisperService.requestPermissions();
306+
307+
expect(requestSpy).toHaveBeenCalledWith(
308+
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
309+
expect.objectContaining({
310+
title: 'Microphone Permission',
311+
buttonPositive: 'OK',
312+
})
313+
);
314+
});
281315
});
282316

283-
it('returns false on Android permission error', async () => {
284-
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
285-
jest.spyOn(PermissionsAndroid, 'request').mockRejectedValue(new Error('Permission error'));
317+
describe('iOS', () => {
318+
beforeEach(() => {
319+
Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
320+
});
321+
322+
it('configures audio session and returns true', async () => {
323+
expect(await whisperService.requestPermissions()).toBe(true);
286324

287-
expect(await whisperService.requestPermissions()).toBe(false);
325+
expect(mockedAudioSessionIos.setCategory).toHaveBeenCalledWith(
326+
'PlayAndRecord',
327+
['AllowBluetooth', 'MixWithOthers']
328+
);
329+
expect(mockedAudioSessionIos.setMode).toHaveBeenCalledWith('Default');
330+
expect(mockedAudioSessionIos.setActive).toHaveBeenCalledWith(true);
331+
});
332+
333+
it('calls setCategory before setMode before setActive', async () => {
334+
const callOrder: string[] = [];
335+
mockedAudioSessionIos.setCategory.mockImplementation(async () => { callOrder.push('setCategory'); });
336+
mockedAudioSessionIos.setMode.mockImplementation(async () => { callOrder.push('setMode'); });
337+
mockedAudioSessionIos.setActive.mockImplementation(async () => { callOrder.push('setActive'); });
338+
339+
await whisperService.requestPermissions();
340+
341+
expect(callOrder).toEqual(['setCategory', 'setMode', 'setActive']);
342+
});
343+
344+
it('returns false when audio session setup fails', async () => {
345+
mockedAudioSessionIos.setCategory.mockRejectedValue(new Error('Audio session error'));
346+
347+
expect(await whisperService.requestPermissions()).toBe(false);
348+
});
349+
350+
it('returns false when setActive fails (permission denied)', async () => {
351+
mockedAudioSessionIos.setActive.mockRejectedValue(new Error('Microphone permission denied'));
352+
353+
expect(await whisperService.requestPermissions()).toBe(false);
354+
});
355+
356+
it('does not call PermissionsAndroid', async () => {
357+
const requestSpy = jest.spyOn(PermissionsAndroid, 'request');
358+
359+
await whisperService.requestPermissions();
360+
361+
expect(requestSpy).not.toHaveBeenCalled();
362+
});
288363
});
289364
});
290365

@@ -376,6 +451,60 @@ describe('WhisperService', () => {
376451
);
377452
});
378453

454+
it('includes audioSessionOnStartIos options on iOS', async () => {
455+
const mockContext = {
456+
id: 'ctx',
457+
release: jest.fn(),
458+
transcribeRealtime: jest.fn(() => Promise.resolve({
459+
stop: jest.fn(),
460+
subscribe: jest.fn(),
461+
})),
462+
transcribe: jest.fn(),
463+
};
464+
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
465+
await whisperService.loadModel('/path/model.bin');
466+
467+
Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
468+
469+
await whisperService.startRealtimeTranscription(jest.fn());
470+
471+
expect(mockContext.transcribeRealtime).toHaveBeenCalledWith(
472+
expect.objectContaining({
473+
audioSessionOnStartIos: expect.objectContaining({
474+
category: 'PlayAndRecord',
475+
options: ['AllowBluetooth', 'MixWithOthers'],
476+
mode: 'Default',
477+
}),
478+
audioSessionOnStopIos: 'restore',
479+
})
480+
);
481+
});
482+
483+
it('does not include audioSession options on Android', async () => {
484+
const mockContext = {
485+
id: 'ctx',
486+
release: jest.fn(),
487+
transcribeRealtime: jest.fn(() => Promise.resolve({
488+
stop: jest.fn(),
489+
subscribe: jest.fn(),
490+
})),
491+
transcribe: jest.fn(),
492+
};
493+
mockedInitWhisper.mockResolvedValueOnce(mockContext as any);
494+
await whisperService.loadModel('/path/model.bin');
495+
496+
Object.defineProperty(Platform, 'OS', { get: () => 'android' });
497+
jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue(
498+
PermissionsAndroid.RESULTS.GRANTED
499+
);
500+
501+
await whisperService.startRealtimeTranscription(jest.fn());
502+
503+
const callArgs = mockContext.transcribeRealtime.mock.calls[0][0];
504+
expect(callArgs.audioSessionOnStartIos).toBeUndefined();
505+
expect(callArgs.audioSessionOnStopIos).toBeUndefined();
506+
});
507+
379508
it('forwards events to callback via subscribe', async () => {
380509
let subscribeFn: any;
381510
const mockContext = {

ios/LocalLLM/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
</dict>
3636
<key>NSLocationWhenInUseUsageDescription</key>
3737
<string></string>
38+
<key>NSMicrophoneUsageDescription</key>
39+
<string>This app needs access to your microphone for voice-to-text transcription using Whisper.</string>
3840
<key>RCTNewArchEnabled</key>
3941
<true/>
4042
<key>UIAppFonts</key>

jest.setup.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ jest.mock('whisper.rn', () => ({
137137
segments: [],
138138
})),
139139
transcribeRealtime: jest.fn(() => Promise.resolve()),
140+
AudioSessionIos: {
141+
setCategory: jest.fn(() => Promise.resolve()),
142+
setMode: jest.fn(() => Promise.resolve()),
143+
setActive: jest.fn(() => Promise.resolve()),
144+
},
140145
}), { virtual: true });
141146

142147
// react-native-fs mock

src/services/whisperService.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { initWhisper, WhisperContext, RealtimeTranscribeEvent } from 'whisper.rn';
1+
import { initWhisper, WhisperContext, RealtimeTranscribeEvent, AudioSessionIos } from 'whisper.rn';
22
import { Platform, PermissionsAndroid } from 'react-native';
33
import RNFS from 'react-native-fs';
44

@@ -183,6 +183,18 @@ class WhisperService {
183183
return false;
184184
}
185185
}
186+
if (Platform.OS === 'ios') {
187+
try {
188+
// Configure audio session for recording - this also triggers the permission prompt
189+
await AudioSessionIos.setCategory('PlayAndRecord', ['AllowBluetooth', 'MixWithOthers']);
190+
await AudioSessionIos.setMode('Default');
191+
await AudioSessionIos.setActive(true);
192+
return true;
193+
} catch (error) {
194+
console.error('[Whisper] iOS audio session/permission error:', error);
195+
return false;
196+
}
197+
}
186198
return true;
187199
}
188200

@@ -227,6 +239,14 @@ class WhisperService {
227239
maxLen: options?.maxLen || 0, // 0 = no limit
228240
realtimeAudioSec: 30, // Process in 30-second chunks
229241
realtimeAudioSliceSec: 3, // Slice every 3 seconds for faster intermediate results
242+
...(Platform.OS === 'ios' && {
243+
audioSessionOnStartIos: {
244+
category: 'PlayAndRecord',
245+
options: ['AllowBluetooth', 'MixWithOthers'],
246+
mode: 'Default',
247+
},
248+
audioSessionOnStopIos: 'restore',
249+
}),
230250
});
231251

232252
console.log('[WhisperService] transcribeRealtime started successfully');

0 commit comments

Comments
 (0)