diff --git a/ui/src/pages/chat/realtime-talk-audio.test.ts b/ui/src/pages/chat/realtime-talk-audio.test.ts index b0db4684e9b6..bb771a9e6263 100644 --- a/ui/src/pages/chat/realtime-talk-audio.test.ts +++ b/ui/src/pages/chat/realtime-talk-audio.test.ts @@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims its interval when the initial level callback stops it", () => { + vi.useFakeTimers(); + const close = vi.fn(async () => undefined); + const disconnectSource = vi.fn(); + const disconnectAnalyser = vi.fn(); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: disconnectSource }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: disconnectAnalyser, + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onLevel = vi.fn((level: number) => { + if (level > 0) { + meter.stop(); + } + }); + const meter = new RealtimeTalkMediaStreamMeter(onLevel); + + meter.start({} as MediaStream); + meter.stop(); + meter.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(disconnectSource).toHaveBeenCalledOnce(); + expect(disconnectAnalyser).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("closes an owned AudioContext when analyser setup fails", () => { const close = vi.fn(async () => undefined); class MockAudioContext { diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index aac2ba302117..a729faa6a7e8 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => { expect(onInputLevel).toHaveBeenLastCalledWith(0); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + const client = createClient(); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createTransport({ client, callbacks: { onInputLevel } }); + + await expect(transport.start()).resolves.toBe("ready"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1); + }); + it("bounds stalled microphone appends and aborts every owner on stop", async () => { const onStatus = vi.fn(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-webrtc.test.ts b/ui/src/pages/chat/realtime-talk-webrtc.test.ts index 6bef1ac0e370..8a662e211f2d 100644 --- a/ui/src/pages/chat/realtime-talk-webrtc.test.ts +++ b/ui/src/pages/chat/realtime-talk-webrtc.test.ts @@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + stubAnswerSdpFetch(); + const close = vi.fn(async () => undefined); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: vi.fn() }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: vi.fn(), + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createOpenAiTransport({}, { onInputLevel }); + + await expect(transport.start()).resolves.toBe("cancelled"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(stopInputTrack).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("does not continue WebRTC setup when stopped while microphone access is pending", async () => { const fetchMock = vi.fn(async () => new Response("answer-sdp")); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);