Skip to content

Commit

Permalink
Merge pull request #127 from braden-w/feat/faster-enumeration-of-reco…
Browse files Browse the repository at this point in the history
…rding-devices

feat: faster enumeration of recorder devices
  • Loading branch information
braden-w authored Jun 30, 2024
2 parents 6287529 + c00a117 commit fac2ece
Show file tree
Hide file tree
Showing 14 changed files with 181 additions and 147 deletions.
2 changes: 1 addition & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "app",
"version": "4.2.1",
"version": "4.3.0",
"private": true,
"scripts": {
"dev": "vite dev",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "4.2.1"
version = "4.3.0"
description = "A Tauri App"
authors = ["you"]
license = ""
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"package": {
"productName": "Whispering",
"version": "4.2.1"
"version": "4.3.0"
},
"tauri": {
"allowlist": {
Expand Down
107 changes: 73 additions & 34 deletions apps/app/src/lib/services/MediaRecorderServiceWebLive.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import { settings } from '$lib/stores/settings.svelte.js';
import { WhisperingError } from '@repo/shared';
import AudioRecorder from 'audio-recorder-polyfill';
import { Effect, Layer } from 'effect';
import { Data, Effect, Either, Layer } from 'effect';
import { nanoid } from 'nanoid/non-secure';
import { MediaRecorderService } from './MediaRecorderService.js';
import { ToastService } from './ToastService.js';

class GetStreamError extends Data.TaggedError('GetStreamError')<{
recordingDeviceId: string;
}> {}

const getStreamForDeviceId = (recordingDeviceId: string) =>
Effect.tryPromise({
try: async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: recordingDeviceId },
channelCount: 1, // Mono audio is usually sufficient for voice recording
sampleRate: 16000, // 16 kHz is a good balance for voice
echoCancellation: true,
noiseSuppression: true,
},
});
return stream;
},
catch: () => new GetStreamError({ recordingDeviceId }),
});

export const MediaRecorderServiceWebLive = Layer.effect(
MediaRecorderService,
Effect.gen(function* () {
const { toast } = yield* ToastService;
let stream: MediaStream | null = null;
let mediaRecorder: MediaRecorder | null = null;
const recordedChunks: Blob[] = [];
Expand All @@ -17,47 +42,61 @@ export const MediaRecorderServiceWebLive = Layer.effect(
mediaRecorder = null;
};

const enumerateRecordingDevices = Effect.tryPromise({
try: async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
stream.getTracks().forEach((track) => track.stop());
const audioInputDevices = devices.filter((device) => device.kind === 'audioinput');
return audioInputDevices;
},
catch: (error) =>
new WhisperingError({
title: 'Error enumerating recording devices',
description: 'Please make sure you have given permission to access your audio devices',
error: error,
}),
});

return {
get recordingState() {
if (!mediaRecorder) return 'inactive';
return mediaRecorder.state;
},
enumerateRecordingDevices: Effect.tryPromise({
try: async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
stream.getTracks().forEach((track) => track.stop());
const audioInputDevices = devices.filter((device) => device.kind === 'audioinput');
return audioInputDevices;
},
catch: (error) =>
new WhisperingError({
title: 'Error enumerating recording devices',
description: 'Please make sure you have given permission to access your audio devices',
error: error,
}),
}),
enumerateRecordingDevices,
startRecording: (recordingDeviceId: string) =>
Effect.gen(function* () {
stream = yield* Effect.tryPromise({
try: () =>
navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: recordingDeviceId },
channelCount: 1, // Mono audio is usually sufficient for voice recording
sampleRate: 16000, // 16 kHz is a good balance for voice
echoCancellation: true,
noiseSuppression: true,
},
stream = yield* getStreamForDeviceId(recordingDeviceId).pipe(
Effect.catchAll(() =>
Effect.gen(function* () {
const defaultingToFirstAvailableDeviceToastId = nanoid();
yield* toast({
id: defaultingToFirstAvailableDeviceToastId,
variant: 'loading',
title: 'No device selected or selected device is not available',
description: 'Defaulting to first available audio input device...',
});
const recordingDevices = yield* enumerateRecordingDevices;
for (const device of recordingDevices) {
const deviceStream = yield* Effect.either(getStreamForDeviceId(device.deviceId));
if (Either.isRight(deviceStream)) {
settings.selectedAudioInputDeviceId = device.deviceId;
yield* toast({
id: defaultingToFirstAvailableDeviceToastId,
variant: 'info',
title: 'Defaulted to first available audio input device',
description: device.label,
});
return deviceStream.right;
}
}
return yield* new WhisperingError({
title: 'No available audio input devices',
description: 'Please make sure you have a microphone connected',
});
}),
catch: (error) =>
new WhisperingError({
title: 'Error getting media stream',
description:
'Please make sure you have given permission to access your audio devices',
error: error,
}),
});
),
);
recordedChunks.length = 0;
mediaRecorder = new AudioRecorder(stream!, {
mimeType: 'audio/webm;codecs=opus',
Expand Down
147 changes: 70 additions & 77 deletions apps/app/src/lib/stores/recorder.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,49 +7,46 @@ import { SetTrayIconService } from '$lib/services/SetTrayIconService';
import { SetTrayIconServiceDesktopLive } from '$lib/services/SetTrayIconServiceDesktopLive';
import { SetTrayIconServiceWebLive } from '$lib/services/SetTrayIconServiceWebLive';
import { ToastServiceLive } from '$lib/services/ToastServiceLive';
import { recordings, settings } from '$lib/stores';
import { NotificationService, WhisperingError, type RecorderState } from '@repo/shared';
import { ToastService } from '$lib/services/ToastService';
import { recordings } from '$lib/stores';
import {
NotificationService,
WhisperingError,
type RecorderState,
type Settings,
} from '@repo/shared';
import { Effect } from 'effect';
import { nanoid } from 'nanoid/non-secure';
import type { Recording } from '../services/RecordingDbService';
import { renderErrorAsToast } from '../services/errors';
import stopSoundSrc from './assets/sound_ex_machina_Button_Blip.mp3';
import startSoundSrc from './assets/zapsplat_household_alarm_clock_button_press_12967.mp3';
import cancelSoundSrc from './assets/zapsplat_multimedia_click_button_short_sharp_73510.mp3';
import { goto } from '$app/navigation';

const startSound = new Audio(startSoundSrc);
const stopSound = new Audio(stopSoundSrc);
const cancelSound = new Audio(cancelSoundSrc);

export let recorderState = (() => {
export let recorderState = Effect.gen(function* () {
const { setTrayIcon } = yield* SetTrayIconService;
let value = $state<RecorderState>('IDLE');
return {
get value() {
return value;
},
set value(newValue: RecorderState) {
value = newValue;
Effect.gen(function* () {
const { setTrayIcon } = yield* SetTrayIconService;
yield* setTrayIcon(newValue);
}).pipe(
Effect.provide(
window.__TAURI__ ? SetTrayIconServiceDesktopLive : SetTrayIconServiceWebLive,
),
Effect.catchAll(renderErrorAsToast),
Effect.runPromise,
);
setTrayIcon(newValue).pipe(Effect.catchAll(renderErrorAsToast), Effect.runPromise);
},
};
})();
}).pipe(
Effect.provide(window.__TAURI__ ? SetTrayIconServiceDesktopLive : SetTrayIconServiceWebLive),
Effect.runSync,
);

const IS_RECORDING_NOTIFICATION_ID = 'WHISPERING_RECORDING_NOTIFICATION';

export const recorder = Effect.gen(function* () {
const mediaRecorderService = yield* MediaRecorderService;
const { toast } = yield* ToastService;
const { notify } = yield* NotificationService;

return {
Expand All @@ -64,7 +61,7 @@ export const recorder = Effect.gen(function* () {
}),
Effect.runPromise,
),
toggleRecording: () =>
toggleRecording: (settings: Settings) =>
Effect.gen(function* () {
if (!settings.apiKey) {
return yield* new WhisperingError({
Expand All @@ -77,74 +74,70 @@ export const recorder = Effect.gen(function* () {
});
}

const recordingDevices = yield* mediaRecorderService.enumerateRecordingDevices;
const isSelectedDeviceExists = recordingDevices.some(
({ deviceId }) => deviceId === settings.selectedAudioInputDeviceId,
);
if (!isSelectedDeviceExists) {
yield* toast({
variant: 'info',
title: 'Defaulting to first available audio input device...',
description: 'No device selected or selected device is not available',
});
const firstAudioInput = recordingDevices[0].deviceId;
settings.selectedAudioInputDeviceId = firstAudioInput;
}

switch (mediaRecorderService.recordingState) {
case 'inactive':
yield* mediaRecorderService.startRecording(settings.selectedAudioInputDeviceId);
if (settings.isPlaySoundEnabled) {
if (!document.hidden) {
startSound.play();
} else {
yield* sendMessageToExtension({
name: 'external/playSound',
body: { sound: 'start' },
});
}
}
yield* Effect.logInfo('Recording started');
recorderState.value = 'RECORDING';
yield* notify({
id: IS_RECORDING_NOTIFICATION_ID,
title: 'Whispering is recording...',
description: 'Click to go to recorder',
action: {
label: 'Go to recorder',
goto: '/',
},
});
yield* Effect.all([
Effect.sync(() => (recorderState.value = 'RECORDING')),
Effect.logInfo('Recording started'),
Effect.gen(function* () {
if (settings.isPlaySoundEnabled) {
if (!document.hidden) {
startSound.play();
} else {
yield* sendMessageToExtension({
name: 'external/playSound',
body: { sound: 'start' },
});
}
}
}).pipe(Effect.catchAll(renderErrorAsToast)),
notify({
id: IS_RECORDING_NOTIFICATION_ID,
title: 'Whispering is recording...',
description: 'Click to go to recorder',
action: {
label: 'Go to recorder',
goto: '/',
},
}).pipe(Effect.catchAll(renderErrorAsToast)),
]);
return;
case 'recording':
const audioBlob = yield* mediaRecorderService.stopRecording;
if (settings.isPlaySoundEnabled) {
if (!document.hidden) {
stopSound.play();
} else {
yield* sendMessageToExtension({
name: 'external/playSound',
body: { sound: 'stop' },
});
}
}
yield* Effect.logInfo('Recording stopped');
recorderState.value = 'IDLE';
const newRecording: Recording = {
id: nanoid(),
title: '',
subtitle: '',
timestamp: new Date().toISOString(),
transcribedText: '',
blob: audioBlob,
transcriptionStatus: 'UNPROCESSED',
};
yield* recordings.addRecording(newRecording);
recordings.transcribeRecording(newRecording.id);
yield* Effect.all([
Effect.sync(() => (recorderState.value = 'IDLE')),
Effect.logInfo('Recording stopped'),
Effect.gen(function* () {
if (settings.isPlaySoundEnabled) {
if (!document.hidden) {
stopSound.play();
} else {
yield* sendMessageToExtension({
name: 'external/playSound',
body: { sound: 'stop' },
});
}
}
}).pipe(Effect.catchAll(renderErrorAsToast)),
Effect.gen(function* () {
const newRecording: Recording = {
id: nanoid(),
title: '',
subtitle: '',
timestamp: new Date().toISOString(),
transcribedText: '',
blob: audioBlob,
transcriptionStatus: 'UNPROCESSED',
};
yield* recordings.addRecording(newRecording);
recordings.transcribeRecording(newRecording.id);
}).pipe(Effect.catchAll(renderErrorAsToast)),
]);
return;
}
}).pipe(Effect.catchAll(renderErrorAsToast), Effect.runPromise),
cancelRecording: () =>
cancelRecording: (settings: Settings) =>
Effect.gen(function* () {
yield* mediaRecorderService.cancelRecording;
if (settings.isPlaySoundEnabled) {
Expand Down
Loading

0 comments on commit fac2ece

Please sign in to comment.