diff --git a/.gitignore b/.gitignore index 40746abe..eb94894f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ build/ # Environment # .env .venv/ +node_modules/ env/ venv/ ENV/ @@ -87,3 +88,5 @@ pyproject.toml.bkp # Local scratch notes, scan outputs, anything not meant to ship .local/ examples/assorted_checks/test_silence/out/* +playwright-report/ +test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe7d5b1..a8ed72b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Notable changes to this project will be documented in this file. Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary. +## [Unreleased] +### Fixed +- Web UI long-playback bugfix around the 10-minute mark; in-browser audio buffer is now bounded ahead of `currentTime` with trailing eviction behind it, so long generations stop overflowing the SourceBuffer. +- Web UI stays responsive on extended sessions; waveform animation is transition-gated and `PlayerState` short-circuits no-op updates, so controls don't drift into lag after 10+ minutes of playback. + +### Notes +- Scrubbing may not be fully not supported in current state on MP3 streamed playback. WAV etc, plays back fine on completion. + ## [v0.4.0] - 2026-05-24 ### Added - GPU image variants for Blackwell / RTX 50-series (`:latest-cu128`, `:vX.Y.Z-cu128`, amd64 only) with PyTorch cu128 wheels (#443). Default `:latest` and new `:latest-cu126` alias stay on cu126 for Maxwell/Pascal compatibility. diff --git a/VERSION b/VERSION index 1d0ba9ea..20e5763a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1-rc1 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..e62461e6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "Kokoro-FastAPI", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "^1.57.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..d57c075f --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "type": "module", + "scripts": { + "test:web": "node web/tests/unit/index.test.mjs", + "test:e2e": "playwright test", + "gpu:build": "docker compose -f docker/gpu/docker-compose.yml build", + "gpu:up": "docker compose -f docker/gpu/docker-compose.yml up", + "gpu:down": "docker compose -f docker/gpu/docker-compose.yml down", + "cpu:build": "docker compose -f docker/cpu/docker-compose.yml build", + "cpu:up": "docker compose -f docker/cpu/docker-compose.yml up", + "cpu:down": "docker compose -f docker/cpu/docker-compose.yml down", + "rocm:build": "docker compose -f docker/rocm/docker-compose.yml build", + "rocm:up": "docker compose -f docker/rocm/docker-compose.yml up", + "rocm:down": "docker compose -f docker/rocm/docker-compose.yml down" + }, + "devDependencies": { + "@playwright/test": "^1.57.0" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000..3e6c8e63 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './web/tests/e2e', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:4173', + }, + webServer: { + command: 'node web/tests/e2e/fixtures/static-server.mjs', + url: 'http://127.0.0.1:4173', + reuseExistingServer: true, + timeout: 10_000, + }, +}); diff --git a/web/src/App.js b/web/src/App.js index 9a83f192..53a9cb57 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -226,6 +226,9 @@ export class App { const voice = this.voiceService.getSelectedVoiceString(); const speed = this.playerState.getState().speed; + this.playerState.setReady(false); + this.playerState.setPlaying(false); + this.playerState.setTime(0, 0); this.setGenerating(true); this._playbackFailed = false; this.elements.downloadBtn.classList.remove('ready'); diff --git a/web/src/components/PlayerControls.js b/web/src/components/PlayerControls.js index 3daf33c8..7f5ca3fc 100644 --- a/web/src/components/PlayerControls.js +++ b/web/src/components/PlayerControls.js @@ -19,6 +19,10 @@ export class PlayerControls { } formatTime(secs) { + if (!Number.isFinite(secs) || secs < 0) { + return '0:00'; + } + const minutes = Math.floor(secs / 60); const seconds = Math.floor(secs % 60); return `${minutes}:${seconds.toString().padStart(2, '0')}`; @@ -47,7 +51,7 @@ export class PlayerControls { `${this.formatTime(currentTime)} / ${this.formatTime(duration || 0)}`; // Update seek slider - if (duration > 0 && !this.elements.seekSlider.dragging) { + if (Number.isFinite(duration) && duration > 0 && !this.elements.seekSlider.dragging) { this.elements.seekSlider.value = (currentTime / duration) * 100; } @@ -123,6 +127,11 @@ export class PlayerControls { this.stopTimeUpdate(); }); + this.audioService.addEventListener('ready', () => { + this.playerState.setReady(true); + this.updateTimeDisplay(); + }); + // Initial time display this.updateTimeDisplay(); } @@ -133,8 +142,8 @@ export class PlayerControls { updateControls(state) { // Update button states - this.elements.playPauseBtn.disabled = !state.duration && !state.isGenerating; - this.elements.seekSlider.disabled = !state.duration; + this.elements.playPauseBtn.disabled = !state.isReady && !state.isGenerating; + this.elements.seekSlider.disabled = !state.isReady || !Number.isFinite(state.duration) || state.duration <= 0; this.elements.cancelBtn.style.display = state.isGenerating ? 'block' : 'none'; // Update volume and speed if changed externally @@ -165,4 +174,4 @@ export class PlayerControls { } } -export default PlayerControls; \ No newline at end of file +export default PlayerControls; diff --git a/web/src/components/WaveVisualizer.js b/web/src/components/WaveVisualizer.js index b4df33d8..55ee32c8 100644 --- a/web/src/components/WaveVisualizer.js +++ b/web/src/components/WaveVisualizer.js @@ -40,6 +40,7 @@ export class WaveVisualizer { } setupStateSubscription() { + this.wasPlaying = false; this.playerState.subscribe(state => { // Handle generation progress if (state.isGenerating) { @@ -53,12 +54,14 @@ export class WaveVisualizer { }, 500); } - // Only animate when playing, stop otherwise - if (state.isPlaying) { + // SiriWave.start() is not idempotent — each call spawns a new RAF + // loop. Only call start/stop on isPlaying transitions. + if (state.isPlaying && !this.wasPlaying) { this.wave.start(); - } else { + } else if (!state.isPlaying && this.wasPlaying) { this.wave.stop(); } + this.wasPlaying = state.isPlaying; }); } diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index 63515410..d799a279 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -11,9 +11,13 @@ export class AudioService { this.textLength = 0; this.shouldAutoplay = false; this.CHARS_PER_CHUNK = 150; + this.MAX_LEAD_SECONDS = 60; this.serverDownloadPath = null; this.pendingOperations = []; this.objectUrl = null; + this.chunkQueue = []; + this.streamFinished = false; + this.feederWakeup = null; } supportsMSEMp3() { @@ -25,6 +29,21 @@ export class AudioService { ); } + shouldUseMseStream(responseFormat, canStreamMp3) { + return responseFormat === 'mp3' && canStreamMp3; + } + + attachAudioReadinessEvents() { + if (!this.audio) { + return; + } + + const dispatchReady = () => this.dispatchEvent('ready'); + this.audio.addEventListener('loadedmetadata', dispatchReady); + this.audio.addEventListener('durationchange', dispatchReady); + this.audio.addEventListener('canplay', dispatchReady); + } + async streamAudio(text, voice, speed, onProgress) { try { const canStreamMp3 = this.supportsMSEMp3(); @@ -43,7 +62,7 @@ export class AudioService { const estimatedChunks = Math.max(1, Math.ceil(this.textLength / this.CHARS_PER_CHUNK)); const responseFormat = document.getElementById('format-select').value || 'mp3'; - const canUseMseStream = responseFormat === 'mp3' && canStreamMp3; + const canUseMseStream = this.shouldUseMseStream(responseFormat, canStreamMp3); const apiUrl = await config.getApiUrl('/v1/audio/speech'); const response = await fetch(apiUrl, { @@ -118,8 +137,10 @@ export class AudioService { const blobType = response.headers.get('content-type') || 'audio/mpeg'; const blob = new Blob(chunks, { type: blobType }); this.audio = new Audio(); + this.attachAudioReadinessEvents(); this.objectUrl = URL.createObjectURL(blob); this.audio.src = this.objectUrl; + this.audio.load(); this.audio.addEventListener('error', () => { console.error('Audio error (block mode):', this.audio?.error); @@ -130,7 +151,7 @@ export class AudioService { this.dispatchEvent('ended'); }); - this.audio.addEventListener('canplaythrough', () => { + this.audio.addEventListener('canplay', () => { if (this.shouldAutoplay) { this.play(); } @@ -151,6 +172,7 @@ export class AudioService { } this.audio = new Audio(); + this.attachAudioReadinessEvents(); this.mediaSource = new MediaSource(); this.objectUrl = URL.createObjectURL(this.mediaSource); this.audio.src = this.objectUrl; @@ -183,8 +205,17 @@ export class AudioService { } async processStream(stream, response, onProgress, estimatedChunks) { + this.chunkQueue = []; + this.streamFinished = false; + this.feederWakeup = null; + + const feederPromise = this.runFeeder().catch((err) => { + if (err?.name !== 'AbortError') { + console.warn('Feeder error:', err); + } + }); + const reader = stream.getReader(); - let hasStartedPlaying = false; let receivedChunks = 0; try { @@ -204,22 +235,12 @@ export class AudioService { Object.keys(headers).join(', ')); } - if (this.mediaSource && this.mediaSource.readyState === 'open') { - this.mediaSource.endOfStream(); - } + this.streamFinished = true; + this.wakeFeeder(); onProgress?.(estimatedChunks, estimatedChunks); this.dispatchEvent('complete'); - if ( - this.shouldAutoplay && - !hasStartedPlaying && - this.sourceBuffer && - this.sourceBuffer.buffered.length > 0 - ) { - setTimeout(() => this.play(), 100); - } - setTimeout(() => { this.dispatchEvent('downloadReady'); }, 800); @@ -229,57 +250,122 @@ export class AudioService { receivedChunks++; onProgress?.(receivedChunks, estimatedChunks); + this.chunkQueue.push(value); + this.wakeFeeder(); + } + } catch (error) { + this.streamFinished = true; + this.wakeFeeder(); + if (error.name !== 'AbortError') { + throw error; + } + } + } - try { - if (this.audio?.error) { - console.error('Audio error detected:', this.audio.error); - continue; + wakeFeeder() { + if (this.feederWakeup) { + const resolve = this.feederWakeup; + this.feederWakeup = null; + resolve(); + } + } + + waitForFeederSignal(timeoutMs) { + return new Promise((resolve) => { + this.feederWakeup = resolve; + if (timeoutMs) { + setTimeout(() => { + if (this.feederWakeup === resolve) { + this.feederWakeup = null; + resolve(); } + }, timeoutMs); + } + }); + } - if (this.sourceBuffer?.buffered.length > 0) { - const currentTime = this.audio.currentTime; - const start = this.sourceBuffer.buffered.start(0); + async runFeeder() { + let hasStartedPlaying = false; - if (currentTime - start > 30) { - const removeEnd = Math.max(start, currentTime - 15); - if (removeEnd > start) { - await this.removeBufferRange(start, removeEnd); - } - } + while (true) { + if (!this.audio || !this.sourceBuffer || !this.mediaSource) { + return; + } + if (this.streamFinished && this.chunkQueue.length === 0) { + if (this.mediaSource.readyState === 'open') { + try { + this.mediaSource.endOfStream(); + } catch (e) { + console.warn('endOfStream error:', e); } + } + return; + } + if (this.chunkQueue.length === 0) { + await this.waitForFeederSignal(); + continue; + } - await this.appendChunk(value); + const currentTime = this.audio.currentTime || 0; + const buffered = this.sourceBuffer.buffered; + + // Leading-edge backpressure: hold off if we already have plenty queued + // ahead of currentTime. Keeps MSE buffer bounded so long generations + // (>10 min) don't hit QuotaExceededError. + if (buffered.length > 0) { + const leadingEdge = buffered.end(buffered.length - 1); + if (leadingEdge - currentTime > this.MAX_LEAD_SECONDS) { + await this.waitForFeederSignal(250); + continue; + } + } - if (!hasStartedPlaying && this.sourceBuffer?.buffered.length > 0) { - hasStartedPlaying = true; - if (this.shouldAutoplay) { - setTimeout(() => this.play(), 100); - } + // Trailing eviction: drop audio more than 30s behind currentTime. + if (buffered.length > 0) { + const start = buffered.start(0); + if (currentTime - start > 30) { + const removeEnd = Math.max(start, currentTime - 15); + if (removeEnd > start) { + await this.removeBufferRange(start, removeEnd); } - } catch (error) { - if (error.name === 'QuotaExceededError') { - if (this.sourceBuffer?.buffered.length > 0) { - const currentTime = this.audio.currentTime; - const start = this.sourceBuffer.buffered.start(0); - const removeEnd = Math.max(start, currentTime - 5); - if (removeEnd > start) { - await this.removeBufferRange(start, removeEnd); - try { - await this.appendChunk(value); - } catch (retryError) { - console.warn('Buffer error after cleanup:', retryError); - } - } + } + } + + const chunk = this.chunkQueue.shift(); + try { + if (this.audio?.error) { + console.error('Audio error detected:', this.audio.error); + continue; + } + + await this.appendChunk(chunk); + this.dispatchEvent('ready'); + + if (!hasStartedPlaying && this.sourceBuffer?.buffered.length > 0) { + hasStartedPlaying = true; + if (this.shouldAutoplay) { + setTimeout(() => this.play(), 100); + } + } + } catch (error) { + if (error.name === 'QuotaExceededError') { + this.chunkQueue.unshift(chunk); + const buf = this.sourceBuffer?.buffered; + if (buf && buf.length > 0) { + const start = buf.start(0); + const removeEnd = Math.max(start, (this.audio?.currentTime || 0) - 5); + if (removeEnd > start) { + await this.removeBufferRange(start, removeEnd); } } else { - console.warn('Buffer error:', error); + return; } + } else if (error?.name === 'AbortError') { + return; + } else { + console.warn('Buffer error:', error); } } - } catch (error) { - if (error.name !== 'AbortError') { - throw error; - } } } @@ -295,12 +381,25 @@ export class AudioService { return new Promise((resolve) => { const doRemove = () => { + const sourceBuffer = this.sourceBuffer; + if (!sourceBuffer || !this.mediaSource || this.mediaSource.readyState !== 'open') { + resolve(); + return; + } + + const onUpdateEnd = () => { + sourceBuffer.removeEventListener('updateend', onUpdateEnd); + resolve(); + }; + try { - this.sourceBuffer.remove(start, end); + sourceBuffer.addEventListener('updateend', onUpdateEnd, { once: true }); + sourceBuffer.remove(start, end); } catch (e) { console.warn('Error removing buffer:', e); + sourceBuffer.removeEventListener('updateend', onUpdateEnd); + resolve(); } - resolve(); }; if (this.sourceBuffer.updating) { @@ -375,7 +474,7 @@ export class AudioService { } play() { - if (this.audio && this.audio.readyState >= 2 && !this.audio.error) { + if (this.audio && !this.audio.error) { const playPromise = this.audio.play(); if (playPromise) { playPromise.catch(error => { @@ -458,6 +557,18 @@ export class AudioService { } } + rejectPendingOperations(reason) { + const ops = this.pendingOperations; + this.pendingOperations = []; + ops.forEach((op) => { + try { + op.reject(reason); + } catch (e) { + // ignore + } + }); + } + cancel() { if (this.controller) { this.controller.abort(); @@ -480,7 +591,10 @@ export class AudioService { this.mediaSource = null; this.sourceBuffer = null; this.serverDownloadPath = null; - this.pendingOperations = []; + this.rejectPendingOperations(new Error('AudioService cancelled')); + this.chunkQueue = []; + this.streamFinished = true; + this.wakeFeeder(); this.revokeObjectUrl(); } @@ -507,7 +621,10 @@ export class AudioService { this.mediaSource = null; this.sourceBuffer = null; this.serverDownloadPath = null; - this.pendingOperations = []; + this.rejectPendingOperations(new Error('AudioService cleanup')); + this.chunkQueue = []; + this.streamFinished = true; + this.wakeFeeder(); this.revokeObjectUrl(); } diff --git a/web/src/state/PlayerState.js b/web/src/state/PlayerState.js index a2ea7b1e..3313bec7 100644 --- a/web/src/state/PlayerState.js +++ b/web/src/state/PlayerState.js @@ -8,6 +8,7 @@ export class PlayerState { volume: 1, speed: 1, progress: 0, + isReady: false, error: null }; this.listeners = new Set(); @@ -23,6 +24,14 @@ export class PlayerState { } setState(updates) { + let changed = false; + for (const key in updates) { + if (updates[key] !== this.state[key]) { + changed = true; + break; + } + } + if (!changed) return; this.state = { ...this.state, ...updates @@ -43,6 +52,10 @@ export class PlayerState { this.setState({ progress }); } + setReady(isReady) { + this.setState({ isReady }); + } + setTime(currentTime, duration) { this.setState({ currentTime, duration }); } @@ -74,6 +87,7 @@ export class PlayerState { currentTime: 0, duration: 0, progress: 0, + isReady: false, error: null, speed: currentSpeed, volume: currentVolume @@ -85,4 +99,4 @@ export class PlayerState { } } -export default PlayerState; \ No newline at end of file +export default PlayerState; diff --git a/web/tests/e2e/fixtures/static-server.mjs b/web/tests/e2e/fixtures/static-server.mjs new file mode 100644 index 00000000..04dd11c4 --- /dev/null +++ b/web/tests/e2e/fixtures/static-server.mjs @@ -0,0 +1,52 @@ +import { createReadStream, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve } from 'node:path'; + +const port = Number(process.env.PLAYWRIGHT_STATIC_PORT || 4173); +const root = resolve('web'); + +const contentTypes = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.svg': 'image/svg+xml', +}; + +function resolveRequestPath(url) { + const pathname = new URL(url, `http://127.0.0.1:${port}`).pathname; + const relativePath = pathname === '/' ? 'index.html' : pathname.slice(1); + const requested = resolve(root, normalize(relativePath)); + + if (!requested.startsWith(root)) { + return null; + } + + return requested; +} + +const server = createServer((request, response) => { + const filePath = resolveRequestPath(request.url); + if (!filePath) { + response.writeHead(403); + response.end(); + return; + } + + try { + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new Error('Not a file'); + } + + response.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream', + }); + createReadStream(filePath).pipe(response); + } catch { + response.writeHead(404); + response.end(); + } +}); + +server.listen(port, '127.0.0.1'); diff --git a/web/tests/e2e/long-playback.spec.mjs b/web/tests/e2e/long-playback.spec.mjs new file mode 100644 index 00000000..58d2f52a --- /dev/null +++ b/web/tests/e2e/long-playback.spec.mjs @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test'; + +function longText() { + return Array.from({ length: 2000 }, (_, index) => `word${index}`).join(' '); +} + +test('long MP3 generation uses MediaSource streaming', async ({ page }) => { + await page.addInitScript(() => { + class MockSourceBuffer extends EventTarget { + constructor() { + super(); + this.updating = false; + this.mode = 'segments'; + this.buffered = { + length: 0, + start: () => 0, + end: () => 0, + }; + } + + appendBuffer() { + window.__sourceBufferAppends = (window.__sourceBufferAppends || 0) + 1; + this.updating = true; + setTimeout(() => { + this.updating = false; + this.dispatchEvent(new Event('updateend')); + }, 0); + } + + remove() { + this.updating = true; + setTimeout(() => { + this.updating = false; + this.dispatchEvent(new Event('updateend')); + }, 0); + } + } + + class MockMediaSource extends EventTarget { + constructor() { + super(); + window.__mediaSourceConstructed = (window.__mediaSourceConstructed || 0) + 1; + this.readyState = 'closed'; + setTimeout(() => { + this.readyState = 'open'; + this.dispatchEvent(new Event('sourceopen')); + }, 0); + } + + static isTypeSupported() { + return true; + } + + addSourceBuffer() { + window.__sourceBufferCreated = (window.__sourceBufferCreated || 0) + 1; + return new MockSourceBuffer(); + } + + endOfStream() { + this.readyState = 'ended'; + } + } + + window.__mediaSourceConstructed = 0; + window.__sourceBufferCreated = 0; + window.__sourceBufferAppends = 0; + Object.defineProperty(window, 'MediaSource', { + configurable: true, + value: MockMediaSource, + }); + }); + + await page.route('**/web/config', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ root_path: '', version: 'test' }), + }); + }); + + await page.route('**/v1/audio/voices', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ voices: [{ id: 'af_heart', name: 'af_heart' }] }), + }); + }); + + let speechRequestBody = null; + await page.route('**/v1/audio/speech', async (route) => { + speechRequestBody = JSON.parse(route.request().postData()); + await route.fulfill({ + contentType: 'audio/mpeg', + headers: { 'X-Download-Path': '/download/test.mp3' }, + body: Buffer.from([0xff, 0xfb, 0x90, 0x64]), + }); + }); + + await page.goto('/'); + await page.locator('.page-content').fill(longText()); + await page.locator('#generate-btn').click(); + await expect.poll(() => speechRequestBody).not.toBeNull(); + + expect(speechRequestBody.response_format).toBe('mp3'); + expect(speechRequestBody.stream).toBe(true); + await expect.poll(() => page.evaluate(() => window.__mediaSourceConstructed)).toBeGreaterThan(0); + await expect.poll(() => page.evaluate(() => window.__sourceBufferCreated)).toBeGreaterThan(0); +}); diff --git a/web/tests/unit/audio-service.test.mjs b/web/tests/unit/audio-service.test.mjs new file mode 100644 index 00000000..493cc244 --- /dev/null +++ b/web/tests/unit/audio-service.test.mjs @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const { AudioService } = await import('../../src/services/AudioService.js'); + +test('AudioService streams supported MP3 requests with MediaSource regardless of length', () => { + const service = new AudioService(); + + assert.equal(service.shouldUseMseStream('mp3', true), true); +}); + +test('AudioService does not use MediaSource for unsupported or non-MP3 output', () => { + const service = new AudioService(); + + assert.equal(service.shouldUseMseStream('mp3', false), false); + assert.equal(service.shouldUseMseStream('wav', true), false); + assert.equal(service.shouldUseMseStream('pcm', true), false); +}); diff --git a/web/tests/unit/index.test.mjs b/web/tests/unit/index.test.mjs new file mode 100644 index 00000000..78ac1294 --- /dev/null +++ b/web/tests/unit/index.test.mjs @@ -0,0 +1,2 @@ +import './audio-service.test.mjs'; +import './player-controls.test.mjs'; diff --git a/web/tests/unit/player-controls.test.mjs b/web/tests/unit/player-controls.test.mjs new file mode 100644 index 00000000..91e6ea0c --- /dev/null +++ b/web/tests/unit/player-controls.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +class FakeClassList { + constructor() { + this.classes = new Set(); + } + + add(name) { + this.classes.add(name); + } + + remove(name) { + this.classes.delete(name); + } + + toggle(name, force) { + if (force === undefined ? !this.classes.has(name) : force) { + this.classes.add(name); + } else { + this.classes.delete(name); + } + } +} + +class FakeElement { + constructor() { + this.listeners = new Map(); + this.classList = new FakeClassList(); + this.style = {}; + this.disabled = false; + this.value = 0; + this.textContent = ''; + this.dragging = false; + } + + addEventListener(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + } +} + +class FakeAudioService { + constructor() { + this.listeners = new Map(); + } + + addEventListener(event, callback) { + this.listeners.set(event, callback); + } + + emit(event) { + this.listeners.get(event)?.(); + } + + getCurrentTime() { + return 0; + } + + getDuration() { + return 0; + } + + isPlaying() { + return false; + } + + play() {} + + pause() {} + + seek() {} + + setVolume() {} +} + +function setupDocument() { + const elements = new Map(); + global.document = { + getElementById(id) { + if (!elements.has(id)) { + elements.set(id, new FakeElement()); + } + return elements.get(id); + }, + }; + return elements; +} + +test('PlayerControls enables playback when audio readiness fires without duration', async () => { + const elements = setupDocument(); + const { PlayerState } = await import('../../src/state/PlayerState.js'); + const { PlayerControls } = await import('../../src/components/PlayerControls.js'); + + const audioService = new FakeAudioService(); + const playerState = new PlayerState(); + const controls = new PlayerControls(audioService, playerState); + + playerState.reset(); + const playPauseBtn = elements.get('play-pause-btn'); + const seekSlider = elements.get('seek-slider'); + + assert.equal(playPauseBtn.disabled, true); + + audioService.emit('ready'); + + assert.equal(playPauseBtn.disabled, false); + assert.equal(seekSlider.disabled, true); + + controls.cleanup(); +});