// Browser script for the Pocket Piano page. Inlined into the HTML by app.ts. // Kept as plain JS in a raw string: no backticks or template placeholders inside. export const CLIENT_JS = String.raw` (function () { 'use strict' var NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] var DISPLAY_NAMES = ['C', 'C\u266F', 'D', 'D\u266F', 'E', 'F', 'F\u266F', 'G', 'G\u266F', 'A', 'A\u266F', 'B'] var WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] var LOWEST = 21 // A0 var HIGHEST = 108 // C8 var MIDDLE_C = 60 var VISIBLE_WHITES = 15 // C3 to C5 var MIN_KEY_PX = 40 var MAX_VOICES = 16 var DRAG_MIN_EACH = 6 // px each finger must travel along the keyboard var DRAG_MIN_MEAN = 12 // px average travel before a two-finger drag wins var STORE = 'pocket-piano:' // Settings from the first version that no longer exist. try { localStorage.removeItem(STORE + 'volume') localStorage.removeItem(STORE + 'octave') } catch (e) {} function load(key, fallback) { try { var raw = localStorage.getItem(STORE + key) return raw === null ? fallback : JSON.parse(raw) } catch (e) { return fallback } } function save(key, value) { try { localStorage.setItem(STORE + key, JSON.stringify(value)) } catch (e) {} } function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)) } function isWhite(midi) { return WHITE_PCS.indexOf(midi % 12) >= 0 } function noteName(midi) { return NOTE_NAMES[midi % 12] + (Math.floor(midi / 12) - 1) } function displayName(midi) { return DISPLAY_NAMES[midi % 12] + (Math.floor(midi / 12) - 1) } function freq(midi) { return 440 * Math.pow(2, (midi - 69) / 12) } var whites = [] for (var m0 = LOWEST; m0 <= HIGHEST; m0++) if (isWhite(m0)) whites.push(m0) // Singing part (set on the About page): highlighted range and where the keyboard opens. var parts = window.pocketPianoParts var part = parts.read() function homePos() { return parts.homePos(whites, part, view.span) } var state = { sustain: load('sustain', false) === true, lastMidi: null, } var stats = { noteOns: 0, cancels: 0, scrolls: 0 } // ---------- Audio ---------- // No app volume: full output into a limiter, so the phone's media volume is the only control. var ctx = null var master = null var wave = null var voices = new Map() // midi -> voice var holds = new Map() // midi -> number of fingers holding function ensureAudio() { if (!ctx) { var AC = window.AudioContext || window.webkitAudioContext if (!AC) return null ctx = new AC({ latencyHint: 'interactive' }) try { // Safari only today; harmless elsewhere. Chrome on Android already uses the media stream. if (navigator.audioSession) navigator.audioSession.type = 'playback' } catch (e) {} var limiter = ctx.createDynamicsCompressor() limiter.threshold.value = -6 limiter.knee.value = 0 limiter.ratio.value = 20 limiter.attack.value = 0.002 limiter.release.value = 0.12 master = ctx.createGain() master.gain.value = 1 master.connect(limiter) limiter.connect(ctx.destination) var amps = [0, 1, 0.55, 0.32, 0.2, 0.14, 0.08, 0.06, 0.035, 0.02, 0.012] wave = ctx.createPeriodicWave(new Float32Array(amps.length), new Float32Array(amps)) } if (ctx.state !== 'running') ctx.resume().catch(function () {}) return ctx } function holdAt(param, time) { if (param.cancelAndHoldAtTime) { param.cancelAndHoldAtTime(time) } else { var v = param.value param.cancelScheduledValues(time) param.setValueAtTime(v, time) } } function startVoice(midi) { if (!ensureAudio()) return var existing = voices.get(midi) if (existing) stopVoice(existing, 0.015) if (voices.size >= MAX_VOICES) { var oldest = voices.values().next().value if (oldest) stopVoice(oldest, 0.03) } var t = ctx.currentTime var f = freq(midi) var peak = 0.42 * clamp(1.15 - (midi - 48) * 0.008, 0.6, 1.3) var env = ctx.createGain() env.gain.setValueAtTime(0, t) env.gain.linearRampToValueAtTime(peak, t + 0.004) env.gain.setTargetAtTime(peak * 0.28, t + 0.004, 0.35) var filter = ctx.createBiquadFilter() filter.type = 'lowpass' filter.Q.value = 0.6 filter.frequency.setValueAtTime(Math.min(f * 12, 16000), t) filter.frequency.setTargetAtTime(Math.min(f * 3.5, 9000), t, 0.45) var osc1 = ctx.createOscillator() osc1.setPeriodicWave(wave) osc1.frequency.value = f var osc2 = ctx.createOscillator() osc2.setPeriodicWave(wave) osc2.frequency.value = f osc2.detune.value = 3.5 var g2 = ctx.createGain() g2.gain.value = 0.45 osc1.connect(filter) osc2.connect(g2) g2.connect(filter) filter.connect(env) env.connect(master) osc1.start(t) osc2.start(t) var voice = { midi: midi, env: env, oscs: [osc1, osc2], nodes: [filter, g2, env], stopped: false } voices.set(midi, voice) osc1.onended = function () { voice.nodes.forEach(function (n) { try { n.disconnect() } catch (e) {} }) if (voices.get(midi) === voice) voices.delete(midi) } } function stopVoice(voice, timeConstant) { if (!voice || voice.stopped || !ctx) return voice.stopped = true var t = ctx.currentTime holdAt(voice.env.gain, t) voice.env.gain.setTargetAtTime(0, t, timeConstant) voice.oscs.forEach(function (o) { try { o.stop(t + timeConstant * 10 + 0.05) } catch (e) {} }) if (voices.get(voice.midi) === voice) voices.delete(voice.midi) } function ringOut(voice) { if (!voice || voice.stopped || !ctx) return var t = ctx.currentTime holdAt(voice.env.gain, t) voice.env.gain.setTargetAtTime(0, t, 1.3) voice.oscs.forEach(function (o) { try { o.stop(t + 9) } catch (e) {} }) } function noteOn(midi) { stats.noteOns++ holds.set(midi, (holds.get(midi) || 0) + 1) startVoice(midi) setLast(midi) paintKey(midi, true) } function noteOff(midi) { var n = (holds.get(midi) || 0) - 1 if (n > 0) { holds.set(midi, n) return } holds.delete(midi) paintKey(midi, false) var voice = voices.get(midi) if (state.sustain) ringOut(voice) else stopVoice(voice, 0.07) } // A finger turned out to be part of a two-finger drag: silence it right away, sustain or not. function cancelNote(midi) { stats.cancels++ var n = (holds.get(midi) || 0) - 1 if (n > 0) { holds.set(midi, n) return } holds.delete(midi) paintKey(midi, false) stopVoice(voices.get(midi), 0.006) } function releaseAll() { pointers.clear() mode = 'idle' holds.clear() Array.from(voices.values()).forEach(function (v) { stopVoice(v, 0.05) }) document.querySelectorAll('.key.down').forEach(function (el) { el.classList.remove('down') }) } function releaseRinging() { Array.from(voices.values()).forEach(function (v) { if (!holds.get(v.midi)) stopVoice(v, 0.12) }) } // ---------- Keyboard rows ---------- // Landscape: one left-to-right row (15 white keys when they fit). // Portrait: two stacked left-to-right rows, like the first version. The bottom row is the // lower octave and the top row continues from its last key (C3 to C4 below, C4 to C5 above), // so the pair is one continuous window that two-finger drags slide as a unit. var keyboard = document.getElementById('keyboard') var rangeLabel = document.getElementById('range') var readout = document.getElementById('readout') var readoutNote = document.getElementById('readout-note') var readoutHz = document.getElementById('readout-hz') var sustainBtn = document.getElementById('sustain') var installBtn = document.getElementById('install') var portraitQuery = window.matchMedia('(orientation: portrait)') // pos is the white-key index at the left edge of the lowest row. var view = { axis: 'x', rows: 0, k: 60, visible: VISIBLE_WHITES, rowStep: 0, span: VISIBLE_WHITES, pos: 0, maxPos: whites.length - VISIBLE_WHITES, placed: false } var keyEls = new Map() // midi -> [elements], one per row var rowEls = [] // { el, strip, offset } with offset in white keys from pos function fillStrip(strip) { for (var m = LOWEST; m <= HIGHEST; m++) { var white = isWhite(m) var el = document.createElement('div') el.className = 'key ' + (white ? 'white' : 'black') el.dataset.midi = String(m) if (white) el.style.setProperty('--i', String(whites.indexOf(m))) else el.style.setProperty('--c', String(whites.indexOf(m - 1) + 1)) if (m >= part.low && m <= part.high) el.classList.add('in-part') if (m === MIDDLE_C) el.classList.add('middle-c') if (holds.get(m)) el.classList.add('down') var label = document.createElement('span') label.className = 'label' label.textContent = white ? noteName(m) : DISPLAY_NAMES[m % 12] el.appendChild(label) if (m === MIDDLE_C) { var tag = document.createElement('span') tag.className = 'tag' tag.textContent = 'middle C' el.appendChild(tag) } el.setAttribute('aria-label', displayName(m)) strip.appendChild(el) if (!keyEls.has(m)) keyEls.set(m, []) keyEls.get(m).push(el) } } function buildRows(count) { keyboard.textContent = '' keyEls.clear() rowEls = [] for (var r = 0; r < count; r++) { var row = document.createElement('div') row.className = 'row' var strip = document.createElement('div') strip.className = 'strip' fillStrip(strip) row.appendChild(strip) rowEls.push({ el: row, strip: strip, offset: 0 }) } // Highest row first in the DOM so it sits on top. for (var i = rowEls.length - 1; i >= 0; i--) keyboard.appendChild(rowEls[i].el) view.rows = count } function layout() { var rows = portraitQuery.matches ? 2 : 1 if (rows !== view.rows) buildRows(rows) var width = keyboard.clientWidth if (!width) return if (rows === 2) { view.visible = 8 // C to C per row, like the first version view.rowStep = 7 // the top row starts on the bottom row's last key } else { view.visible = VISIBLE_WHITES if (width / view.visible < MIN_KEY_PX) view.visible = Math.max(5, Math.floor(width / MIN_KEY_PX)) view.rowStep = 0 } view.k = width / view.visible view.span = view.rowStep * (rows - 1) + view.visible view.maxPos = whites.length - view.span if (!view.placed) { view.pos = homePos() view.placed = true } rowEls.forEach(function (row, r) { row.offset = r * view.rowStep row.strip.style.setProperty('--k', view.k + 'px') row.strip.style.setProperty('--n', String(whites.length)) }) applyPos() } function applyPos() { view.pos = clamp(view.pos, 0, view.maxPos) rowEls.forEach(function (row) { row.strip.style.transform = 'translate3d(' + -(view.pos + row.offset) * view.k + 'px,0,0)' }) var lo = whites[clamp(Math.round(view.pos), 0, whites.length - 1)] var hi = whites[clamp(Math.round(view.pos + view.span) - 1, 0, whites.length - 1)] rangeLabel.textContent = noteName(lo) + ' to ' + noteName(hi) rangeLabel.classList.toggle('home', Math.abs(view.pos - homePos()) < 0.05) } // ---------- Momentum and reset animation ---------- var anim = null var samples = [] // recent { t, pos } while dragging, for release velocity function stopAnim() { if (anim) cancelAnimationFrame(anim) anim = null } var FRICTION = 0.994 // momentum keeps this fraction of its speed per ms var COAST_MS = -1 / Math.log(FRICTION) // total coast distance = velocity * COAST_MS // When a drag ends: project where momentum would coast to, snap that point to the nearest // whole white key, and glide there in one ease-out curve. No overshoot and come back. function startMomentum() { stopAnim() var now0 = performance.now() samples = samples.filter(function (s) { return now0 - s.t <= 100 }) var velocity = 0 // white keys per ms if (samples.length >= 2) { var first = samples[0] var lastS = samples[samples.length - 1] if (now0 - lastS.t < 60 && lastS.t - first.t >= 16) velocity = (lastS.pos - first.pos) / (lastS.t - first.t) } samples = [] velocity = clamp(velocity, -0.12, 0.12) if (Math.abs(velocity) < 0.002) velocity = 0 var target = clamp(Math.round(view.pos + velocity * COAST_MS), 0, view.maxPos) var distance = Math.abs(target - view.pos) // An ease-out cubic starts at 3x its average speed: match the finger's release speed, // but never take less than 200 ms (a plain snap) or more than 650 ms. var duration = velocity && distance > 0.5 ? (3 * distance) / Math.abs(velocity) : 200 animateTo(target, clamp(duration, 200, 650)) } function animateTo(target, duration) { stopAnim() var from = view.pos var ms = duration || 250 var start = performance.now() if (Math.abs(target - from) < 0.0005) { view.pos = target applyPos() return } function step(now) { var t = Math.min(1, (now - start) / ms) var eased = 1 - Math.pow(1 - t, 3) view.pos = t < 1 ? from + (target - from) * eased : target applyPos() anim = t < 1 ? requestAnimationFrame(step) : null } anim = requestAnimationFrame(step) } // ---------- UI helpers ---------- function setLast(midi) { state.lastMidi = midi readoutNote.textContent = displayName(midi) readoutHz.textContent = freq(midi).toFixed(1) + ' Hz' readout.classList.add('has-note') } function paintKey(midi, down) { var els = keyEls.get(midi) if (els) els.forEach(function (el) { el.classList.toggle('down', down) }) } function setSustain(on) { state.sustain = on sustainBtn.setAttribute('aria-pressed', on ? 'true' : 'false') save('sustain', on) if (!on) releaseRinging() } // ---------- Gestures ---------- // One finger plays and glides. Several fingers play a chord. Two or more fingers moving // together along the keyboard become a scroll: their notes are cut and nothing else plays // until every finger lifts. var pointers = new Map() // pointerId -> { x, y, sx, sy, midi } var mode = 'idle' // idle | play | scroll function keyAt(x, y) { var el = document.elementFromPoint(x, y) var key = el && el.closest ? el.closest('.key') : null if (!key || !keyboard.contains(key)) return null return Number(key.dataset.midi) } function along(p) { return view.axis === 'x' ? p.x - p.sx : p.y - p.sy } function enterScroll() { stopAnim() mode = 'scroll' stats.scrolls++ var total = 0 pointers.forEach(function (p) { if (p.midi !== null) cancelNote(p.midi) p.midi = null total += along(p) }) scrollBy(total / pointers.size) } function scrollBy(deltaPx) { // Content follows the fingers. var dPos = view.axis === 'x' ? -deltaPx / view.k : deltaPx / view.k view.pos += dPos applyPos() return dPos } keyboard.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse' && e.button !== 0) return e.preventDefault() ensureAudio() try { keyboard.setPointerCapture(e.pointerId) } catch (err) {} var p = { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, midi: null } pointers.set(e.pointerId, p) if (mode === 'scroll') return mode = 'play' if (pointers.size >= 2) { // Measure drag intent from the moment the extra finger lands. pointers.forEach(function (q) { q.sx = q.x; q.sy = q.y }) } p.midi = keyAt(e.clientX, e.clientY) if (p.midi !== null) noteOn(p.midi) }) keyboard.addEventListener('pointermove', function (e) { var p = pointers.get(e.pointerId) if (!p) return var dx = e.clientX - p.x var dy = e.clientY - p.y p.x = e.clientX p.y = e.clientY if (mode === 'scroll') { scrollBy((view.axis === 'x' ? dx : dy) / pointers.size) samples.push({ t: performance.now(), pos: view.pos }) if (samples.length > 30) samples.shift() return } if (pointers.size >= 2) { var sign = 0 var together = true var sum = 0 pointers.forEach(function (q) { var a = along(q) var s = a > 0 ? 1 : -1 if (Math.abs(a) < DRAG_MIN_EACH) together = false else if (sign === 0) sign = s else if (s !== sign) together = false sum += Math.abs(a) }) if (together && sum / pointers.size >= DRAG_MIN_MEAN) { samples = [] enterScroll() samples.push({ t: performance.now(), pos: view.pos }) } // No gliding while a chord is held. return } var midi = keyAt(e.clientX, e.clientY) if (midi === p.midi) return var prev = p.midi p.midi = midi if (prev !== null) noteOff(prev) if (midi !== null) noteOn(midi) }) function endPointer(e) { var p = pointers.get(e.pointerId) if (!p) return pointers.delete(e.pointerId) if (p.midi !== null) noteOff(p.midi) if (pointers.size === 0) { if (mode === 'scroll') startMomentum() mode = 'idle' } } keyboard.addEventListener('pointerup', endPointer) keyboard.addEventListener('pointercancel', endPointer) keyboard.addEventListener('lostpointercapture', endPointer) keyboard.addEventListener('contextmenu', function (e) { e.preventDefault() }) // Block pinch / double-tap zoom and rubber-band scrolling anywhere on the page. document.addEventListener('touchmove', function (e) { e.preventDefault() }, { passive: false }) document.addEventListener('dblclick', function (e) { e.preventDefault() }) document.addEventListener('gesturestart', function (e) { e.preventDefault() }) document.addEventListener('pointerdown', function () { ensureAudio() }, { capture: true }) rangeLabel.addEventListener('click', function () { animateTo(homePos()) }) // Coming back from the About page (back button or bfcache): pick up a new part. function syncPart() { var next = parts.read() if (next.id === part.id) return part = next keyEls.forEach(function (els, m) { els.forEach(function (el) { el.classList.toggle('in-part', m >= part.low && m <= part.high) }) }) stopAnim() view.pos = homePos() applyPos() } window.addEventListener('pageshow', syncPart) window.addEventListener('storage', function (e) { if (e.key === parts.key) syncPart() }) sustainBtn.addEventListener('click', function () { setSustain(!state.sustain) }) readout.addEventListener('click', function () { if (state.lastMidi === null) return var midi = state.lastMidi ensureAudio() noteOn(midi) setTimeout(function () { noteOff(midi) }, 900) }) // ---------- Install ---------- var installPrompt = null window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault() installPrompt = e installBtn.hidden = false }) installBtn.addEventListener('click', function () { if (!installPrompt) return installPrompt.prompt() installPrompt.userChoice.finally(function () { installPrompt = null installBtn.hidden = true }) }) window.addEventListener('appinstalled', function () { installPrompt = null installBtn.hidden = true }) if (portraitQuery.addEventListener) { portraitQuery.addEventListener('change', function () { releaseAll(); layout() }) } if (window.ResizeObserver) new ResizeObserver(function () { layout() }).observe(keyboard) else window.addEventListener('resize', layout) document.addEventListener('visibilitychange', function () { if (document.hidden) releaseAll() }) window.pocketPiano = { get state() { return { rows: view.rows, pos: Math.round(view.pos * 1000) / 1000, visible: view.visible, keyPx: Math.round(view.k * 10) / 10, range: rangeLabel.textContent, part: part.id, home: homePos(), mode: mode, animating: anim !== null, sustain: state.sustain, lastNote: state.lastMidi === null ? null : noteName(state.lastMidi), audio: ctx ? ctx.state : 'none', masterGain: master ? master.gain.value : null, voices: voices.size, held: Array.from(holds.keys()), noteOns: stats.noteOns, cancels: stats.cancels, scrolls: stats.scrolls, installPromptAvailable: installPrompt !== null, } }, } setSustain(state.sustain) layout() })() `