import { test, expect } from './coverage-fixtures.js' function mockCapabilities(page, capabilities) { return page.route('**/api/models/capabilities', (route) => { route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: capabilities }), }) }) } // Returns a (Promise, resolver) pair that records the multipart form fields // the page submitted to /audio/transformations. The handler returns a tiny // fake WAV blob so the page can render its result waveforms. function mockAudioTransform(page, filename = 'transformed.wav') { let resolveSubmit const submitted = new Promise((resolve) => { resolveSubmit = resolve }) page.route('**/audio/transformations', (route) => { if (route.request().method() !== 'POST') return route.continue() const req = route.request() const body = req.postData() || '' resolveSubmit({ contentType: req.headers()['content-type'] || '', bodySize: body.length, // Naive multipart field name extraction so a test can assert the // form-data shape without parsing the multipart body. fields: Array.from(body.matchAll(/name="([^"]+)"/g)).map((m) => m[1]), }) const wavHeader = new Uint8Array(44) // 44-byte RIFF/WAVE skeleton route.fulfill({ status: 200, headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': `attachment; filename="${filename}"`, }, body: Buffer.from(wavHeader), }) }) return submitted } // Build a tiny in-memory WAV file (44-byte header, 4 silent samples) so // Playwright's setInputFiles + the page's audio decoder both have valid // bytes to chew on. Returns { name, mimeType, buffer } for setInputFiles. function makeFakeWav(name) { const sampleRate = 16000 const samples = 4 const dataLen = samples * 2 const buf = Buffer.alloc(44 + dataLen) buf.write('RIFF', 0) buf.writeUInt32LE(36 + dataLen, 4) buf.write('WAVE', 8) buf.write('fmt ', 12) buf.writeUInt32LE(16, 16) // PCM chunk size buf.writeUInt16LE(1, 20) // PCM format buf.writeUInt16LE(1, 22) // channels = 1 buf.writeUInt32LE(sampleRate, 24) // sample rate buf.writeUInt32LE(sampleRate * 2, 28)// byte rate buf.writeUInt16LE(2, 32) // block align buf.writeUInt16LE(16, 34) // bits per sample buf.write('data', 36) buf.writeUInt32LE(dataLen, 40) // body left as zeros (silence) return { name, mimeType: 'audio/wav', buffer: buf } } // Build a WAV carrying a real sine tone, long enough that the spectrogram // STFT produces several frames (a few thousand samples). Used to exercise the // FFT / heatmap path, which the 4-sample silent fixture can't. function makeToneWav(name, freq = 1000, seconds = 0.4, sampleRate = 16000) { const samples = Math.floor(seconds * sampleRate) const dataLen = samples * 2 const buf = Buffer.alloc(44 + dataLen) buf.write('RIFF', 0) buf.writeUInt32LE(36 + dataLen, 4) buf.write('WAVE', 8) buf.write('fmt ', 12) buf.writeUInt32LE(16, 16) buf.writeUInt16LE(1, 20) buf.writeUInt16LE(1, 22) buf.writeUInt32LE(sampleRate, 24) buf.writeUInt32LE(sampleRate * 2, 28) buf.writeUInt16LE(2, 32) buf.writeUInt16LE(16, 34) buf.write('data', 36) buf.writeUInt32LE(dataLen, 40) for (let i = 0; i < samples; i++) { const v = Math.round(Math.sin((2 * Math.PI * freq * i) / sampleRate) * 16000) buf.writeInt16LE(v, 44 + i * 2) } return { name, mimeType: 'audio/wav', buffer: buf } } test.describe('Audio Transform', () => { test.beforeEach(async ({ page }) => { await mockCapabilities(page, [ { id: 'localvqe', capabilities: ['FLAG_AUDIO_TRANSFORM'] }, ]) }) test('audio input has Upload + Record tabs', async ({ page }) => { await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // The Audio (required) input should expose both tabs. const tabs = page.getByRole('tab') await expect(tabs.filter({ hasText: 'Upload' }).first()).toBeVisible() await expect(tabs.filter({ hasText: 'Record' }).first()).toBeVisible() }) test('echo-test button only appears once a reference is loaded', async ({ page }) => { await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // No reference yet → echo button hidden. await expect(page.getByRole('button', { name: /Echo test/ })).toHaveCount(0) // Upload a reference into the second AudioInput's file picker. await page.locator('input[type="file"]').nth(1).setInputFiles(makeFakeWav('ref.wav')) await expect(page.getByRole('button', { name: /Echo test/ })).toBeVisible() }) test('renders the AudioTransform page directly', async ({ page }) => { await page.goto('/app/transform') await expect(page.getByRole('heading', { name: /Audio Transform/ })).toBeVisible({ timeout: 10_000 }) await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible() // Audio (required) + Reference (optional) drop zones await expect(page.getByText(/Audio \(required\)/)).toBeVisible() await expect(page.getByText(/Reference \(optional\)/)).toBeVisible() }) test('uploads an audio file, posts multipart, renders enhanced waveform', async ({ page }) => { const submitted = mockAudioTransform(page, 'enhanced.wav') await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // Upload mic file via the hidden file input under "Audio (required)". const audioInput = page.locator('input[type="file"]').first() await audioInput.setInputFiles(makeFakeWav('mic.wav')) await expect(page.getByText('mic.wav')).toBeVisible() // Set a backend tuning param so the form posts params[noise_gate]=true. await page.locator('.textarea').fill('noise_gate=true') await page.getByRole('button', { name: /Transform/ }).last().click() const form = await submitted expect(form.contentType).toContain('multipart/form-data') expect(form.fields).toContain('model') expect(form.fields).toContain('audio') expect(form.fields).toContain('params[noise_gate]') // After processing, the output WaveformPlayer mounts with a download button. await expect(page.getByRole('link', { name: /Download/ })).toBeVisible({ timeout: 10_000 }) }) test('reference file is forwarded as a multipart field when provided', async ({ page }) => { const submitted = mockAudioTransform(page) await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) const inputs = page.locator('input[type="file"]') await inputs.nth(0).setInputFiles(makeFakeWav('mic.wav')) // After the audio file is set, that AudioInput collapses to a filename + // Clear button and removes its . The reference AudioInput, which // was at nth(1), is now the sole remaining input — query afresh. await inputs.first().setInputFiles(makeFakeWav('loopback.wav')) await page.getByRole('button', { name: /Transform/ }).last().click() const form = await submitted expect(form.fields).toContain('audio') expect(form.fields).toContain('reference') }) test('history entry appears after a successful transform and persists across navigation', async ({ page }) => { mockAudioTransform(page, 'enhanced.wav') await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav')) await page.getByRole('button', { name: /Transform/ }).last().click() await expect(page.getByRole('link', { name: /Download/ })).toBeVisible({ timeout: 10_000 }) await expect(page.getByTestId('media-history-item')).toHaveCount(1) await expect(page.getByTestId('media-history-item')).toContainText('mic.wav') // Persist across page reloads via localStorage. await page.waitForTimeout(600) await page.goto('/app/transform') await expect(page.getByTestId('media-history-item')).toHaveCount(1) }) test('renders an input spectrogram on upload and an output one after transform', async ({ page }) => { mockAudioTransform(page, 'enhanced.wav') await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // Choosing a clip should render its input spectrogram immediately — no // backend round-trip needed (it's computed client-side from the bytes). await page.locator('input[type="file"]').first().setInputFiles(makeToneWav('tone.wav')) await expect(page.getByTestId('spectrogram-input')).toBeVisible({ timeout: 10_000 }) // Until a transform runs the output side shows a "compare" placeholder. await expect(page.getByText(/Transform to compare/)).toBeVisible() await page.getByRole('button', { name: /Transform/ }).last().click() // After processing, the output spectrum panel appears alongside the input. await expect(page.getByText('Output spectrum')).toBeVisible({ timeout: 10_000 }) }) test('shows an error banner when the backend returns 4xx', async ({ page }) => { await page.route('**/audio/transformations', (route) => { if (route.request().method() !== 'POST') return route.continue() route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: { message: 'audio sample rate 44100 != model 16000' } }), }) }) await page.goto('/app/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav')) await page.getByRole('button', { name: /Transform/ }).last().click() await expect(page.getByText(/sample rate/)).toBeVisible({ timeout: 10_000 }) }) })