Skip to content
← Public packages

@kentcdodds/pocket-piano

A pocket piano for choir practice: your singing part's range on your phone, installable and offline.

FollowForkOpen app

src/client-script.ts

616 lines · 19.2 KB · TypeScript
Raw
// 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()

})()
`