poke/html/piano.ejs
2025-10-11 12:27:02 +02:00

803 lines
31 KiB
Plaintext

<!--
This Source Code Form is subject to the terms of the GNU General Public License:
Copyright (C) 2021-2025 Poke (https://codeberg.org/ashley/poke)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see https://www.gnu.org/licenses/.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Poke Piano</title>
<meta content="website" property="og:type">
<meta content="PokePiano" property="og:title">
<meta content="PIANO! on web!" property="twitter:description">
<meta content="https://cdn.glitch.me/d68d17bb-f2c0-4bc3-993f-50902734f652/aa70111e-5bcd-4379-8b23-332a33012b78.image.png?v=1701898829884" property="og:image" />
<meta content="summary_large_image" name="twitter:card">
<link href="/css/yt-ukraine.svg?v=7" rel="icon">
<style>
:root {
--bg: #0d0f12;
--panel: #15171d;
--panel-2: #191b22;
--fg: #e7e9ed;
--muted: #9aa4b2;
--border: #2a2e36;
--border-soft: #22252c;
--accent: #6f8cff; /* single accent, no gradients */
--accent-strong: #4772ff;
--danger: #ff5577;
--radius: 14px;
--radius-sm: 10px;
--shadow-1: 0 1px 0 rgba(255,255,255,0.04) inset, 0 8px 24px rgba(0,0,0,0.35);
--shadow-2: 0 1px 0 rgba(255,255,255,0.04) inset, 0 4px 16px rgba(0,0,0,0.28);
}
.light {
--bg: #f6f7fb;
--panel: #ffffff;
--panel-2: #f2f3f8;
--fg: #121419;
--muted: #4f5b6a;
--border: #d8dee8;
--border-soft: #e8ecf3;
--accent: #3b63ff;
--accent-strong: #224bff;
--danger: #e23d5f;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
color: var(--fg);
background: var(--bg);
font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Noto Sans", Arial, "Apple Color Emoji", "Segoe UI Emoji";
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
/* Top bar */
header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--panel-2);
position: sticky;
top: 0;
z-index: 5;
}
.brand {
display: flex; align-items: center; gap: 10px;
min-width: 0;
}
.logo {
width: 22px; height: 22px; border-radius: 6px;
background: var(--accent);
border: 1px solid var(--border);
box-shadow: var(--shadow-2);
}
h1 {
margin: 0; font-size: 16px; letter-spacing: 0.2px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header-actions { display: flex; gap: 8px; }
/* App layout: side controls + main stage */
.app {
display: grid;
grid-template-columns: 340px 1fr;
gap: 16px;
padding: 16px;
}
@media (max-width: 980px) {
.app { grid-template-columns: 1fr; }
}
.side, .main { display: grid; gap: 16px; align-content: start; }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-1);
padding: 12px;
}
.panel .title {
font-size: 11px; text-transform: uppercase; color: var(--muted);
letter-spacing: .3px; margin-bottom: 10px;
}
/* Control grid */
.group { display: grid; gap: 10px; }
.row {
display: grid; gap: 10px;
grid-template-columns: repeat(2, 1fr);
}
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-1 { grid-template-columns: 1fr; }
@media (max-width: 520px) {
.row, .row-3 { grid-template-columns: 1fr; }
}
.pair {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px;
align-items: center;
min-width: 0;
}
.pair > label { color: var(--muted); font-size: 12px; white-space: nowrap; }
/* Inputs / buttons */
select, input[type="range"], input[type="number"], input[type="text"], button, .toggle {
width: 100%;
min-height: 32px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #0f1217;
color: var(--fg);
padding: 7px 10px;
outline: none;
transition: box-shadow .08s ease, border-color .08s ease, transform .04s ease;
}
.light select, .light input[type="range"], .light input[type="number"], .light input[type="text"], .light button, .light .toggle {
background: #fff;
}
input[type="range"] { height: 28px; }
input[type="number"] { text-align: center; }
button {
cursor: pointer;
font-weight: 700; letter-spacing: .2px;
background: var(--accent);
border: 1px solid var(--accent-strong);
color: #fff;
}
button.secondary {
background: var(--panel-2);
border-color: var(--border);
color: var(--fg);
}
button.danger { background: var(--danger); border-color: #c4314e; }
button:active { transform: translateY(1px); }
button:focus-visible, select:focus-visible, input:focus-visible {
box-shadow: 0 0 0 3px rgba(111,140,255,.35);
border-color: var(--accent);
}
.kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: var(--panel-2);
padding: 2px 6px; border-radius: 8px; border: 1px solid var(--border);
}
.light .kbd { background: #eef2ff; }
/* Stage: Visualizer + Piano */
.stage {
display: grid; gap: 16px;
grid-template-rows: auto 1fr;
}
/* Visualizer */
.viz-wrap { padding: 10px; }
canvas {
width: 100%; height: 88px;
border-radius: var(--radius);
background: #0b0f16;
border: 1px solid var(--border);
box-shadow: var(--shadow-2);
display: block;
}
/* Piano */
.piano-wrap { padding: 10px; }
.piano {
position: relative;
height: clamp(220px, 38vh, 360px);
user-select: none;
touch-action: none;
background: var(--panel);
border-radius: var(--radius);
border: 1px solid var(--border);
overflow: hidden;
box-shadow: var(--shadow-1);
}
.white-keys, .black-keys { position: absolute; inset: 0; display: grid; align-items: end; }
.white-keys { grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0; z-index: 1; }
.black-keys { grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0; z-index: 2; pointer-events: none; }
.key { position: relative; border-right: 1px solid var(--border-soft); }
.key:last-child { border-right: none; }
.key.white { height: 100%; background: #f7f8fb; }
.light .key.white { background: #ffffff; }
.key.white.pressed { background: #e8ecf5; box-shadow: inset 0 0 0 3px rgba(0,0,0,.06); }
.key.black {
pointer-events: auto; justify-self: center;
width: 60%; height: 62%; margin-bottom: 38%;
background: #0e1116;
border: 1px solid #000;
border-bottom: 6px solid #000;
border-radius: 0 0 8px 8px;
box-shadow: 0 10px 18px rgba(0,0,0,.55);
}
.light .key.black { background: #0e1116; }
.key.black.pressed { background: #1a1e25; }
.label {
position: absolute; left: 6px; bottom: 6px;
font-size: 11px; color: #5b6475; pointer-events: none;
}
.key.pressed .label { color: #1e242b; }
.black-slot { position: relative; }
.black-slot.hide { visibility: hidden; }
/* Metronome block */
.metro {
display: grid; gap: 10px;
}
.metro-dots {
display:flex; gap:8px; align-items:center; flex-wrap:wrap;
padding: 8px 2px 0;
}
.dot {
width:14px; height:14px; border-radius:999px;
border:1px solid var(--border); background:#0f1319;
box-shadow: inset 0 0 0 2px rgba(0,0,0,.22);
}
.dot.on { background: var(--accent); box-shadow: 0 0 0 3px rgba(111,140,255,.25); }
.dot.accent { border-color: var(--accent); }
.dot.on.accent { background: var(--accent); }
/* Footer status */
.footer {
display: grid; grid-template-columns: 1fr auto;
align-items: center; gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--panel-2);
position: sticky;
bottom: 0;
z-index: 5;
}
.chip {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--panel);
display: inline-flex; align-items: center; gap: 8px;
color: var(--muted);
font-size: 12px;
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<h1>Poke Piano — Studio</h1>
</div>
<div class="header-actions">
<button id="power">Start Audio</button>
<button style="display:none" id="theme" class="secondary">Theme</button>
</div>
</header>
<main class="app">
<!-- LEFT: Controls -->
<section class="side">
<div class="panel group">
<div class="title">Setup</div>
<div class="row">
<div class="pair"><label for="octaves">Octaves</label><input id="octaves" type="range" min="1" max="7" step="1" value="3"></div>
<div class="pair"><label for="baseOct">Base Octave</label><input id="baseOct" type="number" min="0" max="8" value="3"></div>
</div>
<div class="row">
<div class="pair"><label for="a4">A4 (Hz)</label><input id="a4" type="number" min="400" max="480" step="0.1" value="440"></div>
<div class="pair"><label for="poly">Polyphony</label><input id="poly" type="number" min="1" max="64" value="16"></div>
</div>
<div class="row-1">
<div class="pair"><label for="vol">Volume</label><input id="vol" type="range" min="0" max="1" step="0.001" value="0.7"></div>
</div>
</div>
<div class="panel group">
<div class="title">Oscillator</div>
<div class="row">
<div class="pair"><label for="wave">Wave</label>
<select id="wave">
<option>sine</option><option>triangle</option><option selected>square</option><option>sawtooth</option>
</select>
</div>
<div class="pair"><label for="detune">Detune (cents)</label><input id="detune" type="range" min="-100" max="100" value="0"></div>
</div>
<div class="row">
<div class="pair"><label for="port">Glide (ms)</label><input id="port" type="range" min="0" max="400" value="0"></div>
<div class="pair"><label for="lp">LP Cutoff (Hz)</label><input id="lp" type="range" min="200" max="18000" step="1" value="16000"></div>
</div>
</div>
<div class="panel group">
<div class="title">Envelope</div>
<div class="row">
<div class="pair"><label for="atk">Attack</label><input id="atk" type="range" min="0.001" max="2" step="0.001" value="0.01"></div>
<div class="pair"><label for="dec">Decay</label><input id="dec" type="range" min="0.001" max="2" step="0.001" value="0.15"></div>
</div>
<div class="row">
<div class="pair"><label for="sus">Sustain</label><input id="sus" type="range" min="0" max="1" step="0.001" value="0.6"></div>
<div class="pair"><label for="rel">Release</label><input id="rel" type="range" min="0.001" max="4" step="0.001" value="0.3"></div>
</div>
</div>
<div class="panel group">
<div class="title">FX & Utilities</div>
<div class="row-3">
<div class="pair"><label for="dtime">Delay (ms)</label><input id="dtime" type="range" min="0" max="900" value="0"></div>
<div class="pair"><label for="dfeed">Feedback</label><input id="dfeed" type="range" min="0" max="0.95" step="0.01" value="0.3"></div>
<div class="pair"><label for="dmix">Mix</label><input id="dmix" type="range" min="0" max="1" step="0.01" value="0.2"></div>
</div>
<div class="row-3">
<div class="pair"><label for="sustain">Sustain</label><button id="sustain" class="secondary">Off</button></div>
<div class="pair"><label for="labels">Key Labels</label><button id="labels" class="secondary">On</button></div>
<div class="pair"><label for="panic">Panic</label><button id="panic" class="danger">All Off</button></div>
</div>
<div class="row">
<div class="pair"><label for="rec">Record</label><button id="rec" class="secondary">Start</button></div>
<div></div>
</div>
</div>
<div class="panel group metro">
<div class="title">Metronome</div>
<div class="row">
<div class="pair"><label for="metroBtn">Control</label><button id="metroBtn" class="secondary">Start</button></div>
<div class="pair"><label for="bpm">BPM</label><input id="bpm" type="range" min="20" max="300" step="1" value="120"></div>
</div>
<div class="row">
<div class="pair"><label for="sig">Beats/Bar</label>
<select id="sig"><option>2</option><option>3</option><option selected>4</option><option>5</option><option>6</option><option>7</option><option>8</option><option>9</option><option>12</option></select>
</div>
<div class="pair"><label for="mvol">Volume</label><input id="mvol" type="range" min="0" max="1" step="0.01" value="0.8"></div>
</div>
<div class="row-1">
<div class="pair"><label for="accent">Accent</label><button id="accent" class="secondary">On</button></div>
</div>
<div class="metro-dots" id="metroDots" aria-hidden="true"></div>
</div>
</section>
<!-- RIGHT: Stage (Visualizer + Piano) -->
<section class="main">
<div class="panel viz-wrap">
<div class="title">Visualizer</div>
<canvas id="viz" width="900" height="120"></canvas>
</div>
<div class="panel piano-wrap">
<div class="title">Piano</div>
<div class="piano" id="piano">
<div class="white-keys" id="white"></div>
<div class="black-keys" id="black"></div>
</div>
</div>
</section>
</main>
<section class="footer">
<div></div>
<div class="chip"><span id="status">Ready</span></div>
</section>
<script>
;(() => {
const $ = sel => document.querySelector(sel)
const $$ = sel => Array.from(document.querySelectorAll(sel))
const els = {
power: $('#power'), theme: $('#theme'), octaves: $('#octaves'), baseOct: $('#baseOct'), a4: $('#a4'),
wave: $('#wave'), detune: $('#detune'), port: $('#port'), poly: $('#poly'), vol: $('#vol'),
atk: $('#atk'), dec: $('#dec'), sus: $('#sus'), rel: $('#rel'), lp: $('#lp'),
dtime: $('#dtime'), dfeed: $('#dfeed'), dmix: $('#dmix'), sustainBtn: $('#sustain'), labelsBtn: $('#labels'), panic: $('#panic'), recBtn: $('#rec'),
white: $('#white'), black: $('#black'), piano: $('#piano'), viz: $('#viz'),
metroBtn: $('#metroBtn'), bpm: $('#bpm'), sig: $('#sig'), mvol: $('#mvol'), accent: $('#accent'), metroDots: $('#metroDots'),
status: $('#status')
}
const storageKey = 'poke-piano-v2'
const defaults = {
theme: 'dark', octaves: 3, baseOct: 3, a4: 440, wave: 'square', detune: 0, port: 0,
poly: 16, vol: 0.7, atk: 0.01, dec: 0.15, sus: 0.6, rel: 0.3, lp: 16000,
dtime: 0, dfeed: 0.3, dmix: 0.2, sustain: false, labels: true,
bpm: 120, sig: 4, mvol: 0.8, accent: true
}
let conf = { ...defaults }
try { const saved = JSON.parse(localStorage.getItem(storageKey)||'{}'); conf = { ...conf, ...saved } } catch {}
function setStatus(t){ els.status.textContent = t }
function syncUI() {
if (conf.theme === 'light') document.body.classList.add('light'); else document.body.classList.remove('light')
els.octaves.value = conf.octaves
els.baseOct.value = conf.baseOct
els.a4.value = conf.a4
els.wave.value = conf.wave
els.detune.value = conf.detune
els.port.value = conf.port
els.poly.value = conf.poly
els.vol.value = conf.vol
els.atk.value = conf.atk
els.dec.value = conf.dec
els.sus.value = conf.sus
els.rel.value = conf.rel
els.lp.value = conf.lp
els.dtime.value = conf.dtime
els.dfeed.value = conf.dfeed
els.dmix.value = conf.dmix
els.sustainBtn.textContent = conf.sustain ? 'On' : 'Off'
els.labelsBtn.textContent = conf.labels ? 'On' : 'Off'
els.bpm.value = conf.bpm
els.sig.value = String(conf.sig)
els.mvol.value = conf.mvol
els.accent.textContent = conf.accent ? 'On' : 'Off'
}
syncUI()
function save() { localStorage.setItem(storageKey, JSON.stringify(conf)) }
let ctx, master, filter, analyser, delay, delayGain, feedback, merger, recDest, recorder, recChunks = [], recording = false
let mGain
let metroTimer = null, nextBeatTime = 0, beatIndex = 0, metroRunning = false
function initAudio() {
if (ctx) return
ctx = new (window.AudioContext || window.webkitAudioContext)()
master = ctx.createGain(); master.gain.value = conf.vol
filter = ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = conf.lp
analyser = ctx.createAnalyser(); analyser.fftSize = 2048
delay = ctx.createDelay(1.0); delay.delayTime.value = conf.dtime/1000
feedback = ctx.createGain(); feedback.gain.value = conf.dfeed
delayGain = ctx.createGain(); delayGain.gain.value = conf.dmix
recDest = ctx.createMediaStreamDestination()
merger = ctx.createGain()
mGain = ctx.createGain(); mGain.gain.value = conf.mvol
merger.connect(filter)
filter.connect(master)
master.connect(ctx.destination)
master.connect(analyser)
mGain.connect(master)
const delayIn = ctx.createGain()
filter.connect(delayIn)
delayIn.connect(delay)
delay.connect(feedback)
feedback.connect(delay)
delay.connect(delayGain)
delayGain.connect(master)
master.connect(recDest)
drawViz()
}
function drawViz() {
const c = els.viz, g = c.getContext('2d')
const data = new Uint8Array(analyser.frequencyBinCount)
function loop() {
requestAnimationFrame(loop)
analyser.getByteFrequencyData(data)
g.clearRect(0,0,c.width,c.height)
const w = c.width / data.length
for (let i=0;i<data.length;i++) {
const v = data[i]
const h = (v/255) * (c.height-8)
g.fillStyle = '#4a70ff'
g.fillRect(i*w, c.height-h, Math.max(1,w-1), h)
}
}
loop()
}
function freqFromMidi(n) { return conf.a4 * Math.pow(2, (n-69)/12) }
const keyMap = {
'z': 60, 's': 61, 'x': 62, 'd': 63, 'c': 64, 'v': 65, 'g': 66, 'b': 67, 'h': 68, 'n': 69, 'j': 70, 'm': 71,
',': 72, 'l': 73, '.': 74, ';': 75, '/': 76
}
const ACTIVE = new Map()
const HELD = new Set()
const SUSTAINED = new Set()
function voiceOn(midi, velocity=1) {
if (!ctx) initAudio()
if (ACTIVE.size >= conf.poly && !ACTIVE.has(midi)) {
const oldest = ACTIVE.keys().next().value
voiceOff(oldest)
}
const t = ctx.currentTime
const dest = merger
const osc = ctx.createOscillator()
osc.type = conf.wave
osc.frequency.setValueAtTime(freqFromMidi(midi), t)
if (conf.port > 0 && ACTIVE.has('__lastFreq')) {
const last = ACTIVE.get('__lastFreq')
osc.frequency.setValueAtTime(last, t)
osc.frequency.linearRampToValueAtTime(freqFromMidi(midi), t + conf.port/1000)
}
osc.detune.value = conf.detune
const env = ctx.createGain(); env.gain.setValueAtTime(0, t)
env.gain.cancelScheduledValues(t)
env.gain.linearRampToValueAtTime(1, t + conf.atk)
env.gain.linearRampToValueAtTime(conf.sus, t + conf.atk + conf.dec)
osc.connect(env)
env.connect(dest)
osc.start()
ACTIVE.set(midi, { osc, env })
ACTIVE.set('__lastFreq', freqFromMidi(midi))
const keyEl = document.querySelector(`[data-midi="${midi}"]`)
if (keyEl) keyEl.classList.add('pressed')
}
function voiceOff(midi) {
const v = ACTIVE.get(midi)
if (!v) return
const t = ctx.currentTime
v.env.gain.cancelScheduledValues(t)
v.env.gain.setValueAtTime(v.env.gain.value, t)
v.env.gain.linearRampToValueAtTime(0, t + conf.rel)
try { v.osc.stop(t + conf.rel + 0.01) } catch {}
setTimeout(() => {
try { v.osc.disconnect(); v.env.disconnect() } catch {}
}, (conf.rel*1000)+20)
ACTIVE.delete(midi)
const keyEl = document.querySelector(`[data-midi="${midi}"]`)
if (keyEl) keyEl.classList.remove('pressed')
}
function allOff() { Array.from(ACTIVE.keys()).filter(k=>k!=='__lastFreq').forEach(voiceOff); SUSTAINED.clear(); HELD.clear() }
function buildKeys() {
els.white.innerHTML = ''
els.black.innerHTML = ''
const octs = conf.octaves|0
const base = conf.baseOct|0
const whiteOrder = ['C','D','E','F','G','A','B']
const hasSharp = { 'C':true,'D':true,'F':true,'G':true,'A':true }
let midi = (base+1)*12
for (let o=0; o<octs; o++) {
for (const n of whiteOrder) {
const w = document.createElement('div')
w.className = 'key white'
w.dataset.midi = String(midi)
const label = document.createElement('div')
label.className = 'label'
label.textContent = midiToName(midi)
w.appendChild(label)
els.white.appendChild(w)
const slot = document.createElement('div')
slot.className = 'black-slot'
els.black.appendChild(slot)
if (hasSharp[n]) {
const b = document.createElement('div')
b.className = 'key black'
b.dataset.midi = String(midi+1)
const bl = document.createElement('div')
bl.className = 'label'
bl.textContent = midiToName(midi+1)
b.appendChild(bl)
slot.appendChild(b)
} else {
slot.classList.add('hide')
}
midi += (n==='E'||n==='B') ? 1 : 2
}
}
attachKeyHandlers()
applyLabelVisibility()
}
function midiToName(m) {
const names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
const name = names[m%12]
const oct = Math.floor(m/12)-1
return name+oct
}
function attachKeyHandlers() {
const down = e => { const el = e.target.closest('.key'); if (!el) return; const m = +el.dataset.midi; HELD.add(m); voiceOn(m) }
const up = e => { const el = e.target.closest('.key'); if (!el) return; const m = +el.dataset.midi; HELD.delete(m); if (conf.sustain) SUSTAINED.add(m); else voiceOff(m) }
$$('.key.white,.key.black').forEach(k => {
k.onmousedown = down
k.onmouseup = up
k.onmouseleave = e => { if (HELD.has(+k.dataset.midi)) up(e) }
k.ontouchstart = e => { e.preventDefault(); down(e) }
k.ontouchend = e => { e.preventDefault(); up(e) }
})
}
function applyLabelVisibility() { $$('.label').forEach(l => l.style.display = conf.labels ? 'block' : 'none') }
function handleKey(e, isDown) {
if (e.repeat) return
const key = e.key.toLowerCase()
if (key === ' ') { e.preventDefault(); if (isDown) toggleSustain(); return }
if (key === 'escape' && isDown) { allOff(); return }
if (!keyMap.hasOwnProperty(key)) return
const base = (conf.baseOct+1)*12
const offset = keyMap[key] - 60
const m = base + 60 + offset
if (isDown) { if (!HELD.has(m)) { HELD.add(m); voiceOn(m) } }
else { HELD.delete(m); if (conf.sustain) SUSTAINED.add(m); else voiceOff(m) }
}
function releaseSustained() { Array.from(SUSTAINED).forEach(m => { if (!HELD.has(m)) { voiceOff(m); SUSTAINED.delete(m) } }) }
function toggleSustain() { conf.sustain = !conf.sustain; els.sustainBtn.textContent = conf.sustain ? 'On' : 'Off'; if (!conf.sustain) releaseSustained(); save() }
document.addEventListener('keydown', e => handleKey(e, true))
document.addEventListener('keyup', e => handleKey(e, false))
els.panic.onclick = () => { allOff(); setStatus('All notes off') }
els.power.onclick = () => { initAudio(); els.power.textContent = 'Audio On'; setStatus('Audio ready') }
els.theme.onclick = () => { conf.theme = conf.theme==='dark'?'light':'dark'; save(); syncUI() }
els.octaves.oninput = e => { conf.octaves = +e.target.value; save(); buildKeys() }
els.baseOct.oninput = e => { conf.baseOct = Math.min(8, Math.max(0, +e.target.value|0)); save(); buildKeys() }
els.a4.oninput = e => { conf.a4 = +e.target.value; save() }
els.wave.oninput = e => { conf.wave = e.target.value; save() }
els.detune.oninput = e => { conf.detune = +e.target.value; save() }
els.port.oninput = e => { conf.port = +e.target.value; save() }
els.poly.oninput = e => { conf.poly = Math.max(1, Math.min(64, +e.target.value|0)); e.target.value = conf.poly; save() }
els.vol.oninput = e => { conf.vol = +e.target.value; if (master) master.gain.value = conf.vol; save() }
els.atk.oninput = e => { conf.atk = +e.target.value; save() }
els.dec.oninput = e => { conf.dec = +e.target.value; save() }
els.sus.oninput = e => { conf.sus = +e.target.value; save() }
els.rel.oninput = e => { conf.rel = +e.target.value; save() }
els.lp.oninput = e => { conf.lp = +e.target.value; if (filter) filter.frequency.value = conf.lp; save() }
els.dtime.oninput = e => { conf.dtime = +e.target.value; if (delay) delay.delayTime.value = conf.dtime/1000; save() }
els.dfeed.oninput = e => { conf.dfeed = +e.target.value; if (feedback) feedback.gain.value = conf.dfeed; save() }
els.dmix.oninput = e => { conf.dmix = +e.target.value; if (delayGain) delayGain.gain.value = conf.dmix; save() }
els.sustainBtn.onclick = () => toggleSustain()
els.labelsBtn.onclick = () => { conf.labels = !conf.labels; save(); applyLabelVisibility(); els.labelsBtn.textContent = conf.labels ? 'On' : 'Off' }
function startRecording() {
if (!ctx) initAudio()
try { recorder = new MediaRecorder(recDest.stream) } catch { alert('Recording not supported in this browser.'); return }
recChunks = []
recorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data) }
recorder.onstop = () => {
const blob = new Blob(recChunks, { type: recorder.mimeType || 'audio/webm' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `poke-piano-${Date.now()}.webm`
a.textContent = 'Download recording'
a.className = 'kbd'
els.recBtn.replaceWith(a)
setTimeout(() => { URL.revokeObjectURL(url) }, 60000)
}
recorder.start()
recording = true
els.recBtn.textContent = 'Stop'
setStatus('Recording…')
}
function stopRecording() { if (!recorder) return; recorder.stop(); recording = false; setStatus('Recording saved') }
els.recBtn.onclick = () => { recording ? stopRecording() : startRecording() }
function buildDots() {
els.metroDots.innerHTML = ''
for (let i=0;i<conf.sig;i++) {
const d = document.createElement('div')
d.className = 'dot' + (i===0 ? ' accent' : '')
els.metroDots.appendChild(d)
}
}
function setDotActive(i) {
const nodes = els.metroDots.children
for (let k=0;k<nodes.length;k++) nodes[k].classList.toggle('on', k===i)
}
function metroClick(time, isAccent) {
const osc = ctx.createOscillator()
const env = ctx.createGain()
osc.type = 'square'
osc.frequency.setValueAtTime(isAccent ? 2000 : 1300, time)
env.gain.setValueAtTime(0, time)
env.gain.linearRampToValueAtTime(1, time + 0.002)
env.gain.exponentialRampToValueAtTime(0.0001, time + 0.05)
osc.connect(env); env.connect(mGain)
osc.start(time)
osc.stop(time + 0.06)
}
function metroSchedule() {
if (!ctx) initAudio()
const ahead = 0.1
const interval = 60 / conf.bpm
while (nextBeatTime < ctx.currentTime + ahead) {
const idx = beatIndex % conf.sig
const accentBeat = idx === 0 && conf.accent
metroClick(nextBeatTime, accentBeat)
const uiDelay = Math.max(0, nextBeatTime - ctx.currentTime)
setTimeout(() => setDotActive(idx), uiDelay*1000)
beatIndex++
nextBeatTime += interval
}
}
function metroStart() {
if (!ctx) initAudio()
if (metroRunning) return
metroRunning = true
beatIndex = 0
nextBeatTime = ctx.currentTime + 0.06
metroTimer = setInterval(metroSchedule, 25)
els.metroBtn.textContent = 'Stop'
setStatus(`Metronome ${conf.bpm} BPM`)
}
function metroStop() {
if (!metroRunning) return
metroRunning = false
clearInterval(metroTimer); metroTimer = null
setDotActive(-1)
els.metroBtn.textContent = 'Start'
setStatus('Metronome off')
}
els.metroBtn.onclick = () => { metroRunning ? metroStop() : metroStart() }
els.bpm.oninput = e => { conf.bpm = +e.target.value; save(); if (metroRunning) { metroStop(); metroStart() } }
els.sig.oninput = e => { conf.sig = +e.target.value; save(); buildDots(); if (metroRunning) { metroStop(); metroStart() } }
els.mvol.oninput = e => { conf.mvol = +e.target.value; if (mGain) mGain.gain.value = conf.mvol; save() }
els.accent.onclick = () => { conf.accent = !conf.accent; els.accent.textContent = conf.accent ? 'On' : 'Off'; save() }
function initUI() { buildKeys(); buildDots() }
initUI()
window.addEventListener('blur', () => { allOff(); metroStop() })
document.addEventListener('visibilitychange', () => { if (document.hidden) { allOff(); metroStop() } })
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { allOff() } })
})()
</script>
</body>
</html>