mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(reader): keep tts playing after a voice or speed change
A voice or speed change invalidated the playing block's end handler, so the next block was never requested, and a change during loading left the player loading forever. Hand the playing block over to the new settings, refetch any block playback was waiting on, and cancel superseded requests in the client and at the provider. Audio that arrives during a pause no longer resumes playback. Fixes #1734
This commit is contained in:
@@ -43,11 +43,12 @@ async function assertOk(res: Response): Promise<void> {
|
||||
|
||||
// ---- Synthesis ----
|
||||
|
||||
export async function synthesize(params: TtsSynthesisRequest): Promise<Response> {
|
||||
export async function synthesize(params: TtsSynthesisRequest, signal?: AbortSignal): Promise<Response> {
|
||||
return api('/api/v1/tts/synthesize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import type { TtsSynthesisRequest } from '@bookorbit/types'
|
||||
|
||||
// ─── AudioContext mock ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -141,6 +142,56 @@ function simulateBlockEnd(sourceIndex: number) {
|
||||
if (src?.onended) src.onended()
|
||||
}
|
||||
|
||||
function okSynthesisResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
blob: vi.fn<() => Promise<unknown>>().mockResolvedValue({
|
||||
arrayBuffer: vi.fn<() => Promise<ArrayBuffer>>().mockResolvedValue(new ArrayBuffer(8)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type PendingSynthesis = {
|
||||
text: string
|
||||
voiceId: string
|
||||
speed: number
|
||||
signal: AbortSignal
|
||||
resolve: () => void
|
||||
reject: (err: Error) => void
|
||||
}
|
||||
|
||||
/** Holds every synthesis request open until the test settles it; aborting one rejects it like fetch does. */
|
||||
function deferSynthesis(): PendingSynthesis[] {
|
||||
const pending: PendingSynthesis[] = []
|
||||
;(ttsApi.synthesize as Mock).mockImplementation(
|
||||
(params: TtsSynthesisRequest, signal: AbortSignal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
|
||||
pending.push({
|
||||
text: params.text,
|
||||
voiceId: params.voiceId,
|
||||
speed: params.speed,
|
||||
signal,
|
||||
resolve: () => resolve(okSynthesisResponse()),
|
||||
reject,
|
||||
})
|
||||
}),
|
||||
)
|
||||
return pending
|
||||
}
|
||||
|
||||
function synthesisRequests() {
|
||||
return (ttsApi.synthesize as Mock).mock.calls.map(([params]) => {
|
||||
const { text, voiceId, speed } = params as TtsSynthesisRequest
|
||||
return { text, voiceId, speed }
|
||||
})
|
||||
}
|
||||
|
||||
/** Lets every pending promise chain run without moving the (possibly faked) clock. */
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useTtsPlayer (AudioContext)', () => {
|
||||
@@ -205,7 +256,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
describe('startPlayback', () => {
|
||||
it('fetches and schedules first block on start', async () => {
|
||||
await startWithBlocks(['Hello world.'])
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Hello world.', voiceId: 'voice1' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Hello world.', voiceId: 'voice1' }), expect.any(AbortSignal))
|
||||
expect(mockCtxInstance.createBufferSource).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -245,7 +296,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
// scheduled (lastScheduledBlockIdx stayed at -1), so nothing ever played.
|
||||
expect(mockCtxInstance.createdSources.length).toBe(1)
|
||||
expect(mockCtxInstance.createdSources[0]!.start).toHaveBeenCalled()
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'B2.' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'B2.' }), expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('clamps an out-of-range start block to the last block', async () => {
|
||||
@@ -254,7 +305,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(mockCtxInstance.createdSources.length).toBeGreaterThanOrEqual(1)
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'B1.' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'B1.' }), expect.any(AbortSignal))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -388,7 +439,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
.mockResolvedValue({ arrayBuffer: vi.fn<() => Promise<ArrayBuffer>>().mockResolvedValue(new ArrayBuffer(8)) }),
|
||||
})
|
||||
await player.nextBlock()
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Block 1.' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Block 1.' }), expect.any(AbortSignal))
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
})
|
||||
|
||||
@@ -402,7 +453,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
.mockResolvedValue({ arrayBuffer: vi.fn<() => Promise<ArrayBuffer>>().mockResolvedValue(new ArrayBuffer(8)) }),
|
||||
})
|
||||
await player.prevBlock()
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Block 0.' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Block 0.' }), expect.any(AbortSignal))
|
||||
expect(player.currentBlockIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
@@ -472,6 +523,282 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('voice and speed changes during playback', () => {
|
||||
const RESYNC_DELAY_MS = 300
|
||||
const FIVE_BLOCKS = ['B0.', 'B1.', 'B2.', 'B3.', 'B4.']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useTtsPlayer().stopPlayback()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function startPrefetched(): Promise<ReturnType<typeof useTtsPlayer>> {
|
||||
const player = await startWithBlocks(FIVE_BLOCKS)
|
||||
await settle()
|
||||
expect(mockCtxInstance.createdSources).toHaveLength(4)
|
||||
;(ttsApi.synthesize as Mock).mockClear()
|
||||
return player
|
||||
}
|
||||
|
||||
async function startDeferred(blocks = FIVE_BLOCKS) {
|
||||
const pending = deferSynthesis()
|
||||
const player = useTtsPlayer()
|
||||
const started = player.startPlayback(makeBook(), 'provider1', 'voice1', 1.0, makeTextSource([blocks]))
|
||||
await settle()
|
||||
return { player, pending, started }
|
||||
}
|
||||
|
||||
async function passResyncDelay(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(RESYNC_DELAY_MS)
|
||||
await settle()
|
||||
}
|
||||
|
||||
it.each([
|
||||
['voice', (player: ReturnType<typeof useTtsPlayer>) => player.setVoice('provider2', 'voice2'), { voiceId: 'voice2', speed: 1 }],
|
||||
['speed', (player: ReturnType<typeof useTtsPlayer>) => player.setSpeed(1.5), { voiceId: 'voice1', speed: 1.5 }],
|
||||
])('moves on to the next block after a %s change once the playing block ends', async (_, change, expected) => {
|
||||
const player = await startPrefetched()
|
||||
|
||||
change(player)
|
||||
const [playing, ...queued] = mockCtxInstance.createdSources
|
||||
expect(playing!.stop).not.toHaveBeenCalled()
|
||||
for (const source of queued) expect(source.stop).toHaveBeenCalled()
|
||||
|
||||
simulateBlockEnd(0)
|
||||
await settle()
|
||||
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(synthesisRequests()).toContainEqual({ text: 'B1.', ...expected })
|
||||
expect(mockCtxInstance.createdSources.length).toBeGreaterThan(4)
|
||||
expect(mockCtxInstance.createdSources[4]!.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefetches the following blocks in the new voice so they play straight after the current one', async () => {
|
||||
const player = await startPrefetched()
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
await passResyncDelay()
|
||||
|
||||
expect(synthesisRequests()).toEqual([
|
||||
{ text: 'B1.', voiceId: 'voice2', speed: 1 },
|
||||
{ text: 'B2.', voiceId: 'voice2', speed: 1 },
|
||||
{ text: 'B3.', voiceId: 'voice2', speed: 1 },
|
||||
])
|
||||
const [playing, , , , nextBlock] = mockCtxInstance.createdSources
|
||||
const playingStart = (playing!.start as Mock).mock.calls[0]![0] as number
|
||||
expect((nextBlock!.start as Mock).mock.calls[0]![0]).toBeCloseTo(playingStart + 1.0, 5)
|
||||
|
||||
simulateBlockEnd(0)
|
||||
await settle()
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
expect(synthesisRequests().slice(3)).toEqual([{ text: 'B4.', voiceId: 'voice2', speed: 1 }])
|
||||
})
|
||||
|
||||
it('sends one round of requests for a burst of speed changes, at the final speed', async () => {
|
||||
const player = await startPrefetched()
|
||||
|
||||
player.setSpeed(1.25)
|
||||
player.setSpeed(1.5)
|
||||
player.setSpeed(1.75)
|
||||
await settle()
|
||||
expect(synthesisRequests()).toEqual([])
|
||||
|
||||
await passResyncDelay()
|
||||
expect(synthesisRequests()).toEqual([
|
||||
{ text: 'B1.', voiceId: 'voice1', speed: 1.75 },
|
||||
{ text: 'B2.', voiceId: 'voice1', speed: 1.75 },
|
||||
{ text: 'B3.', voiceId: 'voice1', speed: 1.75 },
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels the requests still in flight for the old voice', async () => {
|
||||
const { player, pending, started } = await startDeferred()
|
||||
pending[0]!.resolve()
|
||||
await started
|
||||
await settle()
|
||||
const prefetches = pending.slice(1)
|
||||
expect(prefetches.map((request) => request.text)).toEqual(['B1.', 'B2.', 'B3.'])
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
await settle()
|
||||
|
||||
for (const request of prefetches) expect(request.signal.aborted).toBe(true)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(player.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches the loading block again in the new voice instead of staying in loading', async () => {
|
||||
const { player, pending, started } = await startDeferred()
|
||||
expect(player.playbackState.value).toBe('loading')
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
await started
|
||||
expect(pending[0]!.signal.aborted).toBe(true)
|
||||
expect(player.error.value).toBeNull()
|
||||
|
||||
await passResyncDelay()
|
||||
expect(pending[1]).toMatchObject({ text: 'B0.', voiceId: 'voice2' })
|
||||
pending[1]!.resolve()
|
||||
await settle()
|
||||
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(player.currentBlockIndex.value).toBe(0)
|
||||
expect(mockCtxInstance.createdSources).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fetches the block Next was loading again in the new voice', async () => {
|
||||
const player = await startWithBlocks(['B0.', 'B1.', 'B2.'])
|
||||
await settle()
|
||||
const pending = deferSynthesis()
|
||||
void player.nextBlock()
|
||||
await settle()
|
||||
expect(pending[0]).toMatchObject({ text: 'B1.', voiceId: 'voice1' })
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
await passResyncDelay()
|
||||
expect(pending[0]!.signal.aborted).toBe(true)
|
||||
expect(pending[1]).toMatchObject({ text: 'B1.', voiceId: 'voice2' })
|
||||
pending[1]!.resolve()
|
||||
await settle()
|
||||
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
})
|
||||
|
||||
it('fetches the block playback was waiting on again when its prefetch is cancelled', async () => {
|
||||
const { player, pending, started } = await startDeferred()
|
||||
pending[0]!.resolve()
|
||||
await started
|
||||
await settle()
|
||||
simulateBlockEnd(0)
|
||||
await settle()
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
expect(mockCtxInstance.createdSources).toHaveLength(1)
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
await passResyncDelay()
|
||||
const refetch = pending.find((request) => request.text === 'B1.' && request.voiceId === 'voice2')
|
||||
expect(refetch).toBeDefined()
|
||||
refetch!.resolve()
|
||||
await settle()
|
||||
|
||||
expect(mockCtxInstance.createdSources).toHaveLength(2)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
})
|
||||
|
||||
it('stays paused when the voice changes during a pause, and continues in it on resume', async () => {
|
||||
const player = await startPrefetched()
|
||||
player.pausePlayback()
|
||||
mockCtxInstance.resume.mockClear()
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
await passResyncDelay()
|
||||
|
||||
expect(synthesisRequests()).toHaveLength(3)
|
||||
expect(mockCtxInstance.resume).not.toHaveBeenCalled()
|
||||
expect(player.playbackState.value).toBe('paused')
|
||||
|
||||
player.resumePlayback()
|
||||
simulateBlockEnd(0)
|
||||
await settle()
|
||||
expect(player.currentBlockIndex.value).toBe(1)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
})
|
||||
|
||||
it('does not unpause when a prefetched block arrives during a pause', async () => {
|
||||
const { player, pending, started } = await startDeferred()
|
||||
pending[0]!.resolve()
|
||||
await started
|
||||
await settle()
|
||||
player.pausePlayback()
|
||||
mockCtxInstance.resume.mockClear()
|
||||
|
||||
pending[1]!.resolve()
|
||||
await settle()
|
||||
|
||||
expect(mockCtxInstance.createdSources).toHaveLength(2)
|
||||
expect(mockCtxInstance.resume).not.toHaveBeenCalled()
|
||||
expect(mockCtxInstance.state).toBe('suspended')
|
||||
expect(player.playbackState.value).toBe('paused')
|
||||
})
|
||||
|
||||
it('stays paused when paused while a block is loading', async () => {
|
||||
const { player, pending, started } = await startDeferred()
|
||||
player.pausePlayback()
|
||||
|
||||
pending[0]!.resolve()
|
||||
await started
|
||||
await settle()
|
||||
|
||||
expect(player.playbackState.value).toBe('paused')
|
||||
expect(mockCtxInstance.resume).not.toHaveBeenCalled()
|
||||
player.resumePlayback()
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(mockCtxInstance.createdSources[0]!.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores picking the voice that is already playing', async () => {
|
||||
const player = await startPrefetched()
|
||||
|
||||
player.setVoice('provider1', 'voice1')
|
||||
await passResyncDelay()
|
||||
|
||||
expect(synthesisRequests()).toEqual([])
|
||||
for (const source of mockCtxInstance.createdSources) expect(source.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts the next chapter in a voice picked while that chapter was loading', async () => {
|
||||
let releaseChapter: (blocks: string[]) => void = () => {}
|
||||
const textSource = (idx: number) => (idx === 0 ? Promise.resolve(['Only.']) : new Promise<string[]>((resolve) => (releaseChapter = resolve)))
|
||||
const player = useTtsPlayer()
|
||||
await player.startPlayback(makeBook(), 'provider1', 'voice1', 1.0, textSource)
|
||||
;(ttsApi.synthesize as Mock).mockClear()
|
||||
simulateBlockEnd(0)
|
||||
await settle()
|
||||
|
||||
player.setVoice('provider2', 'voice2')
|
||||
releaseChapter(['C1.', 'C2.'])
|
||||
await settle()
|
||||
await passResyncDelay()
|
||||
|
||||
expect(player.currentChapterIndex.value).toBe(1)
|
||||
expect(player.currentBlockIndex.value).toBe(0)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(synthesisRequests()).toEqual([
|
||||
{ text: 'C1.', voiceId: 'voice2', speed: 1 },
|
||||
{ text: 'C2.', voiceId: 'voice2', speed: 1 },
|
||||
])
|
||||
const [, firstOfChapter, secondOfChapter] = mockCtxInstance.createdSources
|
||||
expect(firstOfChapter!.start).toHaveBeenCalled()
|
||||
expect(secondOfChapter!.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches the block playback was waiting on directly when its prefetch fails', async () => {
|
||||
const { player, pending, started } = await startDeferred()
|
||||
pending[0]!.resolve()
|
||||
await started
|
||||
await settle()
|
||||
simulateBlockEnd(0)
|
||||
await settle()
|
||||
|
||||
pending[1]!.reject(new Error('Synthesis failed: 500'))
|
||||
await settle()
|
||||
const retry = pending.slice(2).find((request) => request.text === 'B1.')
|
||||
expect(retry).toBeDefined()
|
||||
retry!.resolve()
|
||||
await settle()
|
||||
|
||||
expect(mockCtxInstance.createdSources).toHaveLength(2)
|
||||
expect(player.playbackState.value).toBe('playing')
|
||||
expect(player.error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startFromPosition', () => {
|
||||
it('jumps to specified block in same chapter', async () => {
|
||||
const player = await startWithBlocks(['B0.', 'B1.', 'B2.'])
|
||||
@@ -483,7 +810,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
.mockResolvedValue({ arrayBuffer: vi.fn<() => Promise<ArrayBuffer>>().mockResolvedValue(new ArrayBuffer(8)) }),
|
||||
})
|
||||
await player.startFromPosition(0, 2)
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'B2.' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'B2.' }), expect.any(AbortSignal))
|
||||
expect(player.currentBlockIndex.value).toBe(2)
|
||||
})
|
||||
|
||||
@@ -500,7 +827,7 @@ describe('useTtsPlayer (AudioContext)', () => {
|
||||
})
|
||||
await player.startFromPosition(1, 1)
|
||||
expect(player.currentChapterIndex.value).toBe(1)
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Ch2 B1.' }))
|
||||
expect(ttsApi.synthesize).toHaveBeenCalledWith(expect.objectContaining({ text: 'Ch2 B1.' }), expect.any(AbortSignal))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@ const MIN_SPEED = 0.25
|
||||
const MAX_SPEED = 4.0
|
||||
const SPEED_STEP = 0.25
|
||||
const SCHEDULE_LATENCY = 0.05
|
||||
// Long enough to fold a speed-slider drag into one round of requests.
|
||||
const SETTINGS_RESYNC_DELAY_MS = 300
|
||||
|
||||
type TextSourceFn = (chapterIndex: number) => Promise<string[]>
|
||||
type ScheduledSource = { source: AudioBufferSourceNode; blockIdx: number; endTime: number; generation: number }
|
||||
|
||||
const playbackState = ref<TtsPlaybackState>('idle')
|
||||
const currentBook = ref<TtsCurrentBook | null>(null)
|
||||
@@ -28,7 +31,7 @@ const currentProviderId = ref('')
|
||||
const currentVoiceId = ref('')
|
||||
|
||||
let audioCtx: AudioContext | null = null
|
||||
let scheduledSources: Array<{ source: AudioBufferSourceNode; blockIdx: number; endTime: number }> = []
|
||||
let scheduledSources: ScheduledSource[] = []
|
||||
let nextScheduleTime = 0
|
||||
const decodedQueue = new Map<number, AudioBuffer>()
|
||||
let lastScheduledBlockIdx = -1
|
||||
@@ -37,6 +40,10 @@ let getTextBlocks: TextSourceFn | null = null
|
||||
let chapterBlocks: string[] = []
|
||||
const pendingPrefetches = new Set<number>()
|
||||
let prefetchGeneration = 0
|
||||
let synthesisAbort = new AbortController()
|
||||
// The block playback is waiting for, until its audio is scheduled.
|
||||
let awaitedBlockIdx: number | null = null
|
||||
let settingsResyncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const positionComposable = useTtsPosition()
|
||||
const mediaSession = useTtsMediaSession()
|
||||
@@ -52,14 +59,21 @@ function flushCurrentPositionSave() {
|
||||
void positionComposable.flushPendingSave()
|
||||
}
|
||||
|
||||
function getOrCreateAudioContext(): AudioContext {
|
||||
function ensureAudioContext(): AudioContext {
|
||||
if (!audioCtx) audioCtx = new AudioContext()
|
||||
if (audioCtx.state === 'suspended') void audioCtx.resume()
|
||||
return audioCtx
|
||||
}
|
||||
|
||||
// A paused player stays silent: audio that arrives during the pause waits for the listener to resume.
|
||||
function getOrCreateAudioContext(): AudioContext {
|
||||
const ctx = ensureAudioContext()
|
||||
if (ctx.state === 'suspended' && playbackState.value !== 'paused') void ctx.resume()
|
||||
return ctx
|
||||
}
|
||||
|
||||
function primeAudioContext(): void {
|
||||
getOrCreateAudioContext()
|
||||
const ctx = ensureAudioContext()
|
||||
if (ctx.state === 'suspended') void ctx.resume()
|
||||
}
|
||||
|
||||
function internalReset(): void {
|
||||
@@ -92,21 +106,37 @@ async function decodeAudioBlob(blob: Blob): Promise<AudioBuffer> {
|
||||
return ctx.decodeAudioData(ab)
|
||||
}
|
||||
|
||||
function stopAllSources(): void {
|
||||
for (const { source } of scheduledSources) {
|
||||
source.onended = null
|
||||
try {
|
||||
source.stop(0)
|
||||
} catch {
|
||||
// Source may have already ended naturally
|
||||
}
|
||||
function cancelSettingsResync(): void {
|
||||
if (settingsResyncTimer === null) return
|
||||
clearTimeout(settingsResyncTimer)
|
||||
settingsResyncTimer = null
|
||||
}
|
||||
|
||||
function invalidateSynthesis(): void {
|
||||
prefetchGeneration++
|
||||
pendingPrefetches.clear()
|
||||
synthesisAbort.abort()
|
||||
synthesisAbort = new AbortController()
|
||||
cancelSettingsResync()
|
||||
}
|
||||
|
||||
function stopSource(source: AudioBufferSourceNode): void {
|
||||
source.onended = null
|
||||
try {
|
||||
source.stop(0)
|
||||
} catch {
|
||||
// Source may have already ended naturally
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllSources(): void {
|
||||
for (const { source } of scheduledSources) stopSource(source)
|
||||
scheduledSources = []
|
||||
decodedQueue.clear()
|
||||
nextScheduleTime = 0
|
||||
lastScheduledBlockIdx = -1
|
||||
pendingPrefetches.clear()
|
||||
prefetchGeneration++
|
||||
awaitedBlockIdx = null
|
||||
invalidateSynthesis()
|
||||
}
|
||||
|
||||
function stopAndResetFor(blockIdx: number): void {
|
||||
@@ -114,21 +144,21 @@ function stopAndResetFor(blockIdx: number): void {
|
||||
lastScheduledBlockIdx = blockIdx - 1
|
||||
}
|
||||
|
||||
function stopScheduledExcept(keepBlockIdx: number): void {
|
||||
const toStop = scheduledSources.filter((s) => s.blockIdx !== keepBlockIdx)
|
||||
for (const { source } of toStop) {
|
||||
source.onended = null
|
||||
try {
|
||||
source.stop(0)
|
||||
} catch {
|
||||
// Source may have already ended naturally
|
||||
}
|
||||
}
|
||||
scheduledSources = scheduledSources.filter((s) => s.blockIdx === keepBlockIdx)
|
||||
/**
|
||||
* Drops audio queued after the playing block. That block finishes as it was synthesized and is
|
||||
* moved into the current generation, so its end still hands over to the next block.
|
||||
*/
|
||||
function stopScheduledAfter(currentIdx: number): void {
|
||||
decodedQueue.clear()
|
||||
lastScheduledBlockIdx = keepBlockIdx
|
||||
const currentEntry = scheduledSources[0]
|
||||
if (currentEntry) nextScheduleTime = currentEntry.endTime
|
||||
const current = scheduledSources.find((entry) => entry.blockIdx === currentIdx)
|
||||
if (!current) return
|
||||
for (const entry of scheduledSources) {
|
||||
if (entry !== current) stopSource(entry.source)
|
||||
}
|
||||
scheduledSources = [current]
|
||||
current.generation = prefetchGeneration
|
||||
lastScheduledBlockIdx = currentIdx
|
||||
nextScheduleTime = current.endTime
|
||||
}
|
||||
|
||||
function pausePlayback() {
|
||||
@@ -154,11 +184,12 @@ export function useTtsPlayer() {
|
||||
source.start(startTime)
|
||||
const endTime = startTime + buffer.duration
|
||||
nextScheduleTime = endTime
|
||||
scheduledSources.push({ source, blockIdx, endTime })
|
||||
const capturedGen = prefetchGeneration
|
||||
const entry: ScheduledSource = { source, blockIdx, endTime, generation: prefetchGeneration }
|
||||
scheduledSources.push(entry)
|
||||
if (blockIdx === awaitedBlockIdx) awaitedBlockIdx = null
|
||||
source.onended = () => {
|
||||
scheduledSources = scheduledSources.filter((s) => s.source !== source)
|
||||
if (prefetchGeneration !== capturedGen) return
|
||||
scheduledSources = scheduledSources.filter((s) => s !== entry)
|
||||
if (entry.generation !== prefetchGeneration) return
|
||||
if (playbackState.value !== 'playing') return
|
||||
void onBlockEnded(blockIdx)
|
||||
}
|
||||
@@ -233,39 +264,48 @@ export function useTtsPlayer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndPlay(blockIdx: number) {
|
||||
async function fetchAndPlay(blockIdx: number, { keepPaused = false } = {}) {
|
||||
if (!currentBook.value) return
|
||||
playbackState.value = 'loading'
|
||||
cancelSettingsResync()
|
||||
if (!keepPaused || playbackState.value !== 'paused') playbackState.value = 'loading'
|
||||
const text = chapterBlocks[blockIdx]
|
||||
if (!text) {
|
||||
await advanceChapter()
|
||||
return
|
||||
}
|
||||
const gen = prefetchGeneration
|
||||
const { signal } = synthesisAbort
|
||||
awaitedBlockIdx = blockIdx
|
||||
try {
|
||||
const blob = await fetchAudio(text)
|
||||
const blob = await fetchAudio(text, signal)
|
||||
if (prefetchGeneration !== gen) return
|
||||
const buffer = await decodeAudioBlob(blob)
|
||||
if (prefetchGeneration !== gen) return
|
||||
scheduleIfNext(blockIdx, buffer)
|
||||
currentBlockIndex.value = blockIdx
|
||||
playbackState.value = 'playing'
|
||||
mediaSession.setPlaybackState('playing')
|
||||
if (playbackState.value !== 'paused') {
|
||||
playbackState.value = 'playing'
|
||||
mediaSession.setPlaybackState('playing')
|
||||
}
|
||||
queueCurrentPositionSave(blockIdx)
|
||||
prefetchAhead(blockIdx)
|
||||
} catch (err) {
|
||||
if (prefetchGeneration !== gen) return
|
||||
handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAudio(text: string): Promise<Blob> {
|
||||
const res = await ttsApi.synthesize({
|
||||
text,
|
||||
voiceId: currentVoiceId.value,
|
||||
providerId: currentProviderId.value,
|
||||
speed: speed.value,
|
||||
format: 'mp3',
|
||||
})
|
||||
async function fetchAudio(text: string, signal: AbortSignal): Promise<Blob> {
|
||||
const res = await ttsApi.synthesize(
|
||||
{
|
||||
text,
|
||||
voiceId: currentVoiceId.value,
|
||||
providerId: currentProviderId.value,
|
||||
speed: speed.value,
|
||||
format: 'mp3',
|
||||
},
|
||||
signal,
|
||||
)
|
||||
if (!res.ok) throw new Error(`Synthesis failed: ${res.status}`)
|
||||
return res.blob()
|
||||
}
|
||||
@@ -280,13 +320,16 @@ export function useTtsPlayer() {
|
||||
currentBlockIndex.value = next
|
||||
queueCurrentPositionSave(next)
|
||||
prefetchAhead(next)
|
||||
if (next > lastScheduledBlockIdx && !pendingPrefetches.has(next) && !decodedQueue.has(next)) {
|
||||
if (next <= lastScheduledBlockIdx) return
|
||||
awaitedBlockIdx = next
|
||||
if (!pendingPrefetches.has(next) && !decodedQueue.has(next)) {
|
||||
await fetchAndPlay(next)
|
||||
}
|
||||
}
|
||||
|
||||
function prefetchAhead(currentIdx: number) {
|
||||
const gen = prefetchGeneration
|
||||
const { signal } = synthesisAbort
|
||||
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
|
||||
const idx = currentIdx + i
|
||||
if (idx >= chapterBlocks.length) break
|
||||
@@ -299,19 +342,21 @@ export function useTtsPlayer() {
|
||||
pendingPrefetches.delete(idx)
|
||||
continue
|
||||
}
|
||||
fetchAudio(text)
|
||||
.then((blob) => {
|
||||
if (prefetchGeneration !== gen) {
|
||||
pendingPrefetches.delete(idx)
|
||||
return
|
||||
}
|
||||
return decodeAudioBlob(blob).then((buffer) => {
|
||||
pendingPrefetches.delete(idx)
|
||||
if (prefetchGeneration !== gen) return
|
||||
scheduleIfNext(idx, buffer)
|
||||
})
|
||||
// A superseded generation must leave pendingPrefetches alone: it was cleared, and may
|
||||
// already hold the replacement request for the same block.
|
||||
fetchAudio(text, signal)
|
||||
.then(async (blob) => {
|
||||
if (prefetchGeneration !== gen) return
|
||||
const buffer = await decodeAudioBlob(blob)
|
||||
if (prefetchGeneration !== gen) return
|
||||
pendingPrefetches.delete(idx)
|
||||
scheduleIfNext(idx, buffer)
|
||||
})
|
||||
.catch(() => {
|
||||
if (prefetchGeneration !== gen) return
|
||||
pendingPrefetches.delete(idx)
|
||||
if (idx === awaitedBlockIdx) void fetchAndPlay(idx, { keepPaused: true })
|
||||
})
|
||||
.catch(() => pendingPrefetches.delete(idx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,19 +422,37 @@ export function useTtsPlayer() {
|
||||
const nextSpeed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, newSpeed))
|
||||
if (nextSpeed === speed.value) return
|
||||
speed.value = nextSpeed
|
||||
if (!isActive.value) return
|
||||
prefetchGeneration++
|
||||
pendingPrefetches.clear()
|
||||
stopScheduledExcept(currentBlockIndex.value)
|
||||
applySynthesisSettings()
|
||||
}
|
||||
|
||||
function setVoice(providerId: string, voiceId: string) {
|
||||
if (providerId === currentProviderId.value && voiceId === currentVoiceId.value) return
|
||||
currentProviderId.value = providerId
|
||||
currentVoiceId.value = voiceId
|
||||
applySynthesisSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* The playing block finishes as it was synthesized; everything after it is cancelled and asked
|
||||
* for again once the settings stop changing. A block playback is still waiting for is fetched again.
|
||||
*/
|
||||
function applySynthesisSettings() {
|
||||
if (!isActive.value) return
|
||||
prefetchGeneration++
|
||||
pendingPrefetches.clear()
|
||||
stopScheduledExcept(currentBlockIndex.value)
|
||||
invalidateSynthesis()
|
||||
stopScheduledAfter(currentBlockIndex.value)
|
||||
settingsResyncTimer = setTimeout(resyncAfterSettingsChange, SETTINGS_RESYNC_DELAY_MS)
|
||||
}
|
||||
|
||||
function resyncAfterSettingsChange() {
|
||||
settingsResyncTimer = null
|
||||
if (awaitedBlockIdx !== null) {
|
||||
const blockIdx = awaitedBlockIdx
|
||||
stopAndResetFor(blockIdx)
|
||||
void fetchAndPlay(blockIdx, { keepPaused: true })
|
||||
return
|
||||
}
|
||||
const currentIdx = currentBlockIndex.value
|
||||
if (scheduledSources.some((entry) => entry.blockIdx === currentIdx)) prefetchAhead(currentIdx)
|
||||
}
|
||||
|
||||
function increaseSpeed() {
|
||||
@@ -401,6 +464,7 @@ export function useTtsPlayer() {
|
||||
}
|
||||
|
||||
function handleError(err: unknown) {
|
||||
awaitedBlockIdx = null
|
||||
flushCurrentPositionSave()
|
||||
const message = err instanceof Error ? err.message : i18n.global.t('tts.errors.generic')
|
||||
error.value = message
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { OpenAiCompatibleProvider } from './openai-compatible.provider';
|
||||
import type { StaticVoiceConfig } from './openai-compatible.provider';
|
||||
|
||||
@@ -115,6 +116,27 @@ describe('OpenAiCompatibleProvider', () => {
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the abort signal to the upstream request', async () => {
|
||||
const provider = makeProvider();
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(null));
|
||||
const { signal } = new AbortController();
|
||||
|
||||
await provider.synthesize('hello', 'alloy', 1.0, 'mp3', signal);
|
||||
|
||||
expect((fetchSpy.mock.calls[0]![1] as RequestInit).signal).toBe(signal);
|
||||
});
|
||||
|
||||
it('does not log a cancelled request as an error', async () => {
|
||||
const provider = makeProvider();
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
vi.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Aborted', 'AbortError'));
|
||||
|
||||
await expect(provider.synthesize('hello', 'alloy', 1.0, 'mp3', controller.signal)).rejects.toThrow('Aborted');
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when API returns non-ok response', async () => {
|
||||
const provider = makeProvider();
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse('Internal Server Error', false, 500));
|
||||
|
||||
@@ -89,7 +89,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
|
||||
this.supportsVoiceDiscovery = config.supportsVoiceDiscovery ?? true;
|
||||
}
|
||||
|
||||
async synthesize(text: string, voiceId: string, speed: number, format: string): Promise<Buffer> {
|
||||
async synthesize(text: string, voiceId: string, speed: number, format: string, signal?: AbortSignal): Promise<Buffer> {
|
||||
const event = 'tts.openai.synthesize';
|
||||
const startMs = Date.now();
|
||||
this.logger.log(
|
||||
@@ -107,6 +107,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
@@ -119,6 +120,12 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
this.logger.log(
|
||||
`[${event}] [end] providerId=${this.providerId} voiceId=${voiceId} durationMs=${Date.now() - startMs} outcome=cancelled - openai-compatible synthesis cancelled`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
const error = err instanceof Error ? sanitizeLogValue(err.message) : 'unknown';
|
||||
this.logger.error(
|
||||
|
||||
@@ -7,7 +7,8 @@ export interface ProviderCaptionedSpeech {
|
||||
}
|
||||
|
||||
export interface ITtsProvider {
|
||||
synthesize(text: string, voiceId: string, speed: number, format?: string): Promise<Buffer>;
|
||||
/** `signal` aborts the upstream request once nobody is waiting for the audio. */
|
||||
synthesize(text: string, voiceId: string, speed: number, format?: string, signal?: AbortSignal): Promise<Buffer>;
|
||||
/**
|
||||
* Synthesize with word timings when the upstream can produce them.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { TtsSynthesisService } from './tts-synthesis.service';
|
||||
import type { ITtsProvider } from './providers/tts-provider.interface';
|
||||
|
||||
@@ -22,7 +24,7 @@ describe('TtsSynthesisService', () => {
|
||||
it('should call provider.synthesize and return audio buffer', async () => {
|
||||
const result = await service.synthesize(provider, '1', 'af_heart', 1.0, 'Hello world', 'mp3');
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(provider.synthesize).toHaveBeenCalledWith('Hello world', 'af_heart', 1.0, 'mp3');
|
||||
expect(provider.synthesize).toHaveBeenCalledWith('Hello world', 'af_heart', 1.0, 'mp3', undefined);
|
||||
});
|
||||
|
||||
it('should cache the result with provider+voice+speed+text as key', async () => {
|
||||
@@ -49,6 +51,27 @@ describe('TtsSynthesisService', () => {
|
||||
expect(provider.synthesize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the abort signal on to the provider', async () => {
|
||||
const { signal } = new AbortController();
|
||||
await service.synthesize(provider, '1', 'af_heart', 1.0, 'Hello', 'mp3', signal);
|
||||
expect(provider.synthesize).toHaveBeenCalledWith('Hello', 'af_heart', 1.0, 'mp3', signal);
|
||||
});
|
||||
|
||||
it('treats a synthesis cancelled by the client as an outcome, not an error, and caches nothing', async () => {
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const controller = new AbortController();
|
||||
(provider.synthesize as vi.Mock).mockImplementation(() => {
|
||||
controller.abort();
|
||||
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
|
||||
await expect(service.synthesize(provider, '1', 'af_heart', 1.0, 'Hello', 'mp3', controller.signal)).rejects.toThrow('Aborted');
|
||||
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(service.getCacheSize()).toBe(0);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should propagate provider errors', async () => {
|
||||
(provider.synthesize as vi.Mock).mockRejectedValue(new Error('synthesis failed'));
|
||||
await expect(service.synthesize(provider, '1', 'jenny', 1.0, 'text', 'mp3')).rejects.toThrow('synthesis failed');
|
||||
@@ -145,7 +168,7 @@ describe('TtsSynthesisService', () => {
|
||||
it('should synthesize a preview sentence', async () => {
|
||||
const result = await service.previewVoice(provider, '1', 'af_heart');
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(provider.synthesize).toHaveBeenCalledWith(expect.any(String), 'af_heart', 1.0, 'mp3');
|
||||
expect(provider.synthesize).toHaveBeenCalledWith(expect.any(String), 'af_heart', 1.0, 'mp3', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,15 @@ export class TtsSynthesisService {
|
||||
this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
async synthesize(provider: ITtsProvider, providerId: string, voiceId: string, speed: number, text: string, format = 'mp3'): Promise<Buffer> {
|
||||
async synthesize(
|
||||
provider: ITtsProvider,
|
||||
providerId: string,
|
||||
voiceId: string,
|
||||
speed: number,
|
||||
text: string,
|
||||
format = 'mp3',
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> {
|
||||
const event = 'tts.synthesis';
|
||||
const cacheKey = this.buildCacheKey(providerId, voiceId, speed, text, format);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
@@ -39,7 +47,7 @@ export class TtsSynthesisService {
|
||||
const startMs = Date.now();
|
||||
this.logger.log(`[${event}] [start] providerId=${providerId} voiceId=${voiceId} speed=${speed} textLen=${text.length} - synthesis started`);
|
||||
try {
|
||||
const buffer = await provider.synthesize(text, voiceId, speed, format);
|
||||
const buffer = await provider.synthesize(text, voiceId, speed, format, signal);
|
||||
this.logger.log(
|
||||
`[${event}] [end] providerId=${providerId} voiceId=${voiceId} durationMs=${Date.now() - startMs} bytes=${buffer.length} - synthesis completed`,
|
||||
);
|
||||
@@ -48,6 +56,12 @@ export class TtsSynthesisService {
|
||||
}
|
||||
return buffer;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
this.logger.log(
|
||||
`[${event}] [end] providerId=${providerId} voiceId=${voiceId} durationMs=${Date.now() - startMs} outcome=cancelled - synthesis cancelled because the client disconnected`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
const error = err instanceof Error ? sanitizeLogValue(err.message) : 'unknown';
|
||||
this.logger.error(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { TtsController } from './tts.controller';
|
||||
import { TtsService } from './tts.service';
|
||||
@@ -37,6 +39,78 @@ describe('TtsController', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('synthesize', () => {
|
||||
const dto = { text: 'Hello', providerId: '1', voiceId: 'af_heart', speed: 1.0 };
|
||||
|
||||
function makeReply() {
|
||||
const raw = Object.assign(new EventEmitter(), { writableEnded: false });
|
||||
const reply = {
|
||||
raw,
|
||||
header: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockImplementation(() => {
|
||||
raw.writableEnded = true;
|
||||
return reply;
|
||||
}),
|
||||
};
|
||||
return reply;
|
||||
}
|
||||
|
||||
function holdSynthesisUntilAborted(): { signal: () => AbortSignal } {
|
||||
let captured: AbortSignal | undefined;
|
||||
mockTtsService.synthesize.mockImplementationOnce(
|
||||
(_dto: unknown, signal: AbortSignal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
captured = signal;
|
||||
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||
}),
|
||||
);
|
||||
return { signal: () => captured! };
|
||||
}
|
||||
|
||||
it('sends the synthesized audio with its content type', async () => {
|
||||
const reply = makeReply();
|
||||
await controller.synthesize(dto, reply as unknown as FastifyReply);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'audio/mpeg');
|
||||
expect(reply.send).toHaveBeenCalledWith(Buffer.from('audio'));
|
||||
expect(reply.raw.listenerCount('close')).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels the synthesis and sends nothing when the client disconnects first', async () => {
|
||||
const reply = makeReply();
|
||||
const held = holdSynthesisUntilAborted();
|
||||
const pending = controller.synthesize(dto, reply as unknown as FastifyReply);
|
||||
expect(held.signal().aborted).toBe(false);
|
||||
|
||||
reply.raw.emit('close');
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(held.signal().aborted).toBe(true);
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
expect(reply.raw.listenerCount('close')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not cancel when the connection closes after the audio was sent', async () => {
|
||||
const reply = makeReply();
|
||||
const pending = controller.synthesize(dto, reply as unknown as FastifyReply);
|
||||
const signal = mockTtsService.synthesize.mock.calls[0]![1] as AbortSignal;
|
||||
await pending;
|
||||
|
||||
reply.raw.writableEnded = true;
|
||||
reply.raw.emit('close');
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows a synthesis failure while the client is still connected', async () => {
|
||||
mockTtsService.synthesize.mockRejectedValueOnce(new Error('provider down'));
|
||||
const reply = makeReply();
|
||||
|
||||
await expect(controller.synthesize(dto, reply as unknown as FastifyReply)).rejects.toThrow('provider down');
|
||||
expect(reply.raw.listenerCount('close')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('synthesizeCaptioned', () => {
|
||||
it('returns base64 audio with word timings as JSON, not raw bytes', async () => {
|
||||
const payload = {
|
||||
|
||||
@@ -13,10 +13,26 @@ import { TtsService } from './tts.service';
|
||||
export class TtsController {
|
||||
constructor(private readonly ttsService: TtsService) {}
|
||||
|
||||
/**
|
||||
* A reader that skips ahead or changes voice drops the request, and the provider call is cancelled
|
||||
* with it. Not `request.signal`: Fastify aborts that once the request body has been read.
|
||||
*/
|
||||
@Post('synthesize')
|
||||
async synthesize(@Body() dto: SynthesizeDto, @Res() reply: FastifyReply) {
|
||||
const buffer = await this.ttsService.synthesize(dto);
|
||||
void reply.header('Content-Type', ttsContentType(dto.format)).header('Content-Length', buffer.length).send(buffer);
|
||||
const disconnected = new AbortController();
|
||||
const handleClose = () => {
|
||||
if (!reply.raw.writableEnded) disconnected.abort();
|
||||
};
|
||||
reply.raw.on('close', handleClose);
|
||||
try {
|
||||
const buffer = await this.ttsService.synthesize(dto, disconnected.signal);
|
||||
void reply.header('Content-Type', ttsContentType(dto.format)).header('Content-Length', buffer.length).send(buffer);
|
||||
} catch (err) {
|
||||
if (disconnected.signal.aborted) return;
|
||||
throw err;
|
||||
} finally {
|
||||
reply.raw.off('close', handleClose);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,19 +107,19 @@ describe('TtsService', () => {
|
||||
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
|
||||
await service.synthesize({ providerId: '1', voiceId: 'alloy', text: 'Hello', speed: 1.0 });
|
||||
expect(factory.getOpenAiProvider).toHaveBeenCalledWith(DB_PROVIDER);
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3');
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3', undefined);
|
||||
});
|
||||
|
||||
it('should trim voiceId before synthesis', async () => {
|
||||
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
|
||||
await service.synthesize({ providerId: '1', voiceId: ' alloy ', text: 'Hello', speed: 1.0 });
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3');
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3', undefined);
|
||||
});
|
||||
|
||||
it('should fall back to the first listed voice when voiceId is missing', async () => {
|
||||
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
|
||||
await service.synthesize({ providerId: '1', voiceId: '', text: 'Hello', speed: 1.0 });
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3');
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3', undefined);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when no fallback voices are available', async () => {
|
||||
@@ -295,7 +295,7 @@ describe('TtsService', () => {
|
||||
it('synthesizes a curated voice', async () => {
|
||||
curateProvider();
|
||||
await service.synthesize({ providerId: '1', voiceId: 'af_heart', text: 'Hello', speed: 1.0 });
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_heart', 1.0, 'Hello', 'mp3');
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_heart', 1.0, 'Hello', 'mp3', undefined);
|
||||
});
|
||||
|
||||
it('rejects a voice curation has withdrawn', async () => {
|
||||
@@ -313,7 +313,7 @@ describe('TtsService', () => {
|
||||
it('accepts any voice id when the provider is not curated', async () => {
|
||||
repo.findProviderById.mockResolvedValue({ ...DB_PROVIDER, staticVoices: null });
|
||||
await service.synthesize({ providerId: '1', voiceId: 'af_sky+af_bella', text: 'Hello', speed: 1.0 });
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_sky+af_bella', 1.0, 'Hello', 'mp3');
|
||||
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_sky+af_bella', 1.0, 'Hello', 'mp3', undefined);
|
||||
});
|
||||
|
||||
it('clears a withdrawn voice from saved preferences', async () => {
|
||||
|
||||
@@ -46,10 +46,18 @@ export class TtsService {
|
||||
|
||||
// ---- Synthesis ----
|
||||
|
||||
async synthesize(dto: SynthesizeDto): Promise<Buffer> {
|
||||
async synthesize(dto: SynthesizeDto, signal?: AbortSignal): Promise<Buffer> {
|
||||
const resolved = await this.resolveProvider(dto.providerId);
|
||||
const voiceId = await this.resolveSynthesisVoiceId(dto.providerId, dto.voiceId, resolved);
|
||||
return this.synthesisService.synthesize(resolved.provider, dto.providerId, voiceId, dto.speed, dto.text, dto.format ?? DEFAULT_TTS_AUDIO_FORMAT);
|
||||
return this.synthesisService.synthesize(
|
||||
resolved.provider,
|
||||
dto.providerId,
|
||||
voiceId,
|
||||
dto.speed,
|
||||
dto.text,
|
||||
dto.format ?? DEFAULT_TTS_AUDIO_FORMAT,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user