fix(desktop): stabilize streaming tts scheduling

This commit is contained in:
DIYgod
2026-06-22 10:41:54 +08:00
parent 497d872a5c
commit 4ab06f42b4
2 changed files with 120 additions and 5 deletions
@@ -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<Uint8Array>({
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)
})
})
@@ -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()