238 lines
9.9 KiB
JavaScript
238 lines
9.9 KiB
JavaScript
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 <input>. 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 })
|
|
})
|
|
})
|