diff --git a/docker-compose.yml b/docker-compose.yml index d723f5c..db7564a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - /mnt/data/ytplayer:/app/bulk environment: PORT: "3000" + PIANO_WORKER_ENABLED: "${PIANO_WORKER_ENABLED:-0}" + PIANO_WORKER_TOKEN: "${PIANO_WORKER_TOKEN:-}" MEDIA_DIR: "/app/bulk/media" MEDIA_VOLUME_MARKER: "/app/bulk/.hl-data" MEDIA_CACHE_MAX_BYTES: "268435456000" # 250 GiB of the 458 GiB drive @@ -102,9 +104,27 @@ services: networks: - lyrics + # Optional high-fidelity transcription. Never starts without --profile piano. + piano-worker: + profiles: ["piano"] + build: ./scripts/piano + restart: unless-stopped + depends_on: [ytplayer] + environment: + YTP_BASE: "http://ytplayer:3000" + YTP_TOKEN: "${PIANO_WORKER_TOKEN:-}" + PIANO_THREADS: "2" + volumes: + - piano-models:/models + cpus: "2" + mem_limit: 6g + networks: [lyrics] + volumes: ytplayer-data: driver: local + piano-models: + driver: local lyrics-models: driver: local lyrics-state: diff --git a/docs/piano-transcription.md b/docs/piano-transcription.md new file mode 100644 index 0000000..22ffb33 --- /dev/null +++ b/docs/piano-transcription.md @@ -0,0 +1,28 @@ +# Piano transcription + +Commit 258aad8 added ideas, not runtime code. The implementation is now opt-in under Settings → Piano transcription. + +## What runs where + +- **Browser:** `frontend/piano-engine.mjs` imports pinned Spotify Basic Pitch 1.0.1 and TensorFlow.js 4.22.0 only after “Transcribe in browser”. It resamples to mono 22,050 Hz in OfflineAudioContext, prefers WebGPU, and falls back to WebGL/CPU. Dependencies load from esm.sh; model/weights load from jsDelivr. Neither the engine nor model is in the service-worker install shell. No audio is uploaded by this path. Processing still needs a network on first use. File/device/server-cached audio is limited to 100 MB and 15 minutes. Basic Pitch is instrument-agnostic, not piano stem separation; mixtures may produce inaccurate notes. +- **Device cache:** IndexedDB `ytp-piano/notes`, per video id, stores the engine label and compact JSON sequence. Export produces `.notes.json`. Each note is `{pitch,start,end,velocity,hand}`. Timing is in seconds; velocity is 1–127. Missing hands use a middle-C split (pitch < 60 = left), which is a visual aid, not reliable fingering. Settings shows an 88-key synced visualizer and a manual practice queue of 8-second A–B loops. +- **Server:** optional `server/piano.js` queues authenticated jobs in SQLite; `scripts/piano/worker.py` downloads server media on the private compose network and runs ByteDance's `piano_transcription_inference` on CPU. This does not run in the web server. Model loading happens at the first claimed job. The worker is off by default and the compose `piano` profile is inactive by default. The checkpoint is about 165 MB; runtime memory needs substantially more (container limit 6 GB). + +## Enable the optional worker + +Configure a dedicated random `PIANO_WORKER_TOKEN` of at least 24 characters and `PIANO_WORKER_ENABLED=1` for the app and worker. Enable the compose `piano` profile and build/start the worker during a separately approved deployment. It shares the existing private `lyrics` network, keeps its checkpoint in `piano-models`, and is limited to two CPUs. Nothing is pushed or deployed by these changes. + +Use an admin API token in the Settings server-request field (kept only in the mounted UI, not persisted), or call the contract directly. Save the song on the server first. “Load server result” retrieves the finished sequence and stores it on the device. Worker jobs survive server restarts, have heartbeat leases and three attempts; expired workers cannot publish. Queue capacity is ten. Replacing a server media generation marks its old sequence stale. + +## API contract + +- `GET /api/media/:id/piano`: public `{ok,enabled,job}`; `job` is null or `{videoId,status,notes,error,updatedAt}`. Notes are available only after completion. Status: queued/running/ready/failed/stale. +- `POST /api/media/:id/piano`: existing admin session/API-token authentication. Returns an existing valid job or queues one (202). Worker off → 503; uncached source → 409; duration over 15 minutes → 422; queue full → 429. +- `POST /api/piano-worker/claim`: dedicated Bearer worker token; returns a job with lease and server-relative audioPath, or null. Claims are atomic. +- `POST /api/piano-worker/:id`: dedicated worker token plus JSON `{lease,action}`. Actions: heartbeat; complete with `notes`; fail with `error`. Invalid/expired leases → 409. Results are bounded and validated before saving. + +The model path is based on [Spotify's browser API](https://github.com/spotify/basic-pitch-ts) and [ByteDance's inference package](https://github.com/qiuqiangkong/piano_transcription_inference). Their upstream licences apply. The browser's TFJS dependency override is pinned and tested with a synthetic WAV; review GPU performance and accuracy with real recordings. + +## Review on devices + +Verify no model request before Transcribe, GPU/CPU fallback, mobile memory limits, cache survival after reload, JSON export, keyboard alignment with seeking, and A–B practice loop timing. Run the optional worker against a short known piano recording before enabling it for users; heavy-model/container inference is not covered by ordinary unit tests. This version does not provide model-based stem separation, score notation, pedal transcription display, or MIDI wait-for-note practice. diff --git a/frontend/app.js b/frontend/app.js index d85f6bd..1ca5452 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -12459,6 +12459,7 @@ async function boot() { EQ.apply(); EqCore.register({ settings: () => data.settings, persist, open: EQ.open, apply: () => { EQ.apply(); EqRender.scheduleAll(); } }); VocalReducer.register({ settings: () => data.settings, persist, apply: () => { EQ.apply(); EqRender.scheduleAll(); } }); + Piano.configure({ settings: () => data.settings, persist, state: () => ({ id: current?.meta?.id || '', time: Player.master?.currentTime || 0, duration: Player.master?.duration || current?.meta?.duration || 0, paused: Player.master?.paused !== false }), loop: (start, end) => { abA = start; abB = end; saveAbMarkers(); updateAbUI(); Player.seek(start); Player.play(); }, clearLoop: clearAb }); MidiPedals.configure({ settings: () => data.settings, persist, action: action => { if (action === 'toggle') { Player.toggle(); updatePlayBtn(); } else if (action === 'next') playNext(); diff --git a/frontend/index.html b/frontend/index.html index cb68ce5..e7187df 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,7 +13,7 @@ YT Player @@ -664,6 +664,8 @@ + + diff --git a/frontend/piano-core.js b/frontend/piano-core.js new file mode 100644 index 0000000..4b4d3f0 --- /dev/null +++ b/frontend/piano-core.js @@ -0,0 +1,59 @@ +/* Compact note sequences and practice ranges, shared by browser and server. */ +(function (root) { + 'use strict'; + const MAX_NOTES = 30000; + function normalizeNotes(input) { + if (!Array.isArray(input) || input.length > MAX_NOTES) + throw new Error('Invalid or oversized note sequence'); + return input + .map((note) => { + const pitch = Number(note.pitch), + start = Number(note.start), + end = Number(note.end), + velocity = Number(note.velocity); + if ( + !Number.isInteger(pitch) || + pitch < 21 || + pitch > 108 || + !Number.isFinite(start) || + start < 0 || + !Number.isFinite(end) || + end <= start || + end > 3600 || + !Number.isFinite(velocity) + ) + throw new Error('Invalid piano note'); + return { + pitch, + start: Math.round(start * 1000) / 1000, + end: Math.max(Math.round(end * 1000) / 1000, Math.round(start * 1000) / 1000 + 0.001), + velocity: Math.max(1, Math.min(127, Math.round(velocity))), + hand: ['left', 'right'].includes(note.hand) ? note.hand : pitch < 60 ? 'left' : 'right', + }; + }) + .sort((a, b) => a.start - b.start || a.pitch - b.pitch); + } + function fromBasicPitch(notes) { + return normalizeNotes( + notes + .filter((n) => n.pitchMidi >= 21 && n.pitchMidi <= 108) + .map((n) => ({ + pitch: n.pitchMidi, + start: n.startTimeSeconds, + end: n.startTimeSeconds + n.durationSeconds, + velocity: n.amplitude * 127, + })), + ); + } + const activeNotes = (notes, time) => notes.filter((n) => n.start <= time && n.end > time); + function practiceRange(time, duration) { + const start = Math.max(0, Number(time) || 0), + end = Math.min(start + 8, Number(duration) || start + 8); + return end > start + ? { start: Math.round(start * 1000) / 1000, end: Math.round(end * 1000) / 1000 } + : null; + } + const api = { normalizeNotes, fromBasicPitch, activeNotes, practiceRange, MAX_NOTES }; + if (typeof module !== 'undefined') module.exports = api; + else root.PianoCore = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/piano-core.test.js b/frontend/piano-core.test.js new file mode 100644 index 0000000..da90309 --- /dev/null +++ b/frontend/piano-core.test.js @@ -0,0 +1,50 @@ +const { test } = require('node:test'), + assert = require('node:assert/strict'); +const P = require('./piano-core'); +test('compact notes sort, round timing and infer hands below middle C', () => { + const notes = P.normalizeNotes([ + { pitch: 60, start: 1.23456, end: 1.8, velocity: 85 }, + { pitch: 48, start: 0, end: 0.5, velocity: 150 }, + { pitch: 55, start: 2, end: 3, velocity: 40, hand: 'right' }, + ]); + assert.equal(notes[0].hand, 'left'); + assert.equal(notes[0].velocity, 127); + assert.equal(notes[1].start, 1.235); + assert.equal(notes[1].hand, 'right'); + assert.equal(notes[2].hand, 'right'); +}); +test('invalid notes are rejected and tiny notes retain positive duration', () => { + assert.throws(() => P.normalizeNotes([{ pitch: 200, start: 0, end: 1, velocity: 90 }])); + assert.throws(() => P.normalizeNotes([{ pitch: 60, start: 2, end: 1, velocity: 90 }])); + const [note] = P.normalizeNotes([{ pitch: 60, start: 0.0001, end: 0.0002, velocity: 0 }]); + assert.ok(note.end > note.start); +}); +test('Basic Pitch output converts amplitude to MIDI velocity and active notes use exclusive ends', () => { + const notes = P.fromBasicPitch([ + { pitchMidi: 60, startTimeSeconds: 1, durationSeconds: 0.5, amplitude: 0.5 }, + ]); + assert.equal(notes[0].velocity, 64); + assert.equal(P.activeNotes(notes, 1).length, 1); + assert.equal(P.activeNotes(notes, 1.5).length, 0); + assert.deepEqual(P.practiceRange(95, 100), { start: 95, end: 100 }); + assert.equal(P.practiceRange(100, 100), null); +}); +test('browser engine prefers WebGPU and reliably falls back to CPU', async () => { + const { selectBackend } = await import('./piano-engine.mjs'); + const calls = []; + const tf = { + setBackend: async (name) => { + calls.push(name); + return true; + }, + }; + assert.equal(await selectBackend(tf, true, async () => {}), 'webgpu'); + assert.deepEqual(calls, ['webgpu']); + const fallback = { setBackend: async (name) => name === 'cpu' }; + assert.equal( + await selectBackend(fallback, true, async () => { + throw new Error('Unavailable GPU'); + }), + 'cpu', + ); +}); diff --git a/frontend/piano-engine.mjs b/frontend/piano-engine.mjs new file mode 100644 index 0000000..fe2c2f6 --- /dev/null +++ b/frontend/piano-engine.mjs @@ -0,0 +1,72 @@ +// This module, TensorFlow and the model are imported only after Transcribe. +let enginePromise, model; +const MODEL = 'https://cdn.jsdelivr.net/npm/@spotify/basic-pitch@1.0.1/model/model.json'; +async function engine() { + if (!enginePromise) + enginePromise = (async () => { + const tf = await import('https://esm.sh/@tensorflow/tfjs@4.22.0'); + await selectBackend( + tf, + !!navigator.gpu, + () => import('https://esm.sh/@tensorflow/tfjs-backend-webgpu@4.22.0'), + ); + await tf.ready(); + const pitch = await import( + 'https://esm.sh/@spotify/basic-pitch@1.0.1?deps=@tensorflow/tfjs@4.22.0' + ); + const graph = await tf.loadGraphModel(MODEL); + model = new pitch.BasicPitch(Promise.resolve(graph)); + return { tf, pitch, backend: tf.getBackend() }; + })().catch((error) => { + enginePromise = null; + throw error; + }); + return enginePromise; +} +export async function selectBackend(tf, hasGPU, loadGPU) { + if (hasGPU) { + try { + await loadGPU(); + if (await tf.setBackend('webgpu')) return 'webgpu'; + } catch {} + } + for (const backend of ['webgl', 'cpu']) { + try { + if (await tf.setBackend(backend)) return backend; + } catch {} + } + throw new Error('No TensorFlow backend is available in this browser.'); +} +export async function transcribe(buffer, progress) { + const decoded = await new OfflineAudioContext(1, 1, 22050).decodeAudioData(buffer); + if (decoded.duration > 900) throw new Error('Browser transcription is limited to 15 minutes.'); + const context = new OfflineAudioContext(1, Math.ceil(decoded.duration * 22050), 22050), + source = context.createBufferSource(); + source.buffer = decoded; + source.connect(context.destination); + source.start(); + const audio = await context.startRendering(); + progress('Loading Basic Pitch library and model…'); + const { tf, pitch, backend } = await engine(); + const frames = [], + onsets = []; + // Basic Pitch 1.0.1 retains intermediate tensors; release each run's scope. + // Model weights were loaded before this scope and stay available for reuse. + tf.engine().startScope(); + try { + await model.evaluateModel( + audio.getChannelData(0), + (f, o) => { + frames.push(...f); + onsets.push(...o); + }, + (p) => progress(`Transcribing on ${backend}: ${Math.round(p * 100)}%`), + ); + } finally { + tf.engine().endScope(); + } + return { + notes: pitch.noteFramesToTime(pitch.outputToNotesPoly(frames, onsets, 0.25, 0.25, 5)), + engine: `basic-pitch-1.0.1/${backend}`, + }; +} diff --git a/frontend/piano.js b/frontend/piano.js new file mode 100644 index 0000000..f3ae236 --- /dev/null +++ b/frontend/piano.js @@ -0,0 +1,347 @@ +/* Opt-in transcription, cached sequences, synced keyboard and practice queue. */ +(function (root) { + 'use strict'; + let adapter, + database, + busy = false; + const memory = new Map(); + async function db() { + if (!database) + database = new Promise((resolve, reject) => { + const req = root.indexedDB.open('ytp-piano', 1); + req.onupgradeneeded = () => req.result.createObjectStore('notes'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return database; + } + async function cache(id, value) { + try { + const store = (await db()) + .transaction('notes', value ? 'readwrite' : 'readonly') + .objectStore('notes'); + if (!value) + return await new Promise((resolve, reject) => { + const req = store.get(id); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const transaction = store.transaction; + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + store.put(value, id); + }); + return value; + } catch { + if (value) memory.set(id, value); + return memory.get(id) || null; + } + } + async function audio(id, file) { + if (file) { + if (file.size > 100 * 1048576) throw new Error('Choose a file smaller than 100 MB.'); + return file.arrayBuffer(); + } + const local = await root.OPFS?.getFileObject(id); + if (local) { + if (local.size > 100 * 1048576) throw new Error('Use a smaller audio file (100 MB maximum).'); + return local.arrayBuffer(); + } + const response = await fetch( + id.startsWith('upl_') + ? `/api/uploads/${encodeURIComponent(id)}` + : `/api/media/${encodeURIComponent(id)}?a=1`, + ); + if (!response.ok) + throw new Error('Save the audio on this device/server, or choose an audio file.'); + const reader = response.body.getReader(), + chunks = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > 100 * 1048576) throw new Error('Audio exceeds 100 MB.'); + chunks.push(value); + } + } finally { + await reader.cancel().catch(() => {}); + } + const out = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out.buffer; + } + function keyboard(canvas, notes, time) { + const ctx = canvas.getContext('2d'), + ratio = root.devicePixelRatio || 1, + width = canvas.clientWidth || 390; + if (canvas.width !== Math.round(width * ratio)) { + canvas.width = Math.round(width * ratio); + canvas.height = 150 * ratio; + } + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, 150); + const active = new Map(root.PianoCore.activeNotes(notes, time).map((n) => [n.pitch, n.hand])), + black = (p) => [1, 3, 6, 8, 10].includes(p % 12), + whites = []; + for (let p = 21; p <= 108; p++) if (!black(p)) whites.push(p); + const unit = width / whites.length; + whites.forEach((p, i) => { + ctx.fillStyle = active.has(p) + ? active.get(p) === 'left' + ? '#62c9e8' + : '#ffb65c' + : '#f4f4f2'; + ctx.fillRect(i * unit, 0, unit - 1, 145); + }); + for (let p = 22; p < 108; p++) + if (black(p)) { + const left = whites.filter((n) => n < p).length * unit; + ctx.fillStyle = active.has(p) + ? active.get(p) === 'left' + ? '#62c9e8' + : '#ffb65c' + : '#141619'; + ctx.fillRect(left - unit * 0.32, 0, unit * 0.64, 90); + } + } + function configure(options) { + adapter = options; + root.SettingsSections.register({ + id: 'piano', + title: 'Piano transcription', + cluster: 'Live & worship', + summary: () => + adapter.settings().pianoEnabled + ? 'On · browser or optional worker' + : 'Off · load model on request', + render(container) { + const doc = container.ownerDocument, + toggle = doc.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = !!adapter.settings().pianoEnabled; + const label = doc.createElement('label'); + label.className = 'set-row'; + label.textContent = 'Enable piano transcription'; + label.append(toggle); + const note = doc.createElement('p'); + note.textContent = + 'Basic Pitch runs on this device; its library/model download only when you transcribe. Prefers WebGPU, then WebGL/CPU. Best for isolated instruments. The optional server worker must be enabled by an administrator.'; + const file = doc.createElement('input'); + file.type = 'file'; + file.accept = 'audio/*,video/*'; + file.setAttribute('aria-label', 'Optional audio file for the playing song'); + const token = doc.createElement('input'); + token.type = 'password'; + token.autocomplete = 'off'; + token.placeholder = 'Admin API token (server requests only; not saved)'; + token.setAttribute('aria-label', 'Admin API token for piano worker'); + const status = doc.createElement('p'); + status.setAttribute('role', 'status'); + const actions = doc.createElement('div'); + actions.className = 'piano-actions'; + const canvas = doc.createElement('canvas'); + canvas.className = 'piano-keyboard'; + canvas.setAttribute('role', 'img'); + canvas.setAttribute( + 'aria-label', + '88-key piano: left-hand notes blue, right-hand notes amber', + ); + const practice = doc.createElement('div'); + practice.className = 'piano-practice'; + container.append(label, note, file, token, actions, status, canvas, practice); + let sequence = [], + currentId = '', + lastTime = -1, + alive = true; + const buttons = []; + const snapshot = () => adapter.state(); + function enabled() { + for (const b of buttons) b.disabled = busy || !toggle.checked; + } + toggle.onchange = () => { + adapter.settings().pianoEnabled = toggle.checked; + adapter.persist(); + enabled(); + }; + function button(text, action) { + const b = doc.createElement('button'); + b.type = 'button'; + b.textContent = text; + b.onclick = action; + actions.append(b); + buttons.push(b); + return b; + } + async function run(action) { + const id = snapshot().id; + if (!id) { + status.textContent = 'Play a song first.'; + return; + } + if (busy) return; + busy = true; + enabled(); + try { + await action(id); + } catch (error) { + if (alive) status.textContent = error.message; + } finally { + busy = false; + enabled(); + } + } + button('Transcribe in browser', () => + run(async (id) => { + const buffer = await audio(id, file.files[0]); + const engine = await import('./piano-engine.mjs'); + const result = await engine.transcribe(buffer, (text) => { + if (alive) status.textContent = text; + }); + const notes = root.PianoCore.fromBasicPitch(result.notes); + await cache(id, { notes, engine: result.engine, at: Date.now() }); + if (snapshot().id === id) { + sequence = notes; + status.textContent = `${notes.length} notes cached on this device · ${result.engine}. Hand split is a middle-C heuristic.`; + } + }), + ); + button('Request server transcription', () => + run(async (id) => { + const response = await fetch(`/api/media/${encodeURIComponent(id)}/piano`, { + method: 'POST', + headers: { Authorization: `Bearer ${token.value}` }, + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Server request failed'); + status.textContent = `Server job ${result.job.status}. Use “Load server result” when ready.`; + }), + ); + button('Load server result', () => + run(async (id) => { + const response = await fetch(`/api/media/${encodeURIComponent(id)}/piano`), + result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Server unavailable'); + if (!result.job?.notes) { + status.textContent = result.enabled + ? `Server job: ${result.job?.status || 'not requested'}` + : 'The optional server worker is off.'; + return; + } + const notes = root.PianoCore.normalizeNotes(result.job.notes); + await cache(id, { notes, engine: 'bytedance-server', at: Date.now() }); + if (snapshot().id === id) { + sequence = notes; + status.textContent = `${notes.length} server notes cached on this device.`; + } + }), + ); + button('Export note JSON', () => { + if (!sequence.length) { + status.textContent = 'No note sequence yet.'; + return; + } + const url = URL.createObjectURL( + new Blob([JSON.stringify(sequence)], { type: 'application/json' }), + ); + const a = doc.createElement('a'); + a.href = url; + a.download = `${snapshot().id}.notes.json`; + doc.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }); + function ranges() { + return adapter.settings().pianoPractice?.[snapshot().id] || []; + } + function saveRanges(value) { + const settings = adapter.settings(); + settings.pianoPractice ||= {}; + settings.pianoPractice[snapshot().id] = value; + adapter.persist(); + renderPractice(); + } + function renderPractice() { + practice.replaceChildren(); + const h = doc.createElement('h4'); + h.textContent = 'Practice queue'; + practice.append(h); + ranges().forEach((range, i) => { + const row = doc.createElement('div'); + row.className = 'piano-actions'; + const play = doc.createElement('button'); + play.type = 'button'; + play.textContent = `Loop ${range.start.toFixed(1)}–${range.end.toFixed(1)} s`; + play.onclick = () => adapter.loop(range.start, range.end); + const remove = doc.createElement('button'); + remove.type = 'button'; + remove.textContent = 'Remove'; + remove.setAttribute('aria-label', `Remove practice range ${i + 1}`); + remove.onclick = () => saveRanges(ranges().filter((_, k) => k !== i)); + row.append(play, remove); + practice.append(row); + }); + } + button('Add current 8 s to practice', () => { + if (!snapshot().id) return; + const range = root.PianoCore.practiceRange(snapshot().time, snapshot().duration); + if (range && ranges().length < 50) saveRanges([...ranges(), range]); + }); + button('Stop practice loop', () => adapter.clearLoop()); + enabled(); + let frame, idleTimer; + async function tick() { + if (!alive) return; + if (!container.isConnected) { + alive = false; + token.value = ''; + return; + } + if (container.hidden) { + idleTimer = setTimeout(() => { + frame = root.requestAnimationFrame(tick); + }, 250); + return; + } + enabled(); + const state = snapshot(); + if (state.id !== currentId) { + currentId = state.id; + sequence = []; + renderPractice(); + const cached = state.id ? await cache(state.id) : null; + if (!alive) return; + if (currentId === state.id) { + sequence = cached?.notes || []; + status.textContent = cached + ? `${sequence.length} cached notes · ${cached.engine}` + : 'Choose a playing song, then transcribe.'; + } + } + if (state.time !== lastTime || state.paused) { + keyboard(canvas, sequence, state.time); + lastTime = state.time; + } + frame = root.requestAnimationFrame(tick); + } + frame = root.requestAnimationFrame(tick); + return () => { + alive = false; + clearTimeout(idleTimer); + root.cancelAnimationFrame(frame); + token.value = ''; + }; + }, + }); + } + const api = { configure }; + if (typeof module !== 'undefined') module.exports = api; + else root.Piano = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/settings-sections.css b/frontend/settings-sections.css index a87cb3f..bbf652b 100644 --- a/frontend/settings-sections.css +++ b/frontend/settings-sections.css @@ -39,3 +39,8 @@ .setlist-import { display: grid; gap: 12px; } .setlist-import input, .setlist-import textarea, .setlist-import select { width: 100%; min-width: 0; min-height: 44px; padding: 10px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; } .setlist-import .download-job > button { min-height: 44px; } + +.piano-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-block: 12px; } +.piano-actions button { min-height: 44px; padding: 8px 12px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; } +.piano-keyboard { width: 100%; height: 150px; display: block; } +#settings-section-piano input[type="file"], #settings-section-piano input[type="password"] { max-width: 100%; min-height: 44px; } diff --git a/frontend/sw.js b/frontend/sw.js index 8b7249b..c06b038 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -91,6 +91,8 @@ const SHELL = [ '/lower-third.js', '/vocal-reducer.js', '/eq-core.js', + '/piano-core.js', + '/piano.js', '/setlist-import.js', '/car-mode.js', '/car-mode.css', diff --git a/playwright.piano.config.js b/playwright.piano.config.js new file mode 100644 index 0000000..233ae28 --- /dev/null +++ b/playwright.piano.config.js @@ -0,0 +1,6 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ + ...require('./playwright.classic.config'), + use: { ...require('./playwright.classic.config').use, ignoreHTTPSErrors: true }, + testMatch: /piano-engine\.spec\.js/, +}); diff --git a/scripts/piano/Dockerfile b/scripts/piano/Dockerfile new file mode 100644 index 0000000..2347a68 --- /dev/null +++ b/scripts/piano/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.10-slim +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 OMP_NUM_THREADS=2 +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libsndfile1 wget && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY worker.py . +VOLUME ["/models"] +CMD ["python", "worker.py"] diff --git a/scripts/piano/requirements.txt b/scripts/piano/requirements.txt new file mode 100644 index 0000000..7b8a679 --- /dev/null +++ b/scripts/piano/requirements.txt @@ -0,0 +1,9 @@ +--extra-index-url https://download.pytorch.org/whl/cpu +torch==2.5.1+cpu +piano-transcription-inference==0.0.6 +numpy==1.23.5 +librosa==0.9.2 +numba==0.57.1 +scipy==1.10.1 +soundfile==0.12.1 +mido==1.3.3 diff --git a/scripts/piano/test_worker.py b/scripts/piano/test_worker.py new file mode 100644 index 0000000..7aee32f --- /dev/null +++ b/scripts/piano/test_worker.py @@ -0,0 +1,18 @@ +import unittest +from worker import compact + + +class CompactNotesTest(unittest.TestCase): + def test_hand_split_and_velocity(self): + notes = compact([{'midi_note': 48, 'onset_time': 1.23456, 'offset_time': 1.78, 'velocity': 85}, {'midi_note': 60, 'onset_time': 2, 'offset_time': 3, 'velocity': 200}]) + self.assertEqual(notes[0], {'pitch': 48, 'start': 1.235, 'end': 1.78, 'velocity': 85, 'hand': 'left'}) + self.assertEqual(notes[1]['hand'], 'right') + self.assertEqual(notes[1]['velocity'], 127) + + def test_empty_or_zero_duration(self): + self.assertEqual(compact([]), []) + self.assertEqual(compact([{'midi_note': 60, 'onset_time': 2, 'offset_time': 2, 'velocity': 85}]), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/piano/worker.py b/scripts/piano/worker.py new file mode 100644 index 0000000..9f63770 --- /dev/null +++ b/scripts/piano/worker.py @@ -0,0 +1,123 @@ +"""Optional CPU piano worker. Polls authenticated jobs; loads model on first job.""" +import json +import os +import re +import tempfile +import threading +import time +import urllib.request +import urllib.error +from pathlib import Path + +BASE = os.environ.get('YTP_BASE', 'http://ytplayer:3000').rstrip('/') +TOKEN = os.environ.get('YTP_TOKEN', '') +CHECKPOINT = Path(os.environ.get('PIANO_CHECKPOINT', '/models/piano.pth')) +MODEL_URL = 'https://zenodo.org/record/4034264/files/CRNN_note_F1%3D0.9677_pedal_F1%3D0.9186.pth?download=1' +ID = re.compile(r'^(?:[\w-]{11}|upl_[a-f0-9]{12})$') + + +def api(path, body): + request = urllib.request.Request(BASE + path, data=json.dumps(body).encode(), headers={'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json'}) + with urllib.request.urlopen(request, timeout=45) as response: + return json.load(response) + + +def compact(events): + return [{'pitch': int(event['midi_note']), 'start': round(float(event['onset_time']), 3), + 'end': round(float(event['offset_time']), 3), 'velocity': max(1, min(127, int(event['velocity']))), + 'hand': 'left' if int(event['midi_note']) < 60 else 'right'} + for event in events if event['offset_time'] > event['onset_time']] + + +def download(url, destination, limit): + with urllib.request.urlopen(url, timeout=60) as response, open(destination, 'wb') as target: + size = 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + if size > limit: + raise ValueError('Download exceeds worker limit') + target.write(chunk) + + +def work(job, model): + video = job['videoId'] + if not ID.fullmatch(video): + raise ValueError('Invalid job id') + path = '/api/uploads/' + video if video.startswith('upl_') else '/api/media/' + video + '?a=1' + if job['audioPath'] != path: + raise ValueError('Invalid audio path') + endpoint = '/api/piano-worker/' + video + stop = threading.Event() + + def heartbeat(): + while not stop.wait(30): + try: + api(endpoint, {'action': 'heartbeat', 'lease': job['lease']}) + except Exception: + # Completion still checks the lease; an expired worker cannot publish. + pass + + thread = threading.Thread(target=heartbeat, daemon=True) + thread.start() + try: + if model is None: + if not CHECKPOINT.exists() or CHECKPOINT.stat().st_size < 160000000: + CHECKPOINT.parent.mkdir(parents=True, exist_ok=True) + temporary = CHECKPOINT.with_suffix('.part') + download(MODEL_URL, temporary, 250000000) + if temporary.stat().st_size < 160000000: + raise ValueError('Incomplete model checkpoint') + temporary.replace(CHECKPOINT) + import torch + from piano_transcription_inference import PianoTranscription + torch.set_num_threads(int(os.environ.get('PIANO_THREADS', '2'))) + model = PianoTranscription(device='cpu', checkpoint_path=str(CHECKPOINT)) + from piano_transcription_inference import load_audio, sample_rate + with tempfile.TemporaryDirectory() as folder: + media = Path(folder) / 'audio.bin' + try: + download(BASE + path, media, 100 * 1048576) + except urllib.error.HTTPError as error: + if error.code != 404 or video.startswith('upl_'): + raise + download(BASE + path.split('?')[0], media, 100 * 1048576) + audio, _ = load_audio(str(media), sr=sample_rate, mono=True) + if len(audio) / sample_rate > 900: + raise ValueError('Worker audio is limited to 15 minutes') + result = model.transcribe(audio, None) + notes = compact(result['est_note_events']) + if len(notes) > 30000: + raise ValueError('Too many notes') + api(endpoint, {'action': 'complete', 'lease': job['lease'], 'notes': notes}) + return model + except Exception as error: + api(endpoint, {'action': 'fail', 'lease': job['lease'], 'error': str(error)[:500]}) + return model + finally: + stop.set() + thread.join(timeout=1) + + +def main(): + if len(TOKEN) < 24: + print('Piano worker disabled: configure a dedicated worker token.', flush=True) + while True: + time.sleep(300) + model = None + while True: + try: + job = api('/api/piano-worker/claim', {}).get('job') + if job: + model = work(job, model) + else: + time.sleep(10) + except Exception as error: + print('Piano worker: ' + str(error), flush=True) + time.sleep(15) + + +if __name__ == '__main__': + main() diff --git a/server/piano.js b/server/piano.js new file mode 100644 index 0000000..efcfe9d --- /dev/null +++ b/server/piano.js @@ -0,0 +1,185 @@ +// Optional leased piano jobs; the heavy model always lives in a separate worker. +import { randomUUID, timingSafeEqual } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { existsSync } from 'node:fs'; +import { db as sql } from './db.js'; +const { normalizeNotes } = createRequire(import.meta.url)( + existsSync(new URL('../frontend/piano-core.js', import.meta.url)) + ? '../frontend/piano-core.js' + : './public/piano-core.js', +); +const ID = /^(?:[\w-]{11}|upl_[a-f0-9]{12})$/; +const LEASE = 120000; +const sourceKey = (row) => + String(row?.sha256 || row?.gen || row?.path || row?.created_at || 'original'); +export function registerPianoRoutes( + app, + { db, adminAuth, enabled = false, workerToken = '', now = Date.now }, +) { + enabled = enabled && workerToken.length >= 24; + const ready = sql.execute(`CREATE TABLE IF NOT EXISTS piano_jobs ( + video_id TEXT PRIMARY KEY,status TEXT NOT NULL,source_key TEXT NOT NULL,notes TEXT,error TEXT, + attempts INTEGER NOT NULL DEFAULT 0,lease_token TEXT,lease_until INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`); + const source = async (id) => + id.startsWith('upl_') + ? db.getUpload(id) + : db.getMedia(id).then((row) => (row?.status === 'ready' ? row : null)); + const get = async (id) => + (await sql.execute({ sql: 'SELECT * FROM piano_jobs WHERE video_id=?', args: [id] })).rows[0]; + const publicJob = (row) => + row + ? { + videoId: row.video_id, + status: row.status, + error: row.error, + notes: row.notes ? JSON.parse(row.notes) : null, + updatedAt: Number(row.updated_at), + } + : null; + async function workerAuth(c, next) { + const token = (c.req.header('authorization') || '').replace(/^Bearer\s+/i, ''); + const a = Buffer.from(token), + b = Buffer.from(workerToken); + if (!enabled || a.length !== b.length || !timingSafeEqual(a, b)) + return c.json({ ok: false, error: 'Unauthorized worker' }, 401); + await ready; + return next(); + } + app.get('/api/media/:id/piano', async (c) => { + await ready; + const id = c.req.param('id'); + if (!ID.test(id)) return c.json({ ok: false, error: 'Invalid video id' }, 400); + const row = await get(id), + media = await source(id); + const stale = row && media && sourceKey(media) !== row.source_key; + return c.json( + { + ok: true, + enabled, + job: stale + ? { + videoId: id, + status: 'stale', + notes: null, + error: 'The source changed. Transcribe it again.', + } + : publicJob(row), + }, + 200, + { 'Cache-Control': 'no-store' }, + ); + }); + app.post('/api/media/:id/piano', adminAuth, async (c) => { + await ready; + if (!enabled) return c.json({ ok: false, error: 'The optional piano worker is off.' }, 503); + const id = c.req.param('id'); + if (!ID.test(id)) return c.json({ ok: false, error: 'Invalid video id' }, 400); + const media = await source(id); + if (!media) return c.json({ ok: false, error: 'Save this song on the server first.' }, 409); + if (Number(media.duration) > 900) + return c.json({ ok: false, error: 'Piano jobs are limited to 15 minutes.' }, 422); + const old = await get(id); + if ( + old && + ['queued', 'running', 'ready'].includes(old.status) && + old.source_key === sourceKey(media) + ) + return c.json({ ok: true, job: publicJob(old) }); + const active = Number( + (await sql.execute("SELECT COUNT(*) n FROM piano_jobs WHERE status IN ('queued','running')")) + .rows[0].n, + ); + if (active >= 10) return c.json({ ok: false, error: 'Piano queue is full.' }, 429); + await sql.execute({ + sql: `INSERT INTO piano_jobs(video_id,status,source_key,created_at,updated_at) SELECT ?,'queued',?,?,? WHERE (SELECT COUNT(*) FROM piano_jobs WHERE status IN ('queued','running'))<10 + ON CONFLICT(video_id) DO UPDATE SET status='queued',source_key=excluded.source_key,notes=NULL,error=NULL,attempts=0,lease_token=NULL,lease_until=0,updated_at=excluded.updated_at`, + args: [id, sourceKey(media), now(), now()], + }); + const queued = await get(id); + if ( + !queued || + !['queued', 'running'].includes(queued.status) || + queued.source_key !== sourceKey(media) + ) + return c.json({ ok: false, error: 'Piano queue is full.' }, 429); + return c.json({ ok: true, job: publicJob(queued) }, 202); + }); + app.post('/api/piano-worker/claim', workerAuth, async (c) => { + const time = now(); + await sql.execute({ + sql: "UPDATE piano_jobs SET status='failed',error='Worker stopped repeatedly',updated_at=? WHERE status='running' AND lease_until=3", + args: [time, time], + }); + const row = ( + await sql.execute({ + sql: `UPDATE piano_jobs SET status='running',attempts=attempts+1,lease_token=?,lease_until=?,updated_at=? + WHERE video_id=(SELECT video_id FROM piano_jobs WHERE (status='queued' OR (status='running' AND lease_until { + const id = c.req.param('id'); + if (!ID.test(id)) return c.json({ ok: false, error: 'Invalid video id' }, 400); + const text = await c.req.text(); + if (text.length > 4 * 1048576) return c.json({ ok: false, error: 'Result too large' }, 413); + let body; + try { + body = JSON.parse(text); + } catch { + return c.json({ ok: false, error: 'Invalid JSON' }, 400); + } + const row = await get(id); + if ( + !row || + row.status !== 'running' || + row.lease_token !== body.lease || + Number(row.lease_until) < now() + ) + return c.json({ ok: false, error: 'Job lease expired' }, 409); + if (body.action === 'heartbeat') { + await sql.execute({ + sql: 'UPDATE piano_jobs SET lease_until=?,updated_at=? WHERE video_id=? AND lease_token=?', + args: [now() + LEASE, now(), id, body.lease], + }); + return c.json({ ok: true }); + } + let notes = null, + status = 'failed'; + if (body.action === 'complete') { + try { + notes = normalizeNotes(body.notes); + } catch (error) { + return c.json({ ok: false, error: error.message }, 422); + } + status = 'ready'; + } else if (body.action !== 'fail') + return c.json({ ok: false, error: 'Unknown worker action' }, 400); + await sql.execute({ + sql: 'UPDATE piano_jobs SET status=?,notes=?,error=?,updated_at=?,lease_token=NULL,lease_until=0 WHERE video_id=? AND lease_token=?', + args: [ + status, + notes ? JSON.stringify(notes) : null, + status === 'failed' ? String(body.error || 'Transcription failed').slice(0, 500) : null, + now(), + id, + body.lease, + ], + }); + return c.json({ ok: true }); + }); + return { ready }; +} diff --git a/server/piano.test.js b/server/piano.test.js new file mode 100644 index 0000000..eb3ccb0 --- /dev/null +++ b/server/piano.test.js @@ -0,0 +1,103 @@ +import { test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Hono } from 'hono'; +const root = mkdtempSync(join(tmpdir(), 'ytp-piano-')); +process.env.DB_PATH = join(root, 'test.db'); +const db = await import('./db.js'); +const { registerPianoRoutes } = await import('./piano.js'); +const ID = '0gfX0dFLaBc', + TOKEN = 'piano-worker-test-'.repeat(3); +let app, + time = 1000; +const post = (path, body = {}, token = TOKEN) => + app.request(path, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +beforeAll(async () => { + await db.initDb(); + await db.upsertMedia(ID, { status: 'ready', duration: 60, gen: 1 }); + app = new Hono(); + await registerPianoRoutes(app, { + db, + enabled: true, + workerToken: TOKEN, + now: () => time, + adminAuth: async (c, next) => + c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401), + }).ready; +}); +afterAll(async () => { + db.db.close(); + rmSync(root, { recursive: true, force: true }); +}); +test('server piano requires admin requests and dedicated worker authentication', async () => { + expect((await post(`/api/media/${ID}/piano`)).status).toBe(401); + expect((await post('/api/piano-worker/claim', {}, 'wrong')).status).toBe(401); + const result = await app.request(`/api/media/${ID}/piano`, { + method: 'POST', + headers: { 'x-admin': 'yes' }, + }); + expect(result.status).toBe(202); + expect((await result.json()).job.status).toBe('queued'); +}); +test('claim is exclusive; validated results persist as compact notes with inferred hands', async () => { + const first = await (await post('/api/piano-worker/claim')).json(); + expect(first.job.audioPath).toBe(`/api/media/${ID}?a=1`); + expect((await (await post('/api/piano-worker/claim')).json()).job).toBeNull(); + expect( + (await post(`/api/piano-worker/${ID}`, { action: 'complete', lease: 'wrong', notes: [] })) + .status, + ).toBe(409); + expect( + ( + await post(`/api/piano-worker/${ID}`, { + action: 'complete', + lease: first.job.lease, + notes: [{ pitch: 60, start: 2, end: 1, velocity: 80 }], + }) + ).status, + ).toBe(422); + expect( + ( + await post(`/api/piano-worker/${ID}`, { + action: 'complete', + lease: first.job.lease, + notes: [{ pitch: 48, start: 1.24, end: 1.78, velocity: 85 }], + }) + ).status, + ).toBe(200); + const result = await (await app.request(`/api/media/${ID}/piano`)).json(); + expect(result.job.notes[0]).toEqual({ + pitch: 48, + start: 1.24, + end: 1.78, + velocity: 85, + hand: 'left', + }); +}); +test('changed source invalidates notes, lost leases are re-claimed and stale workers cannot publish', async () => { + await db.upsertMedia(ID, { gen: 2 }); + expect((await (await app.request(`/api/media/${ID}/piano`)).json()).job.status).toBe('stale'); + await app.request(`/api/media/${ID}/piano`, { method: 'POST', headers: { 'x-admin': 'yes' } }); + const old = (await (await post('/api/piano-worker/claim')).json()).job; + time += 120001; + const next = (await (await post('/api/piano-worker/claim')).json()).job; + expect(next.lease).not.toBe(old.lease); + expect( + (await post(`/api/piano-worker/${ID}`, { action: 'complete', lease: old.lease, notes: [] })) + .status, + ).toBe(409); + expect( + (await post(`/api/piano-worker/${ID}`, { action: 'heartbeat', lease: next.lease })).status, + ).toBe(200); +}); +test('optional server pipeline defaults off and rejects malformed ids', async () => { + const disabled = new Hono(); + await registerPianoRoutes(disabled, { db, adminAuth: async (c, next) => next() }).ready; + expect((await disabled.request(`/api/media/${ID}/piano`, { method: 'POST' })).status).toBe(503); + expect((await disabled.request('/api/media/bad/piano')).status).toBe(400); +}); diff --git a/server/server.js b/server/server.js index b6d3b9f..9684aa6 100644 --- a/server/server.js +++ b/server/server.js @@ -47,6 +47,7 @@ import * as notesDb from './db.js'; import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js'; import { createRemoteHub } from './remote.js'; import { createPartyHub } from './party.js'; +import { registerPianoRoutes } from './piano.js'; import { registerUploadRoutes } from './uploads.js'; import { admitFile } from './p2p-admit.js'; import { P2P } from './p2p-config.js'; @@ -2338,6 +2339,8 @@ const notes = registerNoteRoutes(app, { // ============================================================================ // Uploads — the server's own searchable media library (see uploads.js) // ============================================================================ +registerPianoRoutes(app, { db: notesDb, adminAuth: notes.adminAuth, enabled: process.env.PIANO_WORKER_ENABLED === '1', workerToken: process.env.PIANO_WORKER_TOKEN || '' }); + const UPLOAD_DIR = process.env.UPLOAD_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'uploads'); const uploads = registerUploadRoutes(app, { db: notesDb, diff --git a/tests/piano-engine.spec.js b/tests/piano-engine.spec.js new file mode 100644 index 0000000..7570d3c --- /dev/null +++ b/tests/piano-engine.spec.js @@ -0,0 +1,56 @@ +const { test, expect } = require('@playwright/test'); +const { openClassic } = require('./helpers/classic-fixture'); +test('Basic Pitch is lazy and transcribes a short synthetic WAV', async ({ page }) => { + test.setTimeout(120000); + page.on('requestfailed', (req) => + console.log('Engine request failed:', req.url(), req.failure()?.errorText), + ); + const requests = []; + page.on('request', (req) => { + if (/esm\.sh|jsdelivr.*basic-pitch/.test(req.url())) requests.push(req.url()); + }); + await openClassic(page); + expect(requests).toEqual([]); + const result = await page.evaluate(async () => { + const rate = 22050, + samples = rate * 2, + buffer = new ArrayBuffer(44 + samples * 2), + view = new DataView(buffer); + const text = (offset, value) => { + [...value].forEach((c, i) => view.setUint8(offset + i, c.charCodeAt(0))); + }; + text(0, 'RIFF'); + view.setUint32(4, 36 + samples * 2, true); + text(8, 'WAVE'); + text(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, rate, true); + view.setUint32(28, rate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + text(36, 'data'); + view.setUint32(40, samples * 2, true); + for (let i = 0; i < samples; i++) + view.setInt16(44 + i * 2, Math.sin((2 * Math.PI * 440 * i) / rate) * 16000, true); + const engine = await import('/piano-engine.mjs'); + const result = await engine.transcribe(buffer.slice(0), () => {}); + const tf = await import('https://esm.sh/@tensorflow/tfjs@4.22.0'); + const before = tf.memory().numTensors; + const second = await engine.transcribe(buffer.slice(0), () => {}); + return { + engine: result.engine, + notes: PianoCore.fromBasicPitch(result.notes), + secondNotes: second.notes.length, + before, + after: tf.memory().numTensors, + }; + }); + expect(result.engine).toMatch(/basic-pitch-1.0.1\/(webgpu|webgl|cpu)/); + expect(requests.length).toBeGreaterThan(0); + expect(result.notes.length).toBeGreaterThan(0); + expect(result.notes.some((n) => n.pitch === 69)).toBe(true); + expect(result.secondNotes).toBeGreaterThan(0); + expect(result.after).toBe(result.before); +}); diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js index ccff3e4..2c4c18f 100644 --- a/tests/settings-sections.spec.js +++ b/tests/settings-sections.spec.js @@ -73,3 +73,11 @@ for (const layout of ['classic', 'glass-stage']) test(`${layout} secondary lyric await expect(page.locator('#serviceLyricsList .lyric-phonetic')).toHaveText('zhǔ nǐ de ēndiǎn'); expect(await page.locator('#serviceLyricsList .lyric-secondary').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) < parseFloat(getComputedStyle(el.parentElement).fontSize))).toBe(true); }); +for (const layout of ['classic', 'glass-stage']) for (const width of [390,1440]) test(`${layout} piano settings stay lazy at ${width}px`, async ({ page }) => { + const models=[];page.on('request',req=>{if(/esm\.sh|jsdelivr.*basic-pitch/.test(req.url()))models.push(req.url());}); + await page.setViewportSize({width,height:900});await openClassic(page);await page.evaluate(layout=>{data.settings.layout=layout;applyAppearance();current={meta:{id:'0gfX0dFLaBc',title:'Piano fixture',duration:200}};view={type:'settings'};render();},layout); + await page.locator('[data-section="piano"]').click();await expect(page.getByRole('button',{name:'Transcribe in browser',exact:true})).toBeDisabled(); + await page.locator('#settings-section-piano input[type="checkbox"]').check();await expect(page.getByRole('button',{name:'Transcribe in browser',exact:true})).toBeEnabled(); + await page.getByRole('button',{name:'Add current 8 s to practice',exact:true}).click();await expect(page.locator('.piano-practice button').first()).toHaveText('Loop 0.0–8.0 s'); + expect(models).toEqual([]);expect(await page.locator('#settings-section-piano').evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true); +});