Skip to content

Commit

Permalink
Merge pull request #134 from braden-w:feat/skip-reinitialize-stream
Browse files Browse the repository at this point in the history
feat: attempt to reuse MediaStream until user closes stream
  • Loading branch information
braden-w committed Jun 30, 2024
2 parents 4550b90 + 5150f97 commit b2a2ddc
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 185 deletions.
218 changes: 218 additions & 0 deletions apps/app/src/lib/services/MediaRecorderService.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<Blob, Error>((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<undefined, Error>((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<MediaStream | null>(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);
15 changes: 0 additions & 15 deletions apps/app/src/lib/services/MediaRecorderService.ts

This file was deleted.

164 changes: 0 additions & 164 deletions apps/app/src/lib/services/MediaRecorderServiceWebLive.ts

This file was deleted.

Loading

0 comments on commit b2a2ddc

Please sign in to comment.