// @ts-check // Audiobook emotion path: span-cast segment emotion -> script -> rehearser line -> instruct -> /api/tts-preview. // Routes are mocked; nothing here reaches the live TTS API. const { test, expect } = require('./_guard'); const WAV = Buffer.from('UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA=', 'base64'); async function openReader(page) { await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); await page.evaluate(() => window.navTo('s-reader')); await page.waitForFunction(() => typeof window.audiobookBuildScript === 'function' && typeof window.fetchTtsPreviewBlob === 'function', null, { timeout: 15_000 }); } test('span-cast emotion reaches the tts-preview instruct for dialogue only; export shares the path', async ({ page }) => { const bodies = []; await page.route('**/api/tts-preview', route => { bodies.push(route.request().postDataJSON()); return route.fulfill({ status: 200, contentType: 'audio/wav', body: WAV }); }); await page.route('**/api/attribute-dialogue', route => route.fulfill({ json: { segments: [] } })); await openReader(page); // segments exactly as audiobook-cast-spans.js builds them (emotion from the LLM, narration always '') const res = await page.evaluate(async () => { const segs = [ { speaker: 'Narrator', type: 'narration', text: 'Der Zwerg schneuzte sich.', emotion: '', spanId: 'N0' }, { speaker: 'Linosch', type: 'dialogue', text: 'Na los!', emotion: 'wütend', spanId: 'D1' }, { speaker: 'Alrik', type: 'dialogue', text: 'Vielen Dank.', emotion: '', spanId: 'D2' }, ]; const { script, emotions } = window.audiobookBuildScript(segs); rehState.lines = window.parseScript(script); rehState.cast = { LINOSCH: { voice: 'DE_M_test', instruct: '' }, ALRIK: { voice: 'DE_M_test2', instruct: '' } }; rehState.narratorVoice = 'DE_F_narr'; let k = 0; rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = emotions[k++]; if (e) l.emotion = e; } }); const out = []; for (const l of rehState.lines) { const { voice, instruct } = window.audiobookLineVoice(l); await window.fetchTtsPreviewBlob(voice, l.text, 'wav', instruct, 'voice_design'); out.push({ text: l.text, type: l.type, emotion: l.emotion || '' }); } return { out, exportUsesIt: /audiobookLineVoice\(/.test(window.audiobookExport.toString()) }; }); expect(res.exportUsesIt).toBe(true); // the export loop builds its instruct via the same helper expect(bodies).toHaveLength(3); const byText = Object.fromEntries(bodies.map(b => [b.text, b.instruct])); expect(byText['Der Zwerg schneuzte sich.']).toBe(''); expect(byText['Na los!']).toContain('angry'); // German "wütend" translated to the English tag expect(byText['Na los!']).not.toContain('wütend'); expect(byText['Vielen Dank.']).not.toMatch(/angry/); // empty emotion: no emotion clause }); test('changing the emotion changes the line cache key (no stale flat audio)', async ({ page }) => { await openReader(page); const keys = await page.evaluate(async () => { window.rehState.cast = { LINOSCH: { voice: 'DE_M_test', instruct: '' } }; const k = async (emo) => { const { instruct } = window.audiobookLineVoice({ type: 'dialog', speaker: 'LINOSCH', text: 'Na los!', emotion: emo }); return window._lineAudioCacheKey('Na los!', 'DE_M_test', instruct); }; return [await k(''), await k('wütend'), await k('wütend'), await k('traurig')]; }); expect(keys[0]).not.toBe(keys[1]); expect(keys[1]).toBe(keys[2]); expect(keys[1]).not.toBe(keys[3]); });