Pocket Synth
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A playable two-octave synthesiser with no sound files behind it: every note is a pair of detuned oscillators built the instant you press a key, run through a shared lowpass filter, an attack/release envelope and a feedback delay line. Hit Play riff first — that same click is what wakes the audio engine — then take over with the letters printed on the keys (A W S E D F T G Y H U J K, Z and X to shift octave), or drag across the keys to slide between notes and hold several at once for a chord. The little scope earns its place too: while the page is silent it draws the waveform you have armed, and once you play it switches to the real output, so you can watch slow pad and buzzy rebuild the same phrase out of nothing but a wave shape, an envelope and a delay.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Pocket Synth — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local values the shared tokens don't carry */
:root {
--shadow: 0 1px 2px rgba(26,29,33,.05);
--well: #F1F0EC; /* recessed panel the keys sit in */
--wkey: #FFFFFF; /* long key face */
--wkey-lo: #EFEDE6; /* long key shading toward the front lip */
--wkey-edge: #D5D3CB;
--bkey: #23262C; /* short key face */
--bkey-lo: #14161A;
--bkey-ink: #C9C6BE;
--wkey-ink: #4A4F57; /* legend on the long keys, readable on both faces */
--glow: rgba(192,86,0,.30);
--rail: #B0ACA2; /* the strip the keys hinge on */
--btn-ink: #FFFFFF;
--kbh: clamp(140px, 34vw, 178px);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--shadow: 0 1px 2px rgba(0,0,0,.35);
--well: #16181D;
--wkey: #DAD7D0; --wkey-lo: #B4B0A8; --wkey-edge: #4A4E56;
--bkey: #23262C; --bkey-lo: #0C0E11; --bkey-ink: #9AA0A8;
--wkey-ink: #3E434B;
--glow: rgba(255,180,84,.34);
--rail: #0C0E11;
--btn-ink: #14161A;
}
}
:root[data-theme="dark"] {
--shadow: 0 1px 2px rgba(0,0,0,.35);
--well: #16181D;
--wkey: #DAD7D0; --wkey-lo: #B4B0A8; --wkey-edge: #4A4E56;
--bkey: #23262C; --bkey-lo: #0C0E11; --bkey-ink: #9AA0A8;
--wkey-ink: #3E434B;
--glow: rgba(255,180,84,.34);
--rail: #0C0E11;
--btn-ink: #14161A;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap {
max-width: 940px; margin: 0 auto;
padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 14px;
}
@media (max-width: 460px) { .wrap { padding: 16px 8px 20px; gap: 11px; } }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 66ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 14px;
}
@media (max-width: 460px) { .card { padding: 10px 9px; } }
/* ── transport ────────────────────────────────────────────────────────────── */
.transport {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
padding-bottom: 12px; margin-bottom: 12px;
border-bottom: 1px dashed var(--line);
}
.btn {
font: inherit; font-size: 13.5px; font-weight: 600;
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 8px 13px; cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: background .12s ease, border-color .12s ease, transform .06s ease;
-webkit-tap-highlight-color: transparent;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn.primary {
background: var(--accent); border-color: var(--accent); color: var(--btn-ink);
min-width: 122px; justify-content: center; padding: 9px 15px;
position: relative;
}
.btn.primary:hover { filter: brightness(1.07); background: var(--accent); }
.btn.primary.on { background: var(--surface); color: var(--ink); border-color: var(--ink); }
.btn.primary.nudge::after {
content: ""; position: absolute; inset: -4px; border-radius: 11px;
border: 2px solid var(--accent); opacity: 0;
animation: ring 2.6s ease-out infinite; pointer-events: none;
}
@keyframes ring { 0% { opacity: .55; transform: scale(1) } 45%,100% { opacity: 0; transform: scale(1.12) } }
.btn .gl { font-size: 11px; line-height: 1; }
.btn:focus-visible, .chip:focus-visible, .tog:focus-visible, .oct:focus-visible,
.k:focus-visible, input[type=range]:focus-visible, .wave input:focus-visible + label {
outline: 2px solid var(--ink); outline-offset: 2px;
}
/* the ring sits inside the key face, so --ink is wrong on half of them: it
vanishes on a white key in dark theme and on a black key in light theme.
currentColor is already the one colour picked to read on that key. */
.k:focus-visible { outline-color: currentColor; outline-offset: -3px; z-index: 5; }
.status {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .07em;
padding: 5px 9px; border-radius: 99px; white-space: nowrap;
background: var(--well); color: var(--muted); border: 1px solid var(--line);
}
.status .led { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .45; }
.status.live { background: var(--run-soft); color: var(--run); border-color: transparent; }
.status.live .led { background: var(--run); opacity: 1; }
.status.live.blink .led { animation: blink 1.05s steps(1) infinite; }
@keyframes blink { 0%,55% { opacity: 1 } 56%,100% { opacity: .28 } }
.readout {
font-family: var(--font-mono); font-size: 12.5px; color: var(--muted);
display: flex; gap: 5px; align-items: baseline; white-space: nowrap;
}
.readout b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
.readout .sep { opacity: .4; }
.scopebox {
flex: 1 1 190px; min-width: 150px; margin-left: auto;
height: 46px; border-radius: 7px; overflow: hidden;
background: var(--well); border: 1px solid var(--line);
}
@media (max-width: 620px) { .scopebox { margin-left: 0; flex-basis: 100%; } }
#scope { display: block; width: 100%; height: 100%; }
/* ── control rack ─────────────────────────────────────────────────────────── */
.rack { display: flex; flex-direction: column; gap: 13px; }
.row1 {
display: flex; flex-wrap: wrap; align-items: center;
gap: 10px 22px; justify-content: space-between;
}
.row2 { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 13px 18px; }
.grp { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
.rl {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em;
color: var(--muted); flex: none;
}
.wave { display: flex; gap: 5px; flex-wrap: wrap; position: relative; }
.wave input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
.wave label {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; color: var(--muted);
background: var(--surface); border: 1px solid var(--line);
border-radius: 99px; padding: 5px 11px 5px 9px; cursor: pointer;
transition: background .12s ease, color .12s ease, border-color .12s ease;
-webkit-tap-highlight-color: transparent;
}
.wave label:hover { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); }
.wave label svg { display: block; overflow: visible; }
.wave label svg polyline, .wave label svg path { stroke: currentColor; stroke-width: 1.6; fill: none; stroke-linejoin: round; stroke-linecap: round; }
.wave input:checked + label {
color: var(--ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600;
}
.chip {
font: inherit; font-size: 12.5px; color: var(--muted);
background: var(--surface); border: 1px solid var(--line);
border-radius: 99px; padding: 5px 11px; cursor: pointer;
transition: background .12s ease, color .12s ease, border-color .12s ease;
-webkit-tap-highlight-color: transparent;
}
.chip:hover { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); }
.chip[aria-pressed="true"] { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.knob { flex: 1 1 148px; min-width: 118px; max-width: 250px; }
.dgrp {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 13px 14px;
flex: 1 1 336px; min-width: 250px;
}
.dgrp .knob { flex: 1 1 116px; min-width: 104px; max-width: none; }
.knob .kl {
display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .1em;
color: var(--muted); margin-bottom: 3px;
}
.knob .kv { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: .02em; }
.knob.off { opacity: .42; }
.knob.off .kv { color: var(--muted); font-weight: 500; }
input[type=range] {
-webkit-appearance: none; appearance: none;
width: 100%; background: transparent; margin: 0; height: 20px; cursor: pointer;
touch-action: pan-y;
}
input[type=range]:disabled { cursor: default; }
input[type=range]::-webkit-slider-runnable-track { height: 5px; border-radius: 99px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 17px; height: 17px; margin-top: -6px;
border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.25);
}
input[type=range]::-moz-range-track { height: 5px; border-radius: 99px; background: var(--line); }
input[type=range]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 1px 3px rgba(0,0,0,.25);
}
input[type=range]:disabled::-webkit-slider-thumb { background: var(--muted); }
input[type=range]:disabled::-moz-range-thumb { background: var(--muted); }
.tog {
font: inherit; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em;
color: var(--muted); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 8px 12px; cursor: pointer; flex: none; align-self: flex-end;
display: inline-flex; align-items: center; gap: 7px;
transition: background .12s ease, color .12s ease, border-color .12s ease;
-webkit-tap-highlight-color: transparent;
}
.tog .led2 { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .35; }
.tog:hover { color: var(--ink); border-color: var(--accent); }
.tog[aria-pressed="true"] { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); }
.tog[aria-pressed="true"] .led2 { background: var(--accent); opacity: 1; }
/* ── keyboard ─────────────────────────────────────────────────────────────── */
.kbwrap {
background: var(--well); border: 1px solid var(--line);
border-radius: 9px; padding: 9px;
margin-top: 13px;
}
@media (max-width: 460px) { .kbwrap { padding: 6px; } }
.kb {
position: relative; display: flex; height: var(--kbh);
touch-action: none; user-select: none; -webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
border-top: 3px solid var(--rail);
border-radius: 5px 5px 8px 8px;
isolation: isolate;
}
.k {
font: inherit; padding: 0; margin: 0; cursor: pointer;
display: flex; flex-direction: column; justify-content: flex-end; align-items: center;
gap: 3px; overflow: hidden; -webkit-tap-highlight-color: transparent;
}
.wk {
flex: 1 1 0; min-width: 0; position: relative;
background: linear-gradient(180deg, var(--wkey) 0%, var(--wkey) 72%, var(--wkey-lo) 100%);
border: 1px solid var(--wkey-edge); border-top: none;
border-radius: 2px 2px 6px 6px;
margin-right: -1px; padding-bottom: 8px;
color: var(--wkey-ink);
box-shadow: inset 0 -3px 0 rgba(0,0,0,.05);
}
.wk.lastw { margin-right: 0; }
.bk {
position: absolute; top: 0; z-index: 3;
height: 62%; padding-bottom: 7px;
background: linear-gradient(180deg, var(--bkey) 0%, var(--bkey) 68%, var(--bkey-lo) 100%);
border: 1px solid var(--bkey-lo); border-top: none;
border-radius: 2px 2px 5px 5px;
color: var(--bkey-ink);
box-shadow: 0 3px 5px rgba(0,0,0,.28), inset 0 -3px 0 rgba(255,255,255,.06);
}
.k .nm, .k .ltr {
font-family: var(--font-mono); line-height: 1;
pointer-events: none; max-width: 100%;
}
/* the note name has to clear 4.5:1 against the key face in BOTH themes — the
dark-theme key is a light beige, so anything fainter than this fails there */
.k .nm { font-size: 9.5px; letter-spacing: .02em; opacity: .9; }
.wk .ltr {
font-size: 10px; font-weight: 600;
border: 1px solid var(--wkey-edge); border-radius: 4px;
padding: 2px 0; width: 15px; text-align: center;
background: rgba(0,0,0,.03);
}
.bk .ltr {
font-size: 10px; font-weight: 600;
border: 1px solid rgba(255,255,255,.22); border-radius: 4px;
padding: 2px 0; width: 15px; text-align: center;
}
/* the strike glows from the rail down; the legend end of the key stays on
--accent-soft so the letter never lands accent-on-accent */
.wk.lit {
background: linear-gradient(180deg, var(--accent) 0%, var(--accent) 24%, var(--accent-soft) 58%, var(--accent-soft) 100%);
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 12px var(--glow);
color: var(--ink);
}
.wk.lit .ltr { border-color: var(--accent); background: transparent; }
.bk.lit {
background: linear-gradient(180deg, var(--accent) 0%, var(--accent) 70%, var(--accent) 100%);
border-color: var(--accent);
box-shadow: 0 0 12px var(--glow), inset 0 -3px 0 rgba(0,0,0,.18);
color: var(--btn-ink);
}
.bk.lit .ltr { border-color: rgba(0,0,0,.28); }
/* the key press itself must feel instant; only the fade-out is eased */
.k { transition: background .16s ease, box-shadow .16s ease, color .16s ease, border-color .16s ease; }
.k.lit { transition: none; }
@media (max-width: 560px) {
.wk .nm.opt { display: none; }
}
@media (max-width: 440px) {
.wk .ltr { width: 13px; font-size: 9px; padding: 1px 0; }
.bk .ltr { width: auto; border: none; padding: 0; font-size: 9.5px; }
.k .nm { font-size: 8.5px; }
.wk { padding-bottom: 6px; }
.bk { padding-bottom: 5px; }
}
/* ── keyboard bar ─────────────────────────────────────────────────────────── */
.kbbar {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
margin-top: 11px;
}
.oct {
font: inherit; font-size: 15px; line-height: 1;
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 7px;
width: 30px; height: 28px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: background .12s ease, border-color .12s ease;
-webkit-tap-highlight-color: transparent;
}
.oct:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); }
.oct:disabled { color: var(--muted); opacity: .45; cursor: default; }
.octv {
font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--ink); min-width: 74px; text-align: center; letter-spacing: .03em;
}
.tip { font-size: 12.5px; color: var(--muted); margin-left: auto; }
@media (max-width: 620px) { .tip { margin-left: 0; flex-basis: 100%; } }
.hint { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.85; }
.hint b { color: var(--ink); font-weight: 600; }
kbd {
font-family: var(--font-mono); font-size: 11px;
border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
padding: 1px 5px; background: var(--surface); color: var(--ink);
margin-right: 1px; white-space: nowrap;
}
.note { font-family: var(--font-mono); font-size: 12px; color: var(--danger); margin: 0; }
footer {
font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
letter-spacing: .04em; padding-top: 2px;
}
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important; animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Pocket Synth</h1>
<p class="lede">Two octaves of playable synthesiser, built from raw oscillators — <b>no sound files anywhere in this page</b>. Every note is generated the moment you press a key.</p>
</header>
<main class="card">
<div class="transport">
<button class="btn primary nudge" id="riffBtn"><span class="gl" id="riffGl">▶</span><span id="riffTxt">Play riff</span></button>
<span class="status" id="status"><span class="led"></span><span id="statusTxt">TAP TO START</span></span>
<div class="readout">
<span>NOTE</span><b id="roNote">—</b>
<span class="sep">·</span><b id="roHz">—</b><span>Hz</span>
<span class="sep">·</span><span>VOICES</span><b id="roV">0</b>
</div>
<div class="scopebox"><canvas id="scope" aria-hidden="true"></canvas></div>
</div>
<div class="rack">
<div class="row1">
<div class="grp">
<span class="rl">WAVE</span>
<div class="wave" id="waveGrp" role="radiogroup" aria-label="Waveform"></div>
</div>
<div class="grp">
<span class="rl">PATCH</span>
<div class="grp" id="presets"></div>
</div>
</div>
<div class="row2">
<div class="knob">
<div class="kl"><span>ATTACK</span><span class="kv" id="atkV">8 ms</span></div>
<input type="range" id="atk" min="0" max="1000" step="1" value="207" aria-label="Attack time">
</div>
<div class="knob">
<div class="kl"><span>RELEASE</span><span class="kv" id="relV">420 ms</span></div>
<input type="range" id="rel" min="0" max="1000" step="1" value="536" aria-label="Release time">
</div>
<div class="knob">
<div class="kl"><span>TONE</span><span class="kv" id="cutV">3.2 kHz</span></div>
<input type="range" id="cut" min="0" max="1000" step="1" value="677" aria-label="Tone, filter cutoff">
</div>
<div class="dgrp">
<button class="tog" id="dlyBtn" aria-pressed="true"><span class="led2"></span>DELAY</button>
<div class="knob" id="dtKnob">
<div class="kl"><span>TIME</span><span class="kv" id="dtV">260 ms</span></div>
<input type="range" id="dt" min="0" max="1000" step="1" value="654" aria-label="Delay time">
</div>
<div class="knob" id="dfKnob">
<div class="kl"><span>FEEDBACK</span><span class="kv" id="dfV">28 %</span></div>
<input type="range" id="df" min="0" max="75" step="1" value="28" aria-label="Delay feedback">
</div>
</div>
</div>
</div>
<div class="kbwrap">
<div class="kb" id="kb" role="group" aria-label="Two octave keyboard. Use left and right arrows to move between keys, space to play."></div>
</div>
<div class="kbbar">
<span class="rl">OCTAVE</span>
<button class="oct" id="octDn" aria-label="Shift down an octave">−</button>
<span class="octv" id="octV">C3–B4</span>
<button class="oct" id="octUp" aria-label="Shift up an octave">+</button>
<span class="tip">Drag across the keys to slide · hold several at once for a chord</span>
</div>
<p class="sr" id="live" role="status" aria-live="polite"></p>
</main>
<p class="hint"><b>Type to play:</b>
<kbd>A</kbd><kbd>W</kbd><kbd>S</kbd><kbd>E</kbd><kbd>D</kbd><kbd>F</kbd><kbd>T</kbd><kbd>G</kbd><kbd>Y</kbd><kbd>H</kbd><kbd>U</kbd><kbd>J</kbd><kbd>K</kbd>
runs C up to C across the lower octave — the same letters are printed on the keys.
<kbd>Z</kbd> and <kbd>X</kbd> shift the whole keyboard down or up an octave.</p>
<p class="note" id="audioNote" hidden>This browser has no Web Audio support, so the keys light up but stay silent.</p>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
var root = document.documentElement;
/* ── theme plumbing ─────────────────────────────────────────────────────── */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
themeDirty = true;
drawScope();
}
});
var mqReduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduce = !!(mqReduce && mqReduce.matches);
if (mqReduce && mqReduce.addEventListener) {
mqReduce.addEventListener("change", function (e) { reduce = e.matches; });
}
var mqDark = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
if (mqDark && mqDark.addEventListener) {
/* the scope caches its colours, so an idle canvas keeps the old palette
unless we repaint it right here */
mqDark.addEventListener("change", function () { themeDirty = true; drawScope(); });
}
/* ── notes ──────────────────────────────────────────────────────────────── */
var NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"];
var PLAIN = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
var BLACK = [0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0];
var SPAN = 24; /* two octaves on screen */
var LETTERS = ["A", "W", "S", "E", "D", "F", "T", "G", "Y", "H", "U", "J", "K"];
var KEYMAP = {};
LETTERS.forEach(function (l, i) { KEYMAP[l] = i; });
function nameOf(m) { return NAMES[m % 12] + (Math.floor(m / 12) - 1); }
function plainOf(m) { return PLAIN[m % 12] + (Math.floor(m / 12) - 1); }
function hzOf(m) { return 440 * Math.pow(2, (m - 69) / 12); }
var oct = 3; /* C3 is the low key */
function baseMidi() { return 12 * (oct + 1); }
/* ── audio graph ────────────────────────────────────────────────────────── */
var AC = window.AudioContext || window.webkitAudioContext;
var ctx = null, bus = null, filt = null, dry = null, dl = null, fb = null, fbLp = null,
wet = null, master = null, comp = null, analyser = null, scopeBuf = null;
var audioDead = !AC;
var PEAK = { sine: 0.30, triangle: 0.25, sawtooth: 0.15, square: 0.13 };
function expo(v, lo, hi) { return lo * Math.pow(hi / lo, v / 1000); }
var P = { /* the live patch */
wave: "triangle",
atk: 0.008, rel: 0.42, cut: 3200,
delay: true, dtime: 0.26, dfb: 0.28
};
function buildGraph() {
ctx = new AC();
bus = ctx.createGain(); bus.gain.value = 0.9;
filt = ctx.createBiquadFilter(); filt.type = "lowpass"; filt.frequency.value = P.cut; filt.Q.value = 0.8;
dry = ctx.createGain(); dry.gain.value = 1;
dl = ctx.createDelay(1.2); dl.delayTime.value = P.dtime;
fb = ctx.createGain(); fb.gain.value = P.dfb;
fbLp = ctx.createBiquadFilter(); fbLp.type = "lowpass"; fbLp.frequency.value = 2600;
wet = ctx.createGain(); wet.gain.value = P.delay ? 0.42 : 0;
master = ctx.createGain(); master.gain.value = 0.85;
comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14; comp.knee.value = 24; comp.ratio.value = 5;
comp.attack.value = 0.004; comp.release.value = 0.2;
analyser = ctx.createAnalyser();
analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.2;
scopeBuf = new Uint8Array(analyser.fftSize);
bus.connect(filt);
filt.connect(dry); dry.connect(master);
filt.connect(dl);
dl.connect(fb); fb.connect(fbLp); fbLp.connect(dl); /* feedback loop */
dl.connect(wet); wet.connect(master);
master.connect(comp);
comp.connect(analyser);
comp.connect(ctx.destination);
}
function ensureAudio() {
if (audioDead) return false;
if (!ctx) {
try { buildGraph(); }
catch (err) { audioDead = true; document.getElementById("audioNote").hidden = false; return false; }
riffBtn.classList.remove("nudge");
setStatus();
}
if (ctx.state === "suspended" && ctx.resume) { ctx.resume().then(setStatus, function () {}); }
return true;
}
/* ── voices ─────────────────────────────────────────────────────────────── */
var voiceCount = 0;
function bump(v, on) {
if (on === !!v.counted) return;
v.counted = on; voiceCount += on ? 1 : -1;
if (voiceCount < 0) voiceCount = 0;
paintVoices();
}
function startVoice(midi, when) {
if (!ctx) return null;
var peak = PEAK[P.wave] || 0.2;
var atk = P.atk, dec = 0.35 + atk * 0.6, sus = peak * 0.7;
var env = ctx.createGain();
var relG = ctx.createGain();
env.gain.setValueAtTime(0, when);
env.gain.linearRampToValueAtTime(peak, when + atk);
env.gain.linearRampToValueAtTime(sus, when + atk + dec);
relG.gain.setValueAtTime(1, when);
var f = hzOf(midi);
var o1 = ctx.createOscillator(); o1.type = P.wave; o1.frequency.value = f; o1.detune.value = -6;
var o2 = ctx.createOscillator(); o2.type = P.wave; o2.frequency.value = f; o2.detune.value = 7;
var g2 = ctx.createGain(); g2.gain.value = 0.55;
o1.connect(env); o2.connect(g2); g2.connect(env);
env.connect(relG); relG.connect(bus);
o1.start(when); o2.start(when);
var v = { o1: o1, o2: o2, env: env, relG: relG, g2: g2, t0: when, dead: false, counted: false, tin: 0 };
/* a note the riff booked for four seconds from now is not sounding yet, so
count it when it actually starts rather than when it is scheduled */
var lead = (when - ctx.currentTime) * 1000;
if (lead <= 12) bump(v, true);
else v.tin = window.setTimeout(function () { v.tin = 0; if (!v.dead) bump(v, true); }, lead);
o1.onended = function () {
if (v.dead) return;
v.dead = true;
if (v.tin) { clearTimeout(v.tin); v.tin = 0; }
bump(v, false); setStatus();
try { o1.disconnect(); o2.disconnect(); g2.disconnect(); env.disconnect(); relG.disconnect(); } catch (e) {}
};
return v;
}
function stopVoice(v, when) {
if (!v || v.stopped) return;
v.stopped = true;
var rel = P.rel;
/* release rides its own gain node, so the attack curve is never cancelled
mid-flight — that is what stops the click on short notes with slow attacks */
v.relG.gain.setValueAtTime(1, when);
v.relG.gain.linearRampToValueAtTime(0.0001, when + rel);
try { v.o1.stop(when + rel + 0.03); v.o2.stop(when + rel + 0.03); } catch (e) {}
}
/* cancel a voice outright: stopVoice() has already run on every riff note at
schedule time, so "Stop" has to override that automation, not re-request it */
function killVoice(v, when) {
if (!v || v.killed) return;
v.killed = true;
if (v.tin) { clearTimeout(v.tin); v.tin = 0; }
var g = v.relG.gain;
try {
if (g.cancelAndHoldAtTime) g.cancelAndHoldAtTime(when);
else { g.cancelScheduledValues(when); g.setValueAtTime(g.value, when); }
g.linearRampToValueAtTime(0.0001, when + 0.06);
} catch (e) {}
/* a note booked for later has not started yet — stopping before its own
start time would leave it dangling, so stop it just after instead */
var end = Math.max(when + 0.09, v.t0 + 0.005);
try { v.o1.stop(end); v.o2.stop(end); } catch (e) {}
}
/* ── key lighting ───────────────────────────────────────────────────────── */
var keyEls = [], litN = new Array(SPAN).fill(0);
function light(off, d) {
if (off < 0 || off >= SPAN) return;
litN[off] += d;
var el = keyEls[off];
if (!el) return;
if (litN[off] > 0) el.classList.add("lit");
else { litN[off] = 0; el.classList.remove("lit"); }
}
/* ── held notes (pointer + typing share one map) ─────────────────────────── */
var held = new Map(); /* offset -> { v, srcs:Set } */
function press(off, src) {
if (off < 0 || off >= SPAN) return;
var h = held.get(off);
if (h) { h.srcs.add(src); return; }
var ok = ensureAudio();
var midi = baseMidi() + off;
var v = ok && ctx ? startVoice(midi, ctx.currentTime) : null;
held.set(off, { v: v, srcs: new Set([src]) });
light(off, 1);
showNote(midi);
startScope();
setStatus();
}
function release(off, src) {
var h = held.get(off);
if (!h) return;
h.srcs.delete(src);
if (h.srcs.size) return;
held.delete(off);
if (h.v && ctx) stopVoice(h.v, ctx.currentTime);
light(off, -1);
setStatus();
}
function releaseAll() {
Array.from(held.keys()).forEach(function (off) {
var h = held.get(off);
held.delete(off);
if (h.v && ctx) stopVoice(h.v, ctx.currentTime);
light(off, -1);
});
setStatus();
}
/* a one-shot note, for Enter/Space on a focused key */
function blip(off) {
var ok = ensureAudio();
var midi = baseMidi() + off;
if (ok && ctx) {
var v = startVoice(midi, ctx.currentTime);
stopVoice(v, ctx.currentTime + 0.3);
}
light(off, 1);
window.setTimeout(function () { light(off, -1); setStatus(); }, 300);
showNote(midi);
startScope();
setStatus();
}
/* ── readouts ───────────────────────────────────────────────────────────── */
var roNote = document.getElementById("roNote"),
roHz = document.getElementById("roHz"),
roV = document.getElementById("roV"),
statusEl = document.getElementById("status"),
statusTxt = document.getElementById("statusTxt"),
live = document.getElementById("live");
function showNote(midi) {
roNote.textContent = plainOf(midi);
roHz.textContent = hzOf(midi).toFixed(2);
}
function paintVoices() { roV.textContent = String(Math.max(0, voiceCount)); }
function setStatus() {
var on = !!ctx && ctx.state === "running";
var sounding = held.size > 0 || voiceCount > 0 || riffOn;
statusEl.classList.toggle("live", on);
statusEl.classList.toggle("blink", on && sounding);
statusTxt.textContent = !on ? (audioDead ? "NO AUDIO" : "TAP TO START")
: (sounding ? "SOUNDING" : "AUDIO ON");
}
/* ── build the keyboard ─────────────────────────────────────────────────── */
var kb = document.getElementById("kb");
function buildKeys() {
kb.textContent = "";
keyEls = [];
var whites = 0, plan = [];
for (var i = 0; i < SPAN; i++) {
if (BLACK[i % 12]) plan.push({ i: i, black: true, after: whites - 1 });
else { plan.push({ i: i, black: false, w: whites }); whites++; }
}
var u = 100 / whites, bw = u * 0.62;
/* appended in pitch order, not layout order: the short keys are absolutely
positioned so they are out of the flex flow anyway, which means the DOM
can read C, C♯, D … the way a reader expects */
plan.forEach(function (p) {
var el = makeKey(p);
if (p.black) {
el.style.width = bw + "%";
el.style.left = ((p.after + 1) * u - bw / 2) + "%";
} else if (p.w === whites - 1) {
el.classList.add("lastw");
}
kb.appendChild(el);
});
keyEls.forEach(function (el, i) { el.tabIndex = i === 0 ? 0 : -1; });
relabel();
}
function makeKey(p) {
var el = document.createElement("button");
el.type = "button";
el.className = "k " + (p.black ? "bk" : "wk");
el.dataset.off = String(p.i);
var nm = document.createElement("span"); nm.className = "nm";
var lt = document.createElement("span"); lt.className = "ltr";
if (!p.black) el.appendChild(nm);
el.appendChild(lt);
el._nm = nm; el._lt = lt;
keyEls[p.i] = el;
return el;
}
function relabel() {
var b = baseMidi();
keyEls.forEach(function (el, i) {
var midi = b + i, black = !!BLACK[i % 12];
var letter = i < LETTERS.length ? LETTERS[i] : "";
el._lt.textContent = letter;
el._lt.style.display = letter ? "" : "none";
if (!black) {
el._nm.textContent = nameOf(midi);
el._nm.className = "nm" + (midi % 12 === 0 ? "" : " opt");
}
el.setAttribute("aria-label", plainOf(midi) + (letter ? ", key " + letter : ""));
});
document.getElementById("octV").textContent = nameOf(b) + "–" + nameOf(b + SPAN - 1);
document.getElementById("octDn").disabled = oct <= 1;
document.getElementById("octUp").disabled = oct >= 6;
}
function setOct(n) {
n = Math.max(1, Math.min(6, n));
if (n === oct) return;
stopRiff();
releaseAll();
oct = n;
relabel();
if (!held.size) showNote(baseMidi());
live.textContent = "Octave " + nameOf(baseMidi()) + " to " + nameOf(baseMidi() + SPAN - 1);
}
buildKeys();
document.getElementById("octDn").addEventListener("click", function () { setOct(oct - 1); });
document.getElementById("octUp").addEventListener("click", function () { setOct(oct + 1); });
/* ── pointer play ───────────────────────────────────────────────────────── */
var pointers = new Map(); /* pointerId -> offset */
function offAt(x, y) {
var el = document.elementFromPoint(x, y);
if (!el) return -1;
var k = el.closest ? el.closest(".k") : null;
if (!k || !kb.contains(k)) return -1;
return +k.dataset.off;
}
kb.addEventListener("pointerdown", function (e) {
var k = e.target.closest ? e.target.closest(".k") : null;
if (!k) return;
e.preventDefault(); /* no focus ring on a mouse press, no page scroll on touch */
var off = +k.dataset.off, id = "p" + e.pointerId;
/* capture on the whole keyboard, not the key: without this a drag that ends
outside the page never delivers pointerup and the note sustains forever */
try { if (kb.setPointerCapture) kb.setPointerCapture(e.pointerId); } catch (err) {}
pointers.set(e.pointerId, off);
keyEls.forEach(function (el, i) { el.tabIndex = i === off ? 0 : -1; });
press(off, id);
});
window.addEventListener("pointermove", function (e) {
if (!pointers.has(e.pointerId)) return;
var prev = pointers.get(e.pointerId);
var next = offAt(e.clientX, e.clientY);
if (next === prev) return;
var id = "p" + e.pointerId;
if (prev >= 0) release(prev, id);
if (next >= 0) press(next, id);
pointers.set(e.pointerId, next);
}, { passive: true });
function endPointer(e) {
if (!pointers.has(e.pointerId)) return;
var prev = pointers.get(e.pointerId);
pointers.delete(e.pointerId);
if (prev >= 0) release(prev, "p" + e.pointerId);
}
window.addEventListener("pointerup", endPointer);
window.addEventListener("pointercancel", endPointer);
/* arrow-key travel across the keys — the whole row is one tab stop.
Space/Enter are handled here rather than via click, so a touch tap can
never be mistaken for a keyboard activation and fire the note twice. */
kb.addEventListener("keydown", function (e) {
var k = e.target.closest ? e.target.closest(".k") : null;
if (!k) return;
var off = +k.dataset.off, next = null;
if (e.key === " " || e.key === "Spacebar" || e.key === "Enter") {
e.preventDefault();
if (!e.repeat) blip(off);
return;
}
if (e.key === "ArrowRight") next = Math.min(SPAN - 1, off + 1);
else if (e.key === "ArrowLeft") next = Math.max(0, off - 1);
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = SPAN - 1;
if (next === null) return;
e.preventDefault();
keyEls.forEach(function (el, i) { el.tabIndex = i === next ? 0 : -1; });
keyEls[next].focus();
});
/* ── typing ─────────────────────────────────────────────────────────────── */
function typedOff(e) {
var k = (e.key || "").toUpperCase();
return Object.prototype.hasOwnProperty.call(KEYMAP, k) ? KEYMAP[k] : -1;
}
window.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && (t.tagName === "TEXTAREA" || (t.tagName === "INPUT" && t.type !== "range" && t.type !== "radio"))) return;
var k = (e.key || "").toUpperCase();
if (k === "Z" || k === "X") { e.preventDefault(); setOct(oct + (k === "X" ? 1 : -1)); return; }
var off = typedOff(e);
if (off < 0) return;
e.preventDefault();
if (e.repeat) return;
press(off, "kbd");
});
window.addEventListener("keyup", function (e) {
var off = typedOff(e);
if (off >= 0) release(off, "kbd");
});
/* losing focus (or the tab going away) swallows the keyup AND the pointerup,
so drop every held note rather than only the typed ones */
function panic() {
pointers.clear();
releaseAll();
}
window.addEventListener("blur", panic);
document.addEventListener("visibilitychange", function () {
if (document.hidden) panic();
});
/* ── controls ───────────────────────────────────────────────────────────── */
var WAVES = [
{ v: "sine", label: "sine", d: "M1 7 C 3.6 0.4, 6.4 0.4, 9 7 S 14.4 13.6, 17 7" },
{ v: "triangle", label: "tri", poly: "1,12 5,2 9,12 13,2 17,12" },
{ v: "sawtooth", label: "saw", poly: "1,12 6,2 6,12 11,2 11,12 16,2 16,12" },
{ v: "square", label: "square", poly: "1,12 1,2 5,2 5,12 9,12 9,2 13,2 13,12 17,12 17,2" }
];
var waveGrp = document.getElementById("waveGrp");
WAVES.forEach(function (w) {
var inp = document.createElement("input");
inp.type = "radio"; inp.name = "wave"; inp.id = "w-" + w.v; inp.value = w.v;
var lab = document.createElement("label");
lab.htmlFor = inp.id;
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", "18"); svg.setAttribute("height", "14");
svg.setAttribute("viewBox", "0 0 18 14"); svg.setAttribute("aria-hidden", "true");
var sh;
if (w.poly) { sh = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); sh.setAttribute("points", w.poly); }
else { sh = document.createElementNS("http://www.w3.org/2000/svg", "path"); sh.setAttribute("d", w.d); }
svg.appendChild(sh);
lab.appendChild(svg);
lab.appendChild(document.createTextNode(w.label));
inp.addEventListener("change", function () {
if (!inp.checked) return;
P.wave = w.v;
held.forEach(function (h) { if (h.v) { h.v.o1.type = w.v; h.v.o2.type = w.v; } });
markPreset(null);
drawScope();
});
waveGrp.appendChild(inp); waveGrp.appendChild(lab);
});
var atk = document.getElementById("atk"), atkV = document.getElementById("atkV"),
rel = document.getElementById("rel"), relV = document.getElementById("relV"),
cut = document.getElementById("cut"), cutV = document.getElementById("cutV"),
dt = document.getElementById("dt"), dtV = document.getElementById("dtV"),
df = document.getElementById("df"), dfV = document.getElementById("dfV"),
dlyBtn = document.getElementById("dlyBtn"),
dtKnob = document.getElementById("dtKnob"), dfKnob = document.getElementById("dfKnob");
function fmtMs(s) {
var ms = s * 1000;
return ms >= 1000 ? (ms / 1000).toFixed(2) + " s" : Math.round(ms) + " ms";
}
function fmtHz(h) { return h >= 1000 ? (h / 1000).toFixed(1) + " kHz" : Math.round(h) + " Hz"; }
function setAtk(v, fromUser) { P.atk = v; atkV.textContent = fmtMs(v); if (fromUser) markPreset(null); }
function setRel(v, fromUser) { P.rel = v; relV.textContent = fmtMs(v); if (fromUser) markPreset(null); }
function setCut(v, fromUser) {
P.cut = v; cutV.textContent = fmtHz(v);
if (filt) filt.frequency.setTargetAtTime(v, ctx.currentTime, 0.01);
if (fromUser) markPreset(null);
}
function setDt(v, fromUser) {
P.dtime = v; dtV.textContent = fmtMs(v);
if (dl) dl.delayTime.setTargetAtTime(v, ctx.currentTime, 0.05);
if (fromUser) markPreset(null);
}
function setDf(v, fromUser) {
P.dfb = v; dfV.textContent = Math.round(v * 100) + " %";
if (fb) fb.gain.setTargetAtTime(v, ctx.currentTime, 0.02);
if (fromUser) markPreset(null);
}
function readAtk(u) { setAtk(expo(+atk.value, 0.002, 1.6), u); }
function readRel(u) { setRel(expo(+rel.value, 0.04, 3.2), u); }
function readCut(u) { setCut(expo(+cut.value, 200, 12000), u); }
function readDt(u) { setDt(expo(+dt.value, 0.04, 0.7), u); }
function readDf(u) { setDf((+df.value) / 100, u); }
function paintDelay() {
dlyBtn.setAttribute("aria-pressed", P.delay ? "true" : "false");
dtKnob.classList.toggle("off", !P.delay);
dfKnob.classList.toggle("off", !P.delay);
dt.disabled = !P.delay; df.disabled = !P.delay;
if (wet) wet.gain.setTargetAtTime(P.delay ? 0.42 : 0, ctx.currentTime, 0.03);
}
atk.addEventListener("input", function () { readAtk(true); });
rel.addEventListener("input", function () { readRel(true); });
cut.addEventListener("input", function () { readCut(true); });
dt.addEventListener("input", function () { readDt(true); });
df.addEventListener("input", function () { readDf(true); });
dlyBtn.addEventListener("click", function () { P.delay = !P.delay; paintDelay(); markPreset(null); });
/* ── patches ────────────────────────────────────────────────────────────── */
var PATCHES = {
"warm keys": { wave: "triangle", atk: 0.008, rel: 0.42, cut: 3200, delay: true, dtime: 0.26, dfb: 0.28 },
"bell": { wave: "sine", atk: 0.003, rel: 1.60, cut: 9000, delay: true, dtime: 0.34, dfb: 0.44 },
"slow pad": { wave: "sawtooth", atk: 0.62, rel: 1.50, cut: 1400, delay: true, dtime: 0.45, dfb: 0.50 },
"buzzy": { wave: "square", atk: 0.004, rel: 0.16, cut: 2400, delay: false, dtime: 0.16, dfb: 0.18 }
};
var presetsEl = document.getElementById("presets"), presetBtns = {};
Object.keys(PATCHES).forEach(function (name) {
var b = document.createElement("button");
b.type = "button"; b.className = "chip"; b.textContent = name;
b.setAttribute("aria-pressed", "false");
b.addEventListener("click", function () { applyPatch(name); });
presetsEl.appendChild(b);
presetBtns[name] = b;
});
function inv(x, lo, hi) { return Math.round(1000 * Math.log(x / lo) / Math.log(hi / lo)); }
function applyPatch(name) {
var p = PATCHES[name];
if (!p) return;
var w = document.getElementById("w-" + p.wave); if (w) w.checked = true;
P.wave = p.wave;
held.forEach(function (h) { if (h.v) { h.v.o1.type = p.wave; h.v.o2.type = p.wave; } });
atk.value = inv(p.atk, 0.002, 1.6); setAtk(p.atk, false);
rel.value = inv(p.rel, 0.04, 3.2); setRel(p.rel, false);
cut.value = inv(p.cut, 200, 12000); setCut(p.cut, false);
dt.value = inv(p.dtime, 0.04, 0.7); setDt(p.dtime, false);
df.value = Math.round(p.dfb * 100); setDf(p.dfb, false);
P.delay = p.delay; paintDelay();
markPreset(name);
drawScope();
live.textContent = "Patch " + name;
}
function markPreset(name) {
Object.keys(presetBtns).forEach(function (n) {
presetBtns[n].setAttribute("aria-pressed", n === name ? "true" : "false");
});
}
/* ── the riff ───────────────────────────────────────────────────────────── */
var BARS = [
[0, 7, 12, 16, 19, 16, 12, 7],
[9, 12, 16, 21, 16, 12, 9, 4],
[5, 9, 12, 17, 21, 17, 12, 9],
[7, 11, 14, 19, 23, 19, 14, 11]
];
var STEP = 0.215;
var RIFF = [];
BARS.forEach(function (bar, bi) {
bar.forEach(function (o, si) {
RIFF.push({ o: o, t: (bi * 8 + si) * STEP, d: STEP * 1.65 });
});
});
[0, 7, 12, 16].forEach(function (o) { RIFF.push({ o: o, t: 32 * STEP, d: 2.1 }); });
var RIFF_END = 32 * STEP + 2.1;
var riffBtn = document.getElementById("riffBtn"),
riffTxt = document.getElementById("riffTxt"),
riffGl = document.getElementById("riffGl");
var riffOn = false, riffVoices = [], riffTimers = [];
var riffLit = new Array(SPAN).fill(0); /* lights this riff owns, so stopping
it never darkens a key you are holding */
function stopRiff() {
if (!riffOn) return;
riffOn = false;
riffTimers.forEach(clearTimeout); riffTimers = [];
if (ctx) riffVoices.forEach(function (v) { killVoice(v, ctx.currentTime); });
riffVoices = [];
for (var i = 0; i < SPAN; i++) {
while (riffLit[i] > 0) { riffLit[i]--; light(i, -1); }
}
riffBtn.classList.remove("on");
riffTxt.textContent = "Play riff";
riffGl.innerHTML = "▶";
setStatus();
}
function playRiff() {
if (riffOn) { stopRiff(); return; }
ensureAudio(); /* with no Web Audio at all the riff still runs as a light show */
riffOn = true;
riffBtn.classList.add("on");
riffTxt.textContent = "Stop";
riffGl.innerHTML = "■";
setStatus();
startScope();
/* wait for the context to actually be running, or every note would be
scheduled against a clock that has not started ticking yet */
if (ctx && ctx.state !== "running" && ctx.resume) ctx.resume().then(schedRiff, schedRiff);
else schedRiff();
}
function schedRiff() {
if (!riffOn) return;
var t0 = ctx ? ctx.currentTime + 0.09 : 0;
var b = baseMidi();
RIFF.forEach(function (n) {
var v = ctx ? startVoice(b + n.o, t0 + n.t) : null;
if (v) { stopVoice(v, t0 + n.t + n.d); riffVoices.push(v); }
riffTimers.push(window.setTimeout(function () {
riffLit[n.o]++; light(n.o, 1); showNote(b + n.o); setStatus();
}, n.t * 1000 + 90));
riffTimers.push(window.setTimeout(function () {
if (riffLit[n.o] > 0) { riffLit[n.o]--; light(n.o, -1); }
setStatus();
}, (n.t + n.d) * 1000 + 90));
});
riffTimers.push(window.setTimeout(stopRiff, (RIFF_END + P.rel) * 1000 + 250));
}
riffBtn.addEventListener("click", playRiff);
/* ── scope ──────────────────────────────────────────────────────────────── */
var cv = document.getElementById("scope"), cx = cv.getContext("2d");
var cw = 0, ch = 0, dpr = 1, themeDirty = true, colors = {};
var rafId = 0, quietSince = 0;
function readColors() {
var cs = getComputedStyle(root);
colors = {
accent: cs.getPropertyValue("--accent").trim() || "#C05600",
muted: cs.getPropertyValue("--muted").trim() || "#5A6068",
line: cs.getPropertyValue("--line").trim() || "#E3E2DC"
};
themeDirty = false;
}
function sizeCanvas() {
var r = cv.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
cw = Math.max(1, Math.round(r.width)); ch = Math.max(1, Math.round(r.height));
cv.width = Math.round(cw * dpr); cv.height = Math.round(ch * dpr);
cx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawScope();
}
function shapeAt(x) { /* one period of the selected wave, x in 0..1 */
switch (P.wave) {
case "square": return Math.sin(2 * Math.PI * x) >= 0 ? 1 : -1;
case "sawtooth": return 2 * (x - Math.floor(x + 0.5));
case "triangle": return 2 / Math.PI * Math.asin(Math.sin(2 * Math.PI * x));
default: return Math.sin(2 * Math.PI * x);
}
}
function drawScope() {
if (themeDirty) readColors();
if (!cw) return;
cx.clearRect(0, 0, cw, ch);
var mid = ch / 2;
cx.strokeStyle = colors.line; cx.lineWidth = 1;
cx.setLineDash([2, 4]);
cx.beginPath(); cx.moveTo(0, mid + 0.5); cx.lineTo(cw, mid + 0.5); cx.stroke();
cx.setLineDash([]);
var amp = ch * 0.36, i, x, y, level = 0;
if (analyser && scopeBuf) {
analyser.getByteTimeDomainData(scopeBuf);
var n = scopeBuf.length, sum = 0;
for (i = 0; i < n; i++) { var d = (scopeBuf[i] - 128) / 128; sum += d * d; }
level = Math.sqrt(sum / n);
}
cx.lineWidth = 1.7; cx.lineJoin = "round"; cx.lineCap = "round";
if (level > 0.0025) {
/* live output — trigger on a rising zero crossing so the trace holds still */
var buf = scopeBuf, len = buf.length, start = 0;
for (i = 1; i < len / 2; i++) {
if (buf[i - 1] < 128 && buf[i] >= 128) { start = i; break; }
}
var take = Math.min(len - start, Math.max(256, Math.floor(len / 2)));
var gain = Math.min(2.4, 0.5 / Math.max(0.04, level));
cx.strokeStyle = colors.accent;
cx.beginPath();
for (i = 0; i < take; i++) {
x = (i / (take - 1)) * cw;
y = mid - ((buf[start + i] - 128) / 128) * amp * gain;
y = Math.max(1, Math.min(ch - 1, y));
i ? cx.lineTo(x, y) : cx.moveTo(x, y);
}
cx.stroke();
quietSince = 0;
} else {
/* silent — show the shape of the wave that is armed, so the selector
means something before a single key is pressed */
cx.strokeStyle = colors.muted;
cx.globalAlpha = 0.55;
var cycles = 2.5, steps = Math.max(120, Math.round(cw));
cx.beginPath();
for (i = 0; i <= steps; i++) {
x = (i / steps) * cw;
y = mid - shapeAt((i / steps) * cycles) * amp * 0.8;
i ? cx.lineTo(x, y) : cx.moveTo(x, y);
}
cx.stroke();
cx.globalAlpha = 1;
}
return level;
}
function scopeFrame() {
var level = drawScope();
var sounding = held.size > 0 || riffOn || voiceCount > 0;
if (!sounding && (level || 0) < 0.0025) {
if (!quietSince) quietSince = Date.now();
if (Date.now() - quietSince > 900) { rafId = 0; drawScope(); return; }
} else quietSince = 0;
if (reduce) rafId = window.setTimeout(scopeFrame, 100);
else rafId = requestAnimationFrame(scopeFrame);
}
function startScope() {
if (rafId) return;
quietSince = 0;
rafId = reduce ? window.setTimeout(scopeFrame, 100) : requestAnimationFrame(scopeFrame);
}
if (window.ResizeObserver) new ResizeObserver(sizeCanvas).observe(cv);
window.addEventListener("resize", sizeCanvas);
if (window.MutationObserver) {
new MutationObserver(function () { themeDirty = true; drawScope(); })
.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
}
/* ── boot: arrive pre-loaded and ready to be pressed ─────────────────────── */
if (audioDead) {
document.getElementById("audioNote").hidden = false;
riffBtn.classList.remove("nudge"); /* nothing to wake up, so stop nagging */
}
applyPatch("warm keys");
showNote(baseMidi());
paintVoices();
setStatus();
sizeCanvas();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.