From cea62392dfd8271d35a40ae483a1dfad7e1e0c92 Mon Sep 17 00:00:00 2001 From: vedanth Date: Tue, 27 Oct 2026 07:43:03 +0000 Subject: [PATCH 1/3] fix(web): keep voice recording work with its take --- .github/workflows/ci.yml | 1 + .../unit/voice-recorder-lifecycle.test.ts | 210 ++++++++++++++++++ .../timeline/recording/useVoiceRecorder.ts | 5 +- 3 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..be26fb7041a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,7 @@ jobs: __tests__/unit/invite-decline.test.ts __tests__/unit/linkified-text.test.ts __tests__/unit/loom-import-ui.test.ts + __tests__/unit/voice-recorder-lifecycle.test.ts __tests__/unit/media-comment-playback-cleanup.test.ts __tests__/unit/media-format-converter-selection.test.ts __tests__/unit/media-format-converter.test.ts diff --git a/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts b/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts new file mode 100644 index 00000000000..1060c8a52cf --- /dev/null +++ b/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useVoiceRecorder } from "@/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder"; + +const mocks = vi.hoisted(() => ({ + mic: vi.fn(), + mixer: vi.fn(), + decode: vi.fn(), +})); +vi.mock("@cap/recorder-core/capture-streams", () => ({ + acquireMicStream: mocks.mic, + createAudioMixer: mocks.mixer, +})); +vi.mock( + "@/app/s/[videoId]/_components/timeline/recording/compute-waveform-peaks", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/app/s/[videoId]/_components/timeline/recording/compute-waveform-peaks") + >()), + decodeToPeaks: mocks.decode, + }), +); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +class Track { + readyState = "live"; + stop = vi.fn(() => { + this.readyState = "ended"; + }); + getSettings() { + return { deviceId: "microphone" }; + } +} +class Stream { + constructor(readonly track = new Track()) {} + getTracks() { + return [this.track]; + } + getAudioTracks() { + return [this.track]; + } +} +class Recorder { + static isTypeSupported = () => true; + state: RecordingState = "inactive"; + mimeType = "audio/webm"; + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + start() { + this.state = "recording"; + } + stop() { + this.state = "inactive"; + queueMicrotask(() => { + const event = new Event("dataavailable"); + Object.defineProperty(event, "data", { + value: new Blob(["audio"], { type: "audio/webm" }), + }); + this.ondataavailable?.(event as BlobEvent); + this.onstop?.(new Event("stop")); + }); + } +} +let root: Root; +let container: HTMLDivElement; +let mounted: boolean; +let recorder: ReturnType; +const onFinish = vi.fn(); +function mixer(stream = new Stream()) { + return { + context: {}, + stream, + close: vi.fn(async () => {}), + setMicStream: vi.fn(), + }; +} +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("MediaRecorder", Recorder); + onFinish.mockReset(); + mocks.mic.mockReset().mockImplementation(async () => new Stream()); + mocks.mixer.mockReset().mockImplementation(async () => mixer()); + mocks.decode.mockReset().mockResolvedValue([0.25, 1]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + mounted = true; + function Harness() { + recorder = useVoiceRecorder({ onFinish, selectedMicId: null }); + return null; + } + await act(async () => root.render(createElement(Harness))); +}); +afterEach(async () => { + if (mounted) await unmount(); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +async function unmount() { + await act(async () => root.unmount()); + mounted = false; +} +async function beginDecoding() { + const decode = deferred(); + mocks.decode.mockReturnValueOnce(decode.promise); + await act(async () => recorder.start()); + let stopping!: Promise; + await act(async () => { + stopping = recorder.stop(); + }); + expect(mocks.decode).toHaveBeenCalledTimes(1); + return { decode, stopping }; +} + +it.each(["cancel", "unmount"])( + "does not finish a voice note after %s during waveform decoding", + async (action) => { + const { decode, stopping } = await beginDecoding(); + if (action === "cancel") await act(async () => recorder.cancel()); + else await unmount(); + await act(async () => { + decode.resolve([1]); + await stopping; + }); + expect(onFinish).not.toHaveBeenCalled(); + }, +); + +it("does not finish an old take or reset the replacement while decoding completes", async () => { + const { decode, stopping } = await beginDecoding(); + await act(async () => recorder.cancel()); + await act(async () => recorder.start()); + expect(recorder.phase).toBe("recording"); + await act(async () => { + decode.resolve([1]); + await stopping; + }); + expect(onFinish).not.toHaveBeenCalled(); + expect(recorder.phase).toBe("recording"); +}); + +it("does not close the replacement microphone when an abandoned mixer finishes", async () => { + const pendingMixer = deferred>(); + const abandoned = mixer(); + const replacement = mixer(); + const firstMic = new Stream(); + const nextMic = new Stream(); + mocks.mic.mockResolvedValueOnce(firstMic).mockResolvedValueOnce(nextMic); + mocks.mixer + .mockReturnValueOnce(pendingMixer.promise) + .mockResolvedValueOnce(replacement); + let starting!: Promise; + await act(async () => { + starting = recorder.start(); + }); + await act(async () => recorder.cancel()); + await act(async () => recorder.start()); + await act(async () => { + pendingMixer.resolve(abandoned); + await starting; + }); + expect(abandoned.close).toHaveBeenCalled(); + expect(firstMic.track.stop).toHaveBeenCalled(); + expect(nextMic.track.stop).not.toHaveBeenCalled(); + expect(replacement.close).not.toHaveBeenCalled(); + expect(recorder.phase).toBe("recording"); +}); + +it("still finishes a current recording with the decoded waveform", async () => { + await act(async () => recorder.start()); + await act(async () => recorder.stop()); + expect(onFinish).toHaveBeenCalledTimes(1); + expect(onFinish).toHaveBeenCalledWith( + expect.objectContaining({ + mime: "audio/webm", + waveform: [0.25, 1], + blob: expect.objectContaining({ size: 5 }), + }), + ); + expect(recorder.phase).toBe("idle"); +}); + +it("releases a microphone granted after startup was cancelled", async () => { + const pendingMic = deferred(); + const stream = new Stream(); + mocks.mic.mockReturnValueOnce(pendingMic.promise); + let starting!: Promise; + await act(async () => { + starting = recorder.start(); + }); + await act(async () => recorder.cancel()); + await act(async () => { + pendingMic.resolve(stream); + await starting; + }); + expect(stream.track.stop).toHaveBeenCalled(); + expect(mocks.mixer).not.toHaveBeenCalled(); + expect(onFinish).not.toHaveBeenCalled(); + expect(recorder.phase).toBe("idle"); +}); diff --git a/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts b/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts index d38ffaf7dd4..1eae1240c58 100644 --- a/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts +++ b/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts @@ -169,7 +169,7 @@ export function useVoiceRecorder(options: { } if (runId !== runIdRef.current || discardRef.current) { if (mixer) void mixer.close(); - cleanupStreams(); + for (const track of stream.getTracks()) track.stop(); return; } mixerRef.current = mixer; @@ -367,6 +367,7 @@ export function useVoiceRecorder(options: { const stop = useCallback(async () => { if (phaseRef.current !== "recording" && phaseRef.current !== "paused") return; + const runId = runIdRef.current; // Pause accounting folds any open pause in, so this is active time only. const recordedMs = syncDurationFromClock(); const mime = @@ -381,6 +382,7 @@ export function useVoiceRecorder(options: { } catch (cause) { console.error("Failed to finalize voice note", cause); } + if (runId !== runIdRef.current) return; const livePeaks = peaksRef.current?.finish(); peaksRef.current = null; resetRecorder(); @@ -393,6 +395,7 @@ export function useVoiceRecorder(options: { const typedBlob = blob.type ? blob : new Blob([blob], { type: mime }); const waveform = livePeaks ?? (await decodeToPeaks(typedBlob)); + if (runId !== runIdRef.current || discardRef.current) return; optionsRef.current.onFinish({ blob: typedBlob, mime, From 226bd0fb7c7f9c783180151e4fbff7a460d6ff4f Mon Sep 17 00:00:00 2001 From: vedanth Date: Tue, 27 Oct 2026 07:44:21 +0000 Subject: [PATCH 2/3] fix(web): release voice capture when encoder start fails --- .../unit/voice-recorder-lifecycle.test.ts | 23 +++++++++++++++++ .../timeline/recording/useVoiceRecorder.ts | 25 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts b/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts index 1060c8a52cf..9ddd1ea7712 100644 --- a/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts +++ b/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts @@ -50,12 +50,14 @@ class Stream { } class Recorder { static isTypeSupported = () => true; + static startError: Error | null = null; state: RecordingState = "inactive"; mimeType = "audio/webm"; ondataavailable: ((event: BlobEvent) => void) | null = null; onstop: ((event: Event) => void) | null = null; onerror: ((event: Event) => void) | null = null; start() { + if (Recorder.startError) throw Recorder.startError; this.state = "recording"; } stop() { @@ -84,6 +86,7 @@ function mixer(stream = new Stream()) { }; } beforeEach(async () => { + Recorder.startError = null; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("MediaRecorder", Recorder); onFinish.mockReset(); @@ -208,3 +211,23 @@ it("releases a microphone granted after startup was cancelled", async () => { expect(onFinish).not.toHaveBeenCalled(); expect(recorder.phase).toBe("idle"); }); + +it.each(["start", "restart"])( + "releases capture when recorder %s throws", + async (operation) => { + const stream = new Stream(); + const activeMixer = mixer(); + mocks.mic.mockResolvedValueOnce(stream); + mocks.mixer.mockResolvedValueOnce(activeMixer); + if (operation === "restart") await act(async () => recorder.start()); + Recorder.startError = new Error("Encoder unavailable"); + await act(async () => { + if (operation === "start") await recorder.start(); + else recorder.restart(); + }); + expect(recorder.phase).toBe("denied"); + expect(stream.track.stop).toHaveBeenCalled(); + expect(activeMixer.close).toHaveBeenCalled(); + expect(onFinish).not.toHaveBeenCalled(); + }, +); diff --git a/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts b/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts index 1eae1240c58..f5c07bbcfcd 100644 --- a/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts +++ b/apps/web/app/s/[videoId]/_components/timeline/recording/useVoiceRecorder.ts @@ -213,16 +213,28 @@ export function useVoiceRecorder(options: { }, LEVEL_SAMPLE_MS); } - recorder.start(1000); + try { + recorder.start(1000); + } catch { + resetRecorder(); + cleanupStreams(); + clearTimer(); + resetTimer(); + updatePhase("denied"); + return; + } startTimer(); updatePhase("recording"); }, [ attachMeter, cleanupStreams, + clearTimer, mediaRecorderRef, onRecorderDataAvailable, onRecorderError, onRecorderStop, + resetRecorder, + resetTimer, setLocalRecordingStrategy, startTimer, updatePhase, @@ -302,7 +314,16 @@ export function useVoiceRecorder(options: { next.onerror = onRecorderError; mediaRecorderRef.current = next; peaksRef.current = new LivePeaksAccumulator(); - next.start(1000); + try { + next.start(1000); + } catch { + resetRecorder(); + cleanupStreams(); + clearTimer(); + resetTimer(); + updatePhase("denied"); + return; + } startTimer(); updatePhase("recording"); }, [ From fc5d59125eb196d1d890fc239fcaceed54154ed5 Mon Sep 17 00:00:00 2001 From: vedanth Date: Tue, 27 Oct 2026 07:48:47 +0000 Subject: [PATCH 3/3] test(web): cover delayed voice recorder stop events --- .../unit/voice-recorder-lifecycle.test.ts | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts b/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts index 9ddd1ea7712..ec0ea87f8bd 100644 --- a/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts +++ b/apps/web/__tests__/unit/voice-recorder-lifecycle.test.ts @@ -51,6 +51,7 @@ class Stream { class Recorder { static isTypeSupported = () => true; static startError: Error | null = null; + static pendingStops: Array<() => void> | null = null; state: RecordingState = "inactive"; mimeType = "audio/webm"; ondataavailable: ((event: BlobEvent) => void) | null = null; @@ -62,14 +63,19 @@ class Recorder { } stop() { this.state = "inactive"; - queueMicrotask(() => { + const finish = () => { const event = new Event("dataavailable"); + Object.defineProperty(event, "target", { value: this }); Object.defineProperty(event, "data", { value: new Blob(["audio"], { type: "audio/webm" }), }); this.ondataavailable?.(event as BlobEvent); - this.onstop?.(new Event("stop")); - }); + const stopped = new Event("stop"); + Object.defineProperty(stopped, "target", { value: this }); + this.onstop?.(stopped); + }; + if (Recorder.pendingStops) Recorder.pendingStops.push(finish); + else queueMicrotask(finish); } } let root: Root; @@ -87,6 +93,7 @@ function mixer(stream = new Stream()) { } beforeEach(async () => { Recorder.startError = null; + Recorder.pendingStops = null; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("MediaRecorder", Recorder); onFinish.mockReset(); @@ -231,3 +238,27 @@ it.each(["start", "restart"])( expect(onFinish).not.toHaveBeenCalled(); }, ); + +it("keeps a replacement take when the previous stop event arrives late", async () => { + await act(async () => recorder.start()); + Recorder.pendingStops = []; + let oldStop!: Promise; + let newStart!: Promise; + await act(async () => { + oldStop = recorder.stop(); + recorder.cancel(); + newStart = recorder.start(); + await newStart; + await oldStop; + }); + expect(recorder.phase).toBe("recording"); + const pending = Recorder.pendingStops; + Recorder.pendingStops = null; + await act(async () => { + for (const finish of pending ?? []) finish(); + }); + expect(recorder.phase).toBe("recording"); + await act(async () => recorder.stop()); + expect(onFinish).toHaveBeenCalledTimes(1); + expect(recorder.phase).toBe("idle"); +});