voice_audio_multi/webui/native/tests/ui-workflows.mjs
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

167 lines
11 KiB
JavaScript

// Browser regressions with deterministic HTTP fixtures. Real-model validation
// is separate; these checks exercise UI state and recipe request construction.
import { createRequire } from 'node:module';
import path from 'node:path';
import assert from 'node:assert/strict';
const require = createRequire(process.env.UI_BROWSER_TOOLS
? path.resolve(process.env.UI_BROWSER_TOOLS, 'package.json') : import.meta.url);
const { chromium } = require('playwright');
const wav = Buffer.alloc(44 + 1600 * 2);
wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(16000, 24); wav.writeUInt32LE(32000, 28);
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(wav.length - 44, 40);
const file = { name: 'speech.wav', mimeType: 'audio/wav', buffer: wav };
const packageIds = ['ming_3_0_tts_0_5b_q8_0', 'kitten_tts2_20261021_q4_0', 'canary_180m_flash_q8_0', 'moonshine_streaming_tiny_q8_0'];
const registered = new Map();
const speech = [];
const recipes = [];
let recipeBehavior = 'success';
let holdSpeech = false;
let releaseSpeech;
const browser = await chromium.launch({ channel: process.env.UI_BROWSER_CHANNEL || 'chrome', headless: true });
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/health', route => route.fulfill({ json: {
status: 'ok', backend: 'fixture', models: registered.size, ui: true, ui_management: true
} }));
await page.route('**/v1/**', async route => {
const req = route.request();
const url = new URL(req.url());
const data = req.method() === 'POST' && req.headers()['content-type']?.includes('application/json')
? req.postDataJSON() : {};
let json = { data: [], voices: [] };
if (url.pathname === '/v1/models') json = { data: [...registered.values()] };
if (url.pathname === '/v1/models/load') registered.set(data.id, { ...data, loaded: true, configured: false });
if (url.pathname === '/v1/models/unload') registered.delete(data.id);
if (url.pathname === '/v1/ui/models-root') json = { models_root: '/fixture', default_models_root: '/fixture', is_default: true };
if (url.pathname === '/v1/ui/models/package-sizes') json = { state: 'complete', message: '',
data: packageIds.map(id => ({ id, installed: true, size_bytes: 100, state: 'ok' })) };
if (url.pathname === '/v1/ui/path-status') json = { exists: true, file: true, directory: false };
if (url.pathname === '/v1/ui/upload') json = { path: '/fixture/upload.wav' };
if (url.pathname === '/v1/audio/speech') {
speech.push(data);
if (holdSpeech) await new Promise(resolve => { releaseSpeech = resolve; });
return route.fulfill({ status: 200, contentType: 'audio/wav', body: wav });
}
if (url.pathname === '/v1/recipes/run') {
recipes.push(data);
if (recipeBehavior === 'error') return route.fulfill({ status: 400, json: { error: { message: 'Translation failed' } } });
if (recipeBehavior === 'wait') await new Promise(resolve => setTimeout(resolve, 500));
json = { text: 'The train leaves in ten minutes.', audio: wav.toString('base64'),
timing: { wall_ms: 100, audio_duration_ms: 100, rtf: 1 } };
}
return route.fulfill({ json });
});
await page.goto(process.env.UI_BASE_URL || 'http://127.0.0.1:8080');
await page.getByRole('button', { name: /^Text to speech/ }).click();
await page.locator('#model').selectOption('ming3-tts');
await page.waitForFunction(() => document.querySelector('.runbar .status')?.textContent.trim() === 'Ready');
console.log('PASS selecting an installed model clears stale status');
const text = 'The library opens at nine in the morning. Please return the book before Friday. We will meet beside the old clock after lunch.';
await page.locator('#text').fill(text);
await page.getByRole('checkbox', { name: 'Split and merge long text' }).check();
await page.locator('#chunk-budget').fill('40');
await page.locator('button.run').click();
await page.waitForFunction(() => document.querySelector('.runbar .status')?.textContent.includes('Complete in'));
assert.equal(speech.length, 1);
assert.equal(speech[0].input, text);
assert.equal(speech[0].options.text_chunk_size, 40);
console.log('PASS Ming sends one complete request with native chunk settings');
speech.length = 0;
await page.locator('#model').selectOption('kitten-tts2-20261021');
await page.locator('#chunk-budget').fill('40');
await page.locator('#text').fill(text);
await page.locator('button.run').click();
await page.waitForFunction(() => document.querySelector('.runbar .status')?.textContent.includes('Complete in'));
assert(speech.length > 1);
assert(speech.every(request => request.model === 'kitten-tts2-20261021'));
console.log('PASS other models retain client-side splitting');
await page.getByRole('button', { name: 'Arena', exact: true }).click();
await page.locator('#arena-model').selectOption('kitten-tts2-20261021');
await page.locator('#arena-package').selectOption('kitten_tts2_20261021_q4_0');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await page.locator('#arena-text').fill('A short comparison.');
holdSpeech = true;
await page.getByRole('button', { name: 'Run arena', exact: true }).click();
await page.waitForFunction(() => document.querySelector('.arena-mode-switch button')?.disabled);
assert(await page.getByRole('button', { name: 'ASR', exact: true }).isDisabled());
for (let attempt = 0; attempt < 100 && !releaseSpeech; attempt++) {
await new Promise(resolve => setTimeout(resolve, 10));
}
assert(releaseSpeech, 'Arena must dispatch the held speech request');
holdSpeech = false;
releaseSpeech();
await page.getByRole('button', { name: 'ASR', exact: true }).waitFor();
await page.waitForFunction(() => !document.querySelector('.arena-mode-switch button')?.disabled);
assert.equal(await page.locator('.arena-grid audio').count(), 1);
await page.getByRole('button', { name: 'ASR', exact: true }).click();
assert.equal(await page.locator('.arena-grid audio').count(), 0);
assert(await page.getByRole('button', { name: 'Run arena', exact: true }).isDisabled());
console.log('PASS Arena locks task changes during a run and clears queue/results on task change');
await page.getByRole('button', { name: 'Apps', exact: true }).click();
await page.getByRole('button', { name: /Cross-language dubbing/ }).click();
const translator = page.getByLabel('Translation model', { exact: true });
await translator.locator('option[value="model:canary-asr:canary_180m_flash_q8_0"]').waitFor({ state: 'attached' });
assert.equal(await translator.locator('option').count(), 2, 'Only translation-capable ASR should be listed');
await translator.selectOption('model:canary-asr:canary_180m_flash_q8_0');
await page.getByLabel('Speech model', { exact: true }).selectOption('model:kitten-tts2-20261021:kitten_tts2_20261021_q4_0');
await page.getByLabel('Audio file', { exact: true }).setInputFiles(file);
await page.getByLabel('Source language', { exact: true }).fill('de');
await page.getByLabel('Target language', { exact: true }).fill('en');
await page.getByLabel('Dub voice', { exact: true }).selectOption('Bruno');
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
await page.getByText('Dub ready', { exact: true }).waitFor();
const recipe = recipes.at(-1);
assert.equal(recipe.recipe, 'cross-language-dubbing');
assert.deepEqual(recipe.models, { translator: 'canary-asr', tts: 'kitten-tts2-20261021' });
assert.equal(recipe.request.audio, '/fixture/upload.wav');
assert.equal(recipe.request.voice_id, 'Bruno');
assert.equal(recipe.request.options['translator.language'], 'de');
assert.equal(recipe.request.options['translator.target_language'], 'en');
assert(Number.isInteger(recipe.request.options['tts.seed']));
assert(!('language' in recipe.request.options));
const result = page.getByRole('region', { name: 'Dub result' });
const previousUrl = await result.locator('audio').getAttribute('src');
assert.equal(await result.getByRole('link', { name: 'WAV', exact: true }).getAttribute('download'), 'dub.wav');
assert.equal(await result.getByRole('link', { name: 'Translation', exact: true }).getAttribute('download'), 'translation.txt');
recipeBehavior = 'error';
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
await page.getByRole('alert').filter({ hasText: 'Translation failed' }).waitFor();
assert.equal(await result.locator('audio').getAttribute('src'), previousUrl);
recipeBehavior = 'wait';
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByText('Cancelled', { exact: true }).waitFor();
assert.equal(await result.locator('audio').getAttribute('src'), previousUrl);
console.log('PASS recipe routing, role options, voice, downloads, failure recovery and cancellation');
recipeBehavior = 'success';
await page.getByLabel('Speech model', { exact: true }).selectOption('model:ming3-tts:ming_3_0_tts_0_5b_q8_0');
await page.locator('.dubbing summary').filter({ hasText: 'Reference voice' }).click();
await page.getByLabel('Reference audio', { exact: true }).setInputFiles(file);
await page.getByLabel('Reference transcript', { exact: true }).fill('A reference sentence.');
await page.getByLabel('Source language', { exact: true }).fill('');
await page.locator('.dubbing .model-choice').first().getByRole('button', { name: 'More generation options', exact: true }).click();
await page.getByLabel('Translation model language', { exact: true }).fill('de');
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
await page.getByText('Dub ready', { exact: true }).waitFor();
assert.equal(recipes.at(-1).request.voice_ref, '/fixture/upload.wav');
assert.equal(recipes.at(-1).request.options['tts.reference_text'], 'A reference sentence.');
assert.equal(recipes.at(-1).request.options['translator.language'], 'de');
assert(!('voice_id' in recipes.at(-1).request));
assert(!('translator.reference_text' in recipes.at(-1).request.options));
console.log('PASS reference conditioning remains TTS-only and translator language settings are honored');
await page.setViewportSize({ width: 390, height: 844 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
assert.deepEqual(errors, []);
console.log('PASS mobile width and no browser exceptions');
} finally {
await browser.close();
}