← Public packages
@kentcdodds/pocket-piano
A pocket piano for choir practice: your singing part's range on your phone, installable and offline.
src/client-script.ts
616 lines · 19.2 KB · TypeScript// 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()
})()
`