diff --git a/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts b/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts index ee3dd2fef..8b84a7bcb 100644 --- a/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts +++ b/apps/desktop/layer/renderer/src/modules/player/entry-tts.test.ts @@ -4,18 +4,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { playEntryTts } from "./entry-tts" const { + audioElementMock, getEntryMock, getGeneralSettingsMock, + getAudioPlayerAtomValueMock, getReadabilityStatusMock, legacyTtsMock, mountMock, + setAudioPlayerAtomValueMock, toastFetchErrorMock, } = vi.hoisted(() => ({ + audioElementMock: { + addEventListener: vi.fn(), + currentTime: 0, + load: vi.fn(), + pause: vi.fn(), + play: vi.fn(() => Promise.resolve()), + removeEventListener: vi.fn(), + src: "", + srcObject: null as MediaStream | null, + }, getEntryMock: vi.fn(), getGeneralSettingsMock: vi.fn(), + getAudioPlayerAtomValueMock: vi.fn(() => ({})), getReadabilityStatusMock: vi.fn(), legacyTtsMock: vi.fn(), mountMock: vi.fn(), + setAudioPlayerAtomValueMock: vi.fn(), toastFetchErrorMock: vi.fn(), })) @@ -39,10 +54,11 @@ vi.mock("~/atoms/settings/general", () => ({ vi.mock("~/atoms/player", () => ({ AudioPlayer: { + audio: audioElementMock, mount: mountMock, }, - getAudioPlayerAtomValue: vi.fn(() => ({})), - setAudioPlayerAtomValue: vi.fn(), + getAudioPlayerAtomValue: getAudioPlayerAtomValueMock, + setAudioPlayerAtomValue: setAudioPlayerAtomValueMock, })) vi.mock("~/lib/api-client", () => ({ @@ -64,6 +80,51 @@ describe("entry tts", () => { const createObjectURLMock = vi.fn(() => "blob:tts-audio") const revokeObjectURLMock = vi.fn() + const createAudioBufferMock = (duration: number, sampleRate = 1000) => + ({ + copyFromChannel: (destination: Float32Array) => { + destination.fill(0) + }, + copyToChannel: vi.fn(), + duration, + length: Math.round(duration * sampleRate), + numberOfChannels: 1, + sampleRate, + }) as unknown as AudioBuffer + + const waitForExpectation = async (assertion: () => void) => { + const deadline = Date.now() + 1000 + let lastError: unknown + + while (Date.now() < deadline) { + try { + assertion() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + + throw lastError + } + + const createStreamingResponse = () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() + }, + }), + { + headers: { + "content-type": "audio/mpeg", + }, + status: 200, + }, + ) + beforeEach(() => { vi.stubGlobal("fetch", fetchMock) vi.stubGlobal( @@ -84,6 +145,7 @@ describe("entry tts", () => { getGeneralSettingsMock.mockReturnValue({ voice: "en-US-AvaMultilingualNeural", }) + getAudioPlayerAtomValueMock.mockReturnValue({}) getReadabilityStatusMock.mockReturnValue({}) fetchMock.mockResolvedValue( new Response(new Blob(["audio"], { type: "audio/mpeg" }), { @@ -135,4 +197,56 @@ describe("entry tts", () => { it("uses the new default voice", () => { expect(defaultGeneralSettings.voice).toBe("en-US-AvaMultilingualNeural") }) + + it("schedules decoded stream chunks from the current audio context time", async () => { + const sourceStartTimes: number[] = [] + const audioContexts: Array<{ currentTime: number }> = [] + + class FakeAudioContext { + currentTime = 0 + + constructor() { + audioContexts.push(this) + } + + close = vi.fn(() => Promise.resolve()) + createBuffer = (_channels: number, frameCount: number, sampleRate: number) => + createAudioBufferMock(frameCount / sampleRate, sampleRate) + createBufferSource = () => ({ + buffer: null as AudioBuffer | null, + connect: vi.fn(), + start: vi.fn((time: number) => { + sourceStartTimes.push(time) + }), + }) + createMediaStreamDestination = () => ({ + stream: {} as MediaStream, + }) + decodeAudioData = vi.fn(async () => { + this.currentTime = 3 + return createAudioBufferMock(1) + }) + resume = vi.fn(() => Promise.resolve()) + suspend = vi.fn(() => Promise.resolve()) + } + + vi.stubGlobal("window", { + ...window, + AudioContext: FakeAudioContext, + clearInterval: globalThis.clearInterval, + setInterval: globalThis.setInterval, + setTimeout: (handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + audioContexts[0]!.currentTime = 10 + return globalThis.setTimeout(handler, timeout, ...args) + }, + }) + fetchMock.mockResolvedValue(createStreamingResponse()) + + await playEntryTts("entry-1", { toastTitle: "Play TTS" }) + + await waitForExpectation(() => { + expect(sourceStartTimes).toHaveLength(1) + }) + expect(sourceStartTimes[0]).toBeGreaterThanOrEqual(3) + }) }) diff --git a/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts b/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts index bc65f0eaa..911be17b1 100644 --- a/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts +++ b/apps/desktop/layer/renderer/src/modules/player/entry-tts.ts @@ -198,16 +198,17 @@ const createAudioContextStreamingHandle = ( const source = audioContext.createBufferSource() source.buffer = segmentBuffer source.connect(destination) - source.start(scheduledTime) + const segmentStartTime = Math.max(scheduledTime, audioContext.currentTime) + source.start(segmentStartTime) if (decodedDuration === 0) { - playbackStartTime = scheduledTime + playbackStartTime = segmentStartTime if (progressTimer === null) { progressTimer = window.setInterval(updateProgress, 250) } } - scheduledTime += frameCount / sampleRate + scheduledTime = segmentStartTime + frameCount / sampleRate decodedDuration = totalDuration const playerState = getAudioPlayerAtomValue()