/* 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(root.Lazy?.url('/piano-engine.mjs') || './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);