66 lines
3.7 KiB
JavaScript
66 lines
3.7 KiB
JavaScript
// @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]);
|
|
});
|