diff --git a/apps/app/src/lib/services/MediaRecorderService.svelte.ts b/apps/app/src/lib/services/MediaRecorderService.svelte.ts new file mode 100644 index 000000000..fea514498 --- /dev/null +++ b/apps/app/src/lib/services/MediaRecorderService.svelte.ts @@ -0,0 +1,218 @@ +import { settings } from '$lib/stores/settings.svelte.js'; +import { WhisperingError } from '@repo/shared'; +import AudioRecorder from 'audio-recorder-polyfill'; +import { Data, Effect, Either } from 'effect'; +import { nanoid } from 'nanoid/non-secure'; +import { ToastService } from './ToastService.js'; + +export const enumerateRecordingDevices = Effect.tryPromise({ + try: async () => { + const allAudioDevicesStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const devices = await navigator.mediaDevices.enumerateDevices(); + allAudioDevicesStream.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, + }), +}); + +class GetStreamError extends Data.TaggedError('GetStreamError')<{ + recordingDeviceId: string; +}> {} + +class TryResuseStreamError extends Data.TaggedError('TryResuseStreamError') {} + +export const MediaRecorderService = Effect.gen(function* () { + const { toast } = yield* ToastService; + let mediaRecorder: MediaRecorder | null = null; + const recordedChunks: Blob[] = []; + + const resetRecorder = () => { + recordedChunks.length = 0; + mediaRecorder = null; + }; + + return { + get recordingState() { + if (!mediaRecorder) return 'inactive'; + return mediaRecorder.state; + }, + startRecording: (preferredRecordingDeviceId: string) => + Effect.gen(function* () { + const connectingToRecordingDeviceToastId = nanoid(); + yield* toast({ + id: connectingToRecordingDeviceToastId, + variant: 'loading', + title: 'Connecting to audio input device...', + description: 'Please allow access to your microphone if prompted.', + }); + const maybeResusedStream = yield* mediaStream.init({ + shouldReuseStream: true, + preferredRecordingDeviceId, + }); + yield* toast({ + id: connectingToRecordingDeviceToastId, + variant: 'success', + title: 'Connected to audio input device', + description: 'Successfully connected to your microphone stream.', + }); + if (mediaRecorder) { + return yield* new WhisperingError({ + title: 'Unexpected media recorder already exists', + description: + 'It seems like it was not properly deinitialized after the previous stopRecording or cancelRecording call.', + }); + } + const newMediaRecorder = yield* Effect.try({ + try: () => + new AudioRecorder(maybeResusedStream, { + mimeType: 'audio/webm;codecs=opus', + sampleRate: 16000, + }) as MediaRecorder, + catch: () => new TryResuseStreamError(), + }).pipe( + Effect.catchAll(() => + Effect.gen(function* () { + yield* toast({ + variant: 'loading', + title: 'Error initializing media recorder with preferred device', + description: 'Trying to find another available audio input device...', + }); + const stream = yield* mediaStream.init({ shouldReuseStream: false }); + return new AudioRecorder(stream, { + mimeType: 'audio/webm;codecs=opus', + sampleRate: 16000, + }) as MediaRecorder; + }), + ), + ); + newMediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => { + if (!event.data.size) return; + recordedChunks.push(event.data); + }); + newMediaRecorder.start(); + mediaRecorder = newMediaRecorder; + }), + stopRecording: Effect.async((resume) => { + if (!mediaRecorder) return; + mediaRecorder.addEventListener('stop', () => { + const audioBlob = new Blob(recordedChunks, { type: 'audio/wav' }); + resume(Effect.succeed(audioBlob)); + resetRecorder(); + }); + mediaRecorder.stop(); + }).pipe( + Effect.catchAll((error) => { + resetRecorder(); + return new WhisperingError({ + title: 'Error canceling media recorder', + description: error instanceof Error ? error.message : 'Please try again', + error: error, + }); + }), + ), + cancelRecording: Effect.async((resume) => { + if (!mediaRecorder) return; + mediaRecorder.addEventListener('stop', () => { + resetRecorder(); + resume(Effect.succeed(undefined)); + }); + mediaRecorder.stop(); + }).pipe( + Effect.catchAll((error) => { + resetRecorder(); + return new WhisperingError({ + title: 'Error stopping media recorder', + description: error instanceof Error ? error.message : 'Please try again', + error: error, + }); + }), + ), + }; +}); + +export const mediaStream = Effect.gen(function* () { + let internalStream = $state(null); + + 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 }), + }); + + const getFirstAvailableStream = Effect.gen(function* () { + const { toast } = yield* ToastService; + 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', + }); + }); + + return { + get isStreamOpen() { + return internalStream !== null; + }, + init: ({ + shouldReuseStream, + preferredRecordingDeviceId, + }: { + shouldReuseStream: boolean; + preferredRecordingDeviceId?: string; + }) => + Effect.gen(function* () { + if (shouldReuseStream && internalStream) { + const reusedStream = internalStream; + return reusedStream; + } + const newStream = preferredRecordingDeviceId + ? yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( + Effect.catchAll(() => getFirstAvailableStream), + ) + : yield* getFirstAvailableStream; + internalStream = newStream; + return newStream; + }), + destroy: () => { + internalStream?.getTracks().forEach((track) => track.stop()); + internalStream = null; + }, + enumerateRecordingDevices, + }; +}).pipe(Effect.runSync); diff --git a/apps/app/src/lib/services/MediaRecorderService.ts b/apps/app/src/lib/services/MediaRecorderService.ts deleted file mode 100644 index 99379f4a5..000000000 --- a/apps/app/src/lib/services/MediaRecorderService.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { WhisperingError } from '@repo/shared'; -import { Context, Effect } from 'effect'; - -export class MediaRecorderService extends Context.Tag('MediaRecorderService')< - MediaRecorderService, - { - readonly recordingState: RecordingState; - readonly enumerateRecordingDevices: Effect.Effect; - readonly startRecording: ( - recordingDeviceId: string, - ) => Effect.Effect; - readonly stopRecording: Effect.Effect; - readonly cancelRecording: Effect.Effect; - } ->() {} diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts deleted file mode 100644 index a6d46c01b..000000000 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { settings } from '$lib/stores/settings.svelte.js'; -import { WhisperingError } from '@repo/shared'; -import AudioRecorder from 'audio-recorder-polyfill'; -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[] = []; - - const resetRecorder = () => { - recordedChunks.length = 0; - stream?.getTracks().forEach((track) => track.stop()); - stream = null; - 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, - startRecording: (recordingDeviceId: string) => - Effect.gen(function* () { - stream = yield* Effect.gen(function* () { - const connectingToRecordingDeviceToastId = nanoid(); - yield* toast({ - id: connectingToRecordingDeviceToastId, - variant: 'loading', - title: 'Connecting to audio input device...', - description: 'Please allow access to your microphone if prompted.', - }); - const stream = yield* getStreamForDeviceId(recordingDeviceId); - yield* toast({ - id: connectingToRecordingDeviceToastId, - variant: 'success', - title: 'Connected to audio input device', - description: 'Successfully connected to your microphone stream.', - }); - return stream; - }).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', - }); - }), - ), - ); - recordedChunks.length = 0; - mediaRecorder = new AudioRecorder(stream!, { - mimeType: 'audio/webm;codecs=opus', - sampleRate: 16000, - }); - mediaRecorder!.addEventListener('dataavailable', (event: BlobEvent) => { - if (!event.data.size) return; - recordedChunks.push(event.data); - }); - mediaRecorder!.start(); - }), - stopRecording: Effect.async((resume) => { - if (!mediaRecorder) return; - mediaRecorder.addEventListener('stop', () => { - const audioBlob = new Blob(recordedChunks, { type: 'audio/wav' }); - resume(Effect.succeed(audioBlob)); - resetRecorder(); - }); - mediaRecorder.stop(); - }).pipe( - Effect.catchAll((error) => { - resetRecorder(); - return new WhisperingError({ - title: 'Error canceling media recorder', - description: error instanceof Error ? error.message : 'Please try again', - error: error, - }); - }), - ), - cancelRecording: Effect.async((resume) => { - if (!mediaRecorder) return; - mediaRecorder.addEventListener('stop', () => { - resetRecorder(); - resume(Effect.succeed(undefined)); - }); - mediaRecorder.stop(); - }).pipe( - Effect.catchAll((error) => { - resetRecorder(); - return new WhisperingError({ - title: 'Error stopping media recorder', - description: error instanceof Error ? error.message : 'Please try again', - error: error, - }); - }), - ), - }; - }), -); diff --git a/apps/app/src/lib/stores/recorder.svelte.ts b/apps/app/src/lib/stores/recorder.svelte.ts index 0db42d277..ba999b14f 100644 --- a/apps/app/src/lib/stores/recorder.svelte.ts +++ b/apps/app/src/lib/stores/recorder.svelte.ts @@ -1,6 +1,8 @@ import { sendMessageToExtension } from '$lib/sendMessageToExtension'; -import { MediaRecorderService } from '$lib/services/MediaRecorderService'; -import { MediaRecorderServiceWebLive } from '$lib/services/MediaRecorderServiceWebLive'; +import { + MediaRecorderService, + enumerateRecordingDevices, +} from '$lib/services/MediaRecorderService.svelte'; import { NotificationServiceDesktopLive } from '$lib/services/NotificationServiceDesktopLive'; import { NotificationServiceWebLive } from '$lib/services/NotificationServiceWebLive'; import { SetTrayIconService } from '$lib/services/SetTrayIconService'; @@ -54,7 +56,7 @@ export const recorder = Effect.gen(function* () { return recorderState.value; }, enumerateRecordingDevices: () => - mediaRecorderService.enumerateRecordingDevices.pipe( + enumerateRecordingDevices.pipe( Effect.catchAll((error) => { renderErrorAsToast(error); return Effect.succeed([] as MediaDeviceInfo[]); @@ -136,7 +138,11 @@ export const recorder = Effect.gen(function* () { ]); return; } - }).pipe(Effect.catchAll(renderErrorAsToast), Effect.runPromise), + }).pipe( + Effect.catchAll(renderErrorAsToast), + Effect.provide(ToastServiceLive), + Effect.runPromise, + ), cancelRecording: (settings: Settings) => Effect.gen(function* () { yield* mediaRecorderService.cancelRecording; @@ -155,7 +161,6 @@ export const recorder = Effect.gen(function* () { }).pipe(Effect.runPromise), }; }).pipe( - Effect.provide(MediaRecorderServiceWebLive), Effect.provide(ToastServiceLive), Effect.provide(window.__TAURI__ ? NotificationServiceDesktopLive : NotificationServiceWebLive), Effect.runSync, diff --git a/apps/app/src/routes/(app)/+page.svelte b/apps/app/src/routes/(app)/+page.svelte index 492650fad..7fa668cb7 100644 --- a/apps/app/src/routes/(app)/+page.svelte +++ b/apps/app/src/routes/(app)/+page.svelte @@ -4,6 +4,7 @@ import { ClipboardIcon } from '$lib/components/icons'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; + import { mediaStream } from '$lib/services/MediaRecorderService.svelte'; import { recorder, recordings, settings } from '$lib/stores'; import { createRecordingViewTransitionName } from '$lib/utils/createRecordingViewTransitionName'; @@ -65,10 +66,21 @@ onclick={() => recorder.cancelRecording(settings)} variant="ghost" size="icon" - class="absolute -right-16 bottom-1.5 transform text-2xl hover:scale-110 focus:scale-110" + class="absolute -right-14 bottom-0 transform text-2xl hover:scale-110 focus:scale-110" > 🚫 + {:else if mediaStream.isStreamOpen} + + + 🔴 + {/if}