From fc2f63c0792b41f6a2ab3ba102bf0e2b7c782403 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:05:11 -0700 Subject: [PATCH 01/13] refactor: allAudioDevicesStream --- apps/app/src/lib/services/MediaRecorderServiceWebLive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index 65ee5791..4e140205 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -44,9 +44,9 @@ export const MediaRecorderServiceWebLive = Layer.effect( const enumerateRecordingDevices = Effect.tryPromise({ try: async () => { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const allAudioDevicesStream = await navigator.mediaDevices.getUserMedia({ audio: true }); const devices = await navigator.mediaDevices.enumerateDevices(); - stream.getTracks().forEach((track) => track.stop()); + allAudioDevicesStream.getTracks().forEach((track) => track.stop()); const audioInputDevices = devices.filter((device) => device.kind === 'audioinput'); return audioInputDevices; }, From f061a5f725e3aeee06d92acd34b1cb1e6136a06d Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:06:39 -0700 Subject: [PATCH 02/13] refactor: preferredRecordingDeviceId --- apps/app/src/lib/services/MediaRecorderServiceWebLive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index 4e140205..28baff1f 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -64,9 +64,9 @@ export const MediaRecorderServiceWebLive = Layer.effect( return mediaRecorder.state; }, enumerateRecordingDevices, - startRecording: (recordingDeviceId: string) => + startRecording: (preferredRecordingDeviceId: string) => Effect.gen(function* () { - stream = yield* getStreamForDeviceId(recordingDeviceId).pipe( + stream = yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( Effect.catchAll(() => Effect.gen(function* () { const defaultingToFirstAvailableDeviceToastId = nanoid(); From b5c70d2675766d13af28498e56e9d52342b0e7e0 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:09:58 -0700 Subject: [PATCH 03/13] refactor: getFirstAvailableStream --- .../services/MediaRecorderServiceWebLive.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index 28baff1f..3b381b1d 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -10,23 +10,6 @@ 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* () { @@ -58,6 +41,51 @@ export const MediaRecorderServiceWebLive = Layer.effect( }), }); + 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 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 recordingState() { if (!mediaRecorder) return 'inactive'; @@ -67,35 +95,7 @@ export const MediaRecorderServiceWebLive = Layer.effect( startRecording: (preferredRecordingDeviceId: string) => Effect.gen(function* () { stream = yield* getStreamForDeviceId(preferredRecordingDeviceId).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', - }); - }), - ), + Effect.catchAll(() => getFirstAvailableStream), ); recordedChunks.length = 0; mediaRecorder = new AudioRecorder(stream!, { From d6bf190bba35c83d05967027a321a1e4a2be2f03 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:10:42 -0700 Subject: [PATCH 04/13] chore: remove unnecessary recordedChunks.length = 0; --- apps/app/src/lib/services/MediaRecorderServiceWebLive.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index 3b381b1d..4807509f 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -97,7 +97,6 @@ export const MediaRecorderServiceWebLive = Layer.effect( stream = yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( Effect.catchAll(() => getFirstAvailableStream), ); - recordedChunks.length = 0; mediaRecorder = new AudioRecorder(stream!, { mimeType: 'audio/webm;codecs=opus', sampleRate: 16000, From 1037a5e0520d7c8a1e7e6f9140ee038b4d091ef2 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:20:57 -0700 Subject: [PATCH 05/13] feat: reuse stream and add many branches to check in startRecording --- .../services/MediaRecorderServiceWebLive.ts | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index 4807509f..baa1710f 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -12,6 +12,7 @@ class GetStreamError extends Data.TaggedError('GetStreamError')<{ export const MediaRecorderServiceWebLive = Layer.effect( MediaRecorderService, + // @ts-ignore Effect.gen(function* () { const { toast } = yield* ToastService; let stream: MediaStream | null = null; @@ -94,18 +95,44 @@ export const MediaRecorderServiceWebLive = Layer.effect( enumerateRecordingDevices, startRecording: (preferredRecordingDeviceId: string) => Effect.gen(function* () { - stream = yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( - Effect.catchAll(() => getFirstAvailableStream), - ); - mediaRecorder = new AudioRecorder(stream!, { - mimeType: 'audio/webm;codecs=opus', - sampleRate: 16000, + const maybeResusedStream = + stream ?? + (yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( + Effect.catchAll(() => getFirstAvailableStream), + )); + if (mediaRecorder) { + return yield* new WhisperingError({ + title: 'Unexpected media recorder already exists', + description: + 'It seems like it was not properly deinitialized after the previous recording session', + }); + } + const newMediaRecorder = yield* Effect.try({ + try: () => + new AudioRecorder(maybeResusedStream, { + mimeType: 'audio/webm;codecs=opus', + sampleRate: 16000, + }), + catch: () => + 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* getFirstAvailableStream; + return new AudioRecorder(stream, { + mimeType: 'audio/webm;codecs=opus', + sampleRate: 16000, + }); + }), }); - mediaRecorder!.addEventListener('dataavailable', (event: BlobEvent) => { + newMediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => { if (!event.data.size) return; recordedChunks.push(event.data); }); - mediaRecorder!.start(); + newMediaRecorder.start(); + mediaRecorder = newMediaRecorder; }), stopRecording: Effect.async((resume) => { if (!mediaRecorder) return; From 01c35441864a5ce06a0b6e0767278cd550248b96 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:37:21 -0700 Subject: [PATCH 06/13] feat: major speed boost: keep stream open --- apps/app/src/lib/services/MediaRecorderServiceWebLive.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index ab4a8453..a8e298fe 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -21,8 +21,8 @@ export const MediaRecorderServiceWebLive = Layer.effect( const resetRecorder = () => { recordedChunks.length = 0; - stream?.getTracks().forEach((track) => track.stop()); - stream = null; + // stream?.getTracks().forEach((track) => track.stop()); + // stream = null; mediaRecorder = null; }; @@ -145,6 +145,7 @@ export const MediaRecorderServiceWebLive = Layer.effect( recordedChunks.push(event.data); }); newMediaRecorder.start(); + stream = maybeResusedStream; mediaRecorder = newMediaRecorder; }), stopRecording: Effect.async((resume) => { From 57cf7aee80536b775c223f310545412c53d8c0ca Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:41:36 -0700 Subject: [PATCH 07/13] fix: typescript errors and implemented TryReuseStreamError --- .../lib/services/MediaRecorderServiceWebLive.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts index a8e298fe..9d24435f 100644 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts @@ -10,9 +10,10 @@ class GetStreamError extends Data.TaggedError('GetStreamError')<{ recordingDeviceId: string; }> {} +class TryResuseStreamError extends Data.TaggedError('TryResuseStreamError') {} + export const MediaRecorderServiceWebLive = Layer.effect( MediaRecorderService, - // @ts-ignore Effect.gen(function* () { const { toast } = yield* ToastService; let stream: MediaStream | null = null; @@ -125,8 +126,10 @@ export const MediaRecorderServiceWebLive = Layer.effect( new AudioRecorder(maybeResusedStream, { mimeType: 'audio/webm;codecs=opus', sampleRate: 16000, - }), - catch: () => + }) as MediaRecorder, + catch: () => new TryResuseStreamError(), + }).pipe( + Effect.catchAll(() => Effect.gen(function* () { yield* toast({ variant: 'loading', @@ -137,9 +140,10 @@ export const MediaRecorderServiceWebLive = Layer.effect( 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); From 73b1eb244876d2333ab833efd0d061527d2159a5 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 10:46:13 -0700 Subject: [PATCH 08/13] refactor: you don't always have to inject dependencies --- .../src/lib/services/MediaRecorderService.ts | 203 ++++++++++++++++-- .../services/MediaRecorderServiceWebLive.ts | 192 ----------------- apps/app/src/lib/stores/recorder.svelte.ts | 2 - 3 files changed, 188 insertions(+), 209 deletions(-) delete mode 100644 apps/app/src/lib/services/MediaRecorderServiceWebLive.ts diff --git a/apps/app/src/lib/services/MediaRecorderService.ts b/apps/app/src/lib/services/MediaRecorderService.ts index 99379f4a..e9d0d990 100644 --- a/apps/app/src/lib/services/MediaRecorderService.ts +++ b/apps/app/src/lib/services/MediaRecorderService.ts @@ -1,15 +1,188 @@ -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; - } ->() {} +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'; + +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 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 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, + }), + }); + + 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 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 recordingState() { + if (!mediaRecorder) return 'inactive'; + return mediaRecorder.state; + }, + enumerateRecordingDevices, + 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 = + stream ?? + (yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( + Effect.catchAll(() => getFirstAvailableStream), + )); + 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 recording session', + }); + } + 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* getFirstAvailableStream; + 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(); + stream = maybeResusedStream; + 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, + }); + }), + ), + }; +}); diff --git a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts b/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts deleted file mode 100644 index 9d24435f..00000000 --- a/apps/app/src/lib/services/MediaRecorderServiceWebLive.ts +++ /dev/null @@ -1,192 +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; -}> {} - -class TryResuseStreamError extends Data.TaggedError('TryResuseStreamError') {} - -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 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, - }), - }); - - 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 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 recordingState() { - if (!mediaRecorder) return 'inactive'; - return mediaRecorder.state; - }, - enumerateRecordingDevices, - 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 = - stream ?? - (yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( - Effect.catchAll(() => getFirstAvailableStream), - )); - 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 recording session', - }); - } - 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* getFirstAvailableStream; - 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(); - stream = maybeResusedStream; - 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, - }); - }), - ), - }; - }), -); diff --git a/apps/app/src/lib/stores/recorder.svelte.ts b/apps/app/src/lib/stores/recorder.svelte.ts index 0db42d27..c1b8c42f 100644 --- a/apps/app/src/lib/stores/recorder.svelte.ts +++ b/apps/app/src/lib/stores/recorder.svelte.ts @@ -1,6 +1,5 @@ import { sendMessageToExtension } from '$lib/sendMessageToExtension'; import { MediaRecorderService } from '$lib/services/MediaRecorderService'; -import { MediaRecorderServiceWebLive } from '$lib/services/MediaRecorderServiceWebLive'; import { NotificationServiceDesktopLive } from '$lib/services/NotificationServiceDesktopLive'; import { NotificationServiceWebLive } from '$lib/services/NotificationServiceWebLive'; import { SetTrayIconService } from '$lib/services/SetTrayIconService'; @@ -155,7 +154,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, From 12c043f7c6576f09605e0a0fc79a1d4b35d819b8 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 11:15:14 -0700 Subject: [PATCH 09/13] major feat: MediaStreamRecorderService --- .../src/lib/services/MediaRecorderService.ts | 171 ++++++++++-------- apps/app/src/lib/stores/recorder.svelte.ts | 3 +- 2 files changed, 102 insertions(+), 72 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderService.ts b/apps/app/src/lib/services/MediaRecorderService.ts index e9d0d990..c27f420b 100644 --- a/apps/app/src/lib/services/MediaRecorderService.ts +++ b/apps/app/src/lib/services/MediaRecorderService.ts @@ -13,7 +13,7 @@ class TryResuseStreamError extends Data.TaggedError('TryResuseStreamError') {} export const MediaRecorderService = Effect.gen(function* () { const { toast } = yield* ToastService; - let stream: MediaStream | null = null; + const mediaStreamService = yield* MediaStreamService; let mediaRecorder: MediaRecorder | null = null; const recordedChunks: Blob[] = []; @@ -24,73 +24,11 @@ export const MediaRecorderService = Effect.gen(function* () { mediaRecorder = null; }; - 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, - }), - }); - - 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 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 recordingState() { if (!mediaRecorder) return 'inactive'; return mediaRecorder.state; }, - enumerateRecordingDevices, startRecording: (preferredRecordingDeviceId: string) => Effect.gen(function* () { const connectingToRecordingDeviceToastId = nanoid(); @@ -100,11 +38,10 @@ export const MediaRecorderService = Effect.gen(function* () { title: 'Connecting to audio input device...', description: 'Please allow access to your microphone if prompted.', }); - const maybeResusedStream = - stream ?? - (yield* getStreamForDeviceId(preferredRecordingDeviceId).pipe( - Effect.catchAll(() => getFirstAvailableStream), - )); + const maybeResusedStream = yield* mediaStreamService.init({ + shouldReuseStream: true, + preferredRecordingDeviceId, + }); yield* toast({ id: connectingToRecordingDeviceToastId, variant: 'success', @@ -115,7 +52,7 @@ export const MediaRecorderService = Effect.gen(function* () { return yield* new WhisperingError({ title: 'Unexpected media recorder already exists', description: - 'It seems like it was not properly deinitialized after the previous recording session', + 'It seems like it was not properly deinitialized after the previous stopRecording or cancelRecording call.', }); } const newMediaRecorder = yield* Effect.try({ @@ -133,7 +70,7 @@ export const MediaRecorderService = Effect.gen(function* () { title: 'Error initializing media recorder with preferred device', description: 'Trying to find another available audio input device...', }); - const stream = yield* getFirstAvailableStream; + const stream = yield* mediaStreamService.init({ shouldReuseStream: false }); return new AudioRecorder(stream, { mimeType: 'audio/webm;codecs=opus', sampleRate: 16000, @@ -146,7 +83,6 @@ export const MediaRecorderService = Effect.gen(function* () { recordedChunks.push(event.data); }); newMediaRecorder.start(); - stream = maybeResusedStream; mediaRecorder = newMediaRecorder; }), stopRecording: Effect.async((resume) => { @@ -186,3 +122,96 @@ export const MediaRecorderService = Effect.gen(function* () { ), }; }); + +export const MediaStreamService = Effect.gen(function* () { + let internalStream: MediaStream | null = null; + + 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, + }), + }); + + 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; + }), + enumerateRecordingDevices, + }; +}); diff --git a/apps/app/src/lib/stores/recorder.svelte.ts b/apps/app/src/lib/stores/recorder.svelte.ts index c1b8c42f..f27cbfc5 100644 --- a/apps/app/src/lib/stores/recorder.svelte.ts +++ b/apps/app/src/lib/stores/recorder.svelte.ts @@ -46,6 +46,7 @@ const IS_RECORDING_NOTIFICATION_ID = 'WHISPERING_RECORDING_NOTIFICATION'; export const recorder = Effect.gen(function* () { const mediaRecorderService = yield* MediaRecorderService; + const mediaStreamService = yield* MediaStreamService; const { notify } = yield* NotificationService; return { @@ -53,7 +54,7 @@ export const recorder = Effect.gen(function* () { return recorderState.value; }, enumerateRecordingDevices: () => - mediaRecorderService.enumerateRecordingDevices.pipe( + mediaStreamService.enumerateRecordingDevices.pipe( Effect.catchAll((error) => { renderErrorAsToast(error); return Effect.succeed([] as MediaDeviceInfo[]); From 5c71f96233cd11c48859177c8bc42f1ae90f317f Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 11:15:20 -0700 Subject: [PATCH 10/13] fix: ToastServiceLive piped --- apps/app/src/lib/stores/recorder.svelte.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/stores/recorder.svelte.ts b/apps/app/src/lib/stores/recorder.svelte.ts index f27cbfc5..6400bd5d 100644 --- a/apps/app/src/lib/stores/recorder.svelte.ts +++ b/apps/app/src/lib/stores/recorder.svelte.ts @@ -1,5 +1,5 @@ import { sendMessageToExtension } from '$lib/sendMessageToExtension'; -import { MediaRecorderService } from '$lib/services/MediaRecorderService'; +import { MediaRecorderService, MediaStreamService } from '$lib/services/MediaRecorderService'; import { NotificationServiceDesktopLive } from '$lib/services/NotificationServiceDesktopLive'; import { NotificationServiceWebLive } from '$lib/services/NotificationServiceWebLive'; import { SetTrayIconService } from '$lib/services/SetTrayIconService'; @@ -136,7 +136,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; From 9001847deebb9e0716f70e9ca22c6ddf6a57842a Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 11:17:11 -0700 Subject: [PATCH 11/13] refactor: including enumerateRecordingDevices --- .../src/lib/services/MediaRecorderService.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderService.ts b/apps/app/src/lib/services/MediaRecorderService.ts index c27f420b..4ac59849 100644 --- a/apps/app/src/lib/services/MediaRecorderService.ts +++ b/apps/app/src/lib/services/MediaRecorderService.ts @@ -5,6 +5,21 @@ import { Data, Effect, Either } from 'effect'; import { nanoid } from 'nanoid/non-secure'; import { ToastService } from './ToastService.js'; +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; }> {} @@ -126,22 +141,6 @@ export const MediaRecorderService = Effect.gen(function* () { export const MediaStreamService = Effect.gen(function* () { let internalStream: MediaStream | null = null; - 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, - }), - }); - const getStreamForDeviceId = (recordingDeviceId: string) => Effect.tryPromise({ try: async () => { From ae46b895bfd3762cce4010db8442b2cdf1f44261 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 11:18:48 -0700 Subject: [PATCH 12/13] fix: restore destroy mediaStream --- apps/app/src/lib/services/MediaRecorderService.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/services/MediaRecorderService.ts b/apps/app/src/lib/services/MediaRecorderService.ts index 4ac59849..f649cc9f 100644 --- a/apps/app/src/lib/services/MediaRecorderService.ts +++ b/apps/app/src/lib/services/MediaRecorderService.ts @@ -34,9 +34,8 @@ export const MediaRecorderService = Effect.gen(function* () { const resetRecorder = () => { recordedChunks.length = 0; - // stream?.getTracks().forEach((track) => track.stop()); - // stream = null; mediaRecorder = null; + mediaStreamService.destroy(); }; return { @@ -211,6 +210,10 @@ export const MediaStreamService = Effect.gen(function* () { internalStream = newStream; return newStream; }), + destroy: () => { + internalStream?.getTracks().forEach((track) => track.stop()); + internalStream = null; + }, enumerateRecordingDevices, }; }); From 5150f9716e35b28f6e896408aed9a7444bd94a2d Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sun, 30 Jun 2024 15:40:12 -0700 Subject: [PATCH 13/13] major feat: default keep stream open, user can close it with cancel buton --- ...rService.ts => MediaRecorderService.svelte.ts} | 15 +++++++-------- apps/app/src/lib/stores/recorder.svelte.ts | 8 +++++--- apps/app/src/routes/(app)/+page.svelte | 14 +++++++++++++- 3 files changed, 25 insertions(+), 12 deletions(-) rename apps/app/src/lib/services/{MediaRecorderService.ts => MediaRecorderService.svelte.ts} (94%) diff --git a/apps/app/src/lib/services/MediaRecorderService.ts b/apps/app/src/lib/services/MediaRecorderService.svelte.ts similarity index 94% rename from apps/app/src/lib/services/MediaRecorderService.ts rename to apps/app/src/lib/services/MediaRecorderService.svelte.ts index f649cc9f..fea51449 100644 --- a/apps/app/src/lib/services/MediaRecorderService.ts +++ b/apps/app/src/lib/services/MediaRecorderService.svelte.ts @@ -5,7 +5,7 @@ import { Data, Effect, Either } from 'effect'; import { nanoid } from 'nanoid/non-secure'; import { ToastService } from './ToastService.js'; -const enumerateRecordingDevices = Effect.tryPromise({ +export const enumerateRecordingDevices = Effect.tryPromise({ try: async () => { const allAudioDevicesStream = await navigator.mediaDevices.getUserMedia({ audio: true }); const devices = await navigator.mediaDevices.enumerateDevices(); @@ -20,6 +20,7 @@ const enumerateRecordingDevices = Effect.tryPromise({ error: error, }), }); + class GetStreamError extends Data.TaggedError('GetStreamError')<{ recordingDeviceId: string; }> {} @@ -28,14 +29,12 @@ class TryResuseStreamError extends Data.TaggedError('TryResuseStreamError') {} export const MediaRecorderService = Effect.gen(function* () { const { toast } = yield* ToastService; - const mediaStreamService = yield* MediaStreamService; let mediaRecorder: MediaRecorder | null = null; const recordedChunks: Blob[] = []; const resetRecorder = () => { recordedChunks.length = 0; mediaRecorder = null; - mediaStreamService.destroy(); }; return { @@ -52,7 +51,7 @@ export const MediaRecorderService = Effect.gen(function* () { title: 'Connecting to audio input device...', description: 'Please allow access to your microphone if prompted.', }); - const maybeResusedStream = yield* mediaStreamService.init({ + const maybeResusedStream = yield* mediaStream.init({ shouldReuseStream: true, preferredRecordingDeviceId, }); @@ -84,7 +83,7 @@ export const MediaRecorderService = Effect.gen(function* () { title: 'Error initializing media recorder with preferred device', description: 'Trying to find another available audio input device...', }); - const stream = yield* mediaStreamService.init({ shouldReuseStream: false }); + const stream = yield* mediaStream.init({ shouldReuseStream: false }); return new AudioRecorder(stream, { mimeType: 'audio/webm;codecs=opus', sampleRate: 16000, @@ -137,8 +136,8 @@ export const MediaRecorderService = Effect.gen(function* () { }; }); -export const MediaStreamService = Effect.gen(function* () { - let internalStream: MediaStream | null = null; +export const mediaStream = Effect.gen(function* () { + let internalStream = $state(null); const getStreamForDeviceId = (recordingDeviceId: string) => Effect.tryPromise({ @@ -216,4 +215,4 @@ export const MediaStreamService = Effect.gen(function* () { }, enumerateRecordingDevices, }; -}); +}).pipe(Effect.runSync); diff --git a/apps/app/src/lib/stores/recorder.svelte.ts b/apps/app/src/lib/stores/recorder.svelte.ts index 6400bd5d..ba999b14 100644 --- a/apps/app/src/lib/stores/recorder.svelte.ts +++ b/apps/app/src/lib/stores/recorder.svelte.ts @@ -1,5 +1,8 @@ import { sendMessageToExtension } from '$lib/sendMessageToExtension'; -import { MediaRecorderService, MediaStreamService } from '$lib/services/MediaRecorderService'; +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'; @@ -46,7 +49,6 @@ const IS_RECORDING_NOTIFICATION_ID = 'WHISPERING_RECORDING_NOTIFICATION'; export const recorder = Effect.gen(function* () { const mediaRecorderService = yield* MediaRecorderService; - const mediaStreamService = yield* MediaStreamService; const { notify } = yield* NotificationService; return { @@ -54,7 +56,7 @@ export const recorder = Effect.gen(function* () { return recorderState.value; }, enumerateRecordingDevices: () => - mediaStreamService.enumerateRecordingDevices.pipe( + enumerateRecordingDevices.pipe( Effect.catchAll((error) => { renderErrorAsToast(error); return Effect.succeed([] as MediaDeviceInfo[]); diff --git a/apps/app/src/routes/(app)/+page.svelte b/apps/app/src/routes/(app)/+page.svelte index 492650fa..7fa668cb 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}