mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-19 14:31:44 +08:00
fix(desktop): stabilize streaming tts scheduling
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user