From ae228af872e633f9bd056e6a1cf5245744e980ca Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 1 Sep 2026 12:14:59 -0700 Subject: [PATCH 1/2] feat(player): light the code as it plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The element animated a progress bar and nothing else. Deckard and the promo videos light the step under the playhead and the instrument that is sounding, which is most of what makes a step sequencer readable, and the site had none of it. The highlighter now emits the positions it already knew about: data-step on each step in a lane, and data-track on every line, counting track headers in source order — the order parseSong builds channels in, so a trigger's bus maps straight back to the lines that describe it. finds the highlighted block beside it and, on each step, marks the step under the playhead in every lane and tints the lines of each track sounding on it. Lanes are modulo their own length, so a 16-step and a 32-step lane sit at different places in their own patterns, which is what the sequencer does with them. Both classes come off on pause, so a block at rest reads exactly as before, and a with no code beside it finds nothing and lights nothing. The element owns the audio and the page owns the markup; they meet only at those two attributes. --- .../player/element/deck-player-element.js | 59 ++++++++++++++++++- site/highlight.mjs | 20 ++++++- site/style.css | 14 +++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/packages/player/element/deck-player-element.js b/packages/player/element/deck-player-element.js index fd69275..ddd3e09 100644 --- a/packages/player/element/deck-player-element.js +++ b/packages/player/element/deck-player-element.js @@ -19,7 +19,7 @@ // the declaration as an identifier expression and emits JS that doesn't even load. Everything with // actual behaviour lives in the Tish source; this is the DOM shell around it. -import { createDeckPlayer } from '../dist/deck-player.js' +import { createDeckPlayer, stepTriggers } from '../dist/deck-player.js' const STYLES = ` :host { display: inline-flex; align-items: center; gap: 8px; font: inherit; color: inherit; @@ -164,13 +164,69 @@ export class DeckPlayerElement extends HTMLElement { if (state === 'idle') this._fill.style.width = '0%' } + /** + * The highlighted copy of this song, if the page rendered one next to us. + * + * The element owns the audio; the code block is the page's. They are matched up through the data + * attributes the highlighter emits — `data-track` on every line, `data-step` on every step in a + * lane — so neither has to know how the other is built, and a `` with no code beside + * it simply finds nothing and lights nothing. + */ + _collectCode () { + this._lines = [] + this._lanes = [] + this._lastStep = -1 + const host = this.closest('.deck-block') ?? this.parentElement + const code = host ? host.querySelector('pre') : null + if (!code) return + this._lines = Array.from(code.querySelectorAll('.dk-line[data-track]')) + for (const line of this._lines) { + const steps = Array.from(line.querySelectorAll('[data-step]')) + if (steps.length) this._lanes.push(steps) + } + } + + _clearCode () { + for (const lane of this._lanes ?? []) { + for (const s of lane) s.classList.remove('dk-now') + } + for (const line of this._lines ?? []) line.classList.remove('dk-live') + this._lastStep = -1 + } + + /** Light the step under the playhead, and the lines of every track sounding on it. */ + _paintCode (pos) { + const step = Math.floor(pos * 4) + if (step === this._lastStep) return + this._lastStep = step + + // Lanes are their own length: a 16-step lane and a 32-step lane under the same playhead are at + // different places in their own patterns, which is exactly what the sequencer does with them. + for (const lane of this._lanes) { + const at = ((step % lane.length) + lane.length) % lane.length + for (let i = 0; i < lane.length; i++) lane[i].classList.toggle('dk-now', i === at) + } + + let live = null + try { + live = new Set(stepTriggers(this._song, step).map((t) => t.busIndex)) + } catch { + return + } + for (const line of this._lines) { + line.classList.toggle('dk-live', live.has(Number(line.dataset.track))) + } + } + _startTicking () { this._stopTicking() + this._collectCode() const tick = () => { if (!this._player || !this._player.isPlaying()) return const span = this._song.totalBeats ?? this._song.loopBeats const pos = this._player.position() this._fill.style.width = `${span > 0 ? ((pos % span) / span) * 100 : 0}%` + if (this._lines.length) this._paintCode(pos) this._raf = requestAnimationFrame(tick) } this._raf = requestAnimationFrame(tick) @@ -181,6 +237,7 @@ export class DeckPlayerElement extends HTMLElement { cancelAnimationFrame(this._raf) this._raf = 0 } + this._clearCode() } } diff --git a/site/highlight.mjs b/site/highlight.mjs index 274b88e..09b097a 100644 --- a/site/highlight.mjs +++ b/site/highlight.mjs @@ -77,6 +77,7 @@ function highlightDeckLine (line, deck) { const info = deck.classifyLine(code) const stepIdx = new Set(info.stepIndices ?? []) + let stepNo = 0 // Walk runs of whitespace and non-whitespace so the original spacing survives verbatim. Collected // first because classifying a `key value` pair needs to see the NEXT token. @@ -109,6 +110,8 @@ function highlightDeckLine (line, deck) { const prev = tokens[at - 1]?.tok const first = part.index === 0 let cls = null + // Position within the lane, so a player can light the step under the playhead. + let attr = '' if (isPlaceholder(tok)) { // Grammar notation — `note ` — a slot, not a value. @@ -119,6 +122,7 @@ function highlightDeckLine (line, deck) { cls = 'dk-kw' } else if (info.kind === 'steps' && stepIdx.has(part.index)) { cls = 'dk-step' + attr = ` data-step="${stepNo++}"` } else if (deck.isInlineKeyword(tok)) { cls = 'dk-inline' } else if (deck.isNumberToken(tok)) { @@ -141,7 +145,7 @@ function highlightDeckLine (line, deck) { cls = 'dk-param' } - out += cls ? `${escapeHtml(tok)}` : escapeHtml(tok) + out += cls ? `${escapeHtml(tok)}` : escapeHtml(tok) } if (comment) out += `${escapeHtml(comment)}` @@ -149,7 +153,19 @@ function highlightDeckLine (line, deck) { } export function highlightDeck (src, deck) { - return src.split('\n').map((line) => highlightDeckLine(line, deck)).join('\n') + // Each line is tagged with the track it belongs to — a `track` header opens a new one and the + // indented lines under it inherit the number. The index counts `track` headers in source order, + // which is the order parseSong builds channels in, so a player can map a trigger's bus straight + // back to the lines that describe it and light them as they sound. + let track = -1 + return src + .split('\n') + .map((line) => { + if (/^\s*track\s/.test(line)) track += 1 + const inner = highlightDeckLine(line, deck) + return track >= 0 ? `${inner}` : inner + }) + .join('\n') } // ── entry point ─────────────────────────────────────────────────────────────── diff --git a/site/style.css b/site/style.css index 3f21e41..01f7798 100644 --- a/site/style.css +++ b/site/style.css @@ -247,6 +247,20 @@ pre code { background: none; color: inherit; padding: 0; font-size: inherit; } /* A step grid reads as rhythm, so `x` has to pop off `.` at a glance. */ .dk-step { color: var(--accent); font-weight: 700; } +/* Playback lighting. The element adds these while a block is playing: `dk-now` on the step under + the playhead in each lane, `dk-live` on the lines of every track sounding on that step. Both are + removed on pause, so a block at rest reads exactly as it did before. */ +.dk-step.dk-now { + background: var(--accent); + color: var(--code-bg); + border-radius: 2px; + box-shadow: 0 0 0 2px var(--accent-dim); +} +.dk-line.dk-live { background: var(--accent-dim); } +@media (prefers-reduced-motion: no-preference) { + .dk-line { transition: background 120ms linear; } +} + /* ── tables ──────────────────────────────────────────────────────────────── */ .table-wrap, main table { display: block; overflow-x: auto; } From 92a948a31781fac1893889d5ff53971cf80dc906 Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 1 Sep 2026 12:14:59 -0700 Subject: [PATCH 2/2] docs(examples): an example for every voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage was 22 of 33. The gaps were noiseBurst, fmTone, aether, formantVocal, chiptune, cymbal, obSync, laserSync, patch, and the two speech voices. Adds sections for percussion and metal, two-operator FM, drifting textures, sung vowels, the generic chip voice, the rest of the sync family, and a hand-wired patch graph — the last being the one voice with no fixed architecture, where the gen_block names its own oscillators, filters and envelopes. ttsVocal and meSpeakVocal are documented rather than pretended to be ordinary. Both reach outside the audio graph — the Web Speech API and the meSpeak engine's assets — so they need host support and render as silence offline. Saying so is more useful than a Play button that does nothing. The intro no longer claims the player only synthesizes three generators faithfully; it carries the whole catalogue now, and nothing on the page is substituted. 33/33 voices now have an example. Every block was checked for substitutions and for building a real graph; ten of them, covering the voices written from scratch here, were rendered to audio through scripts/render-wav.mjs to confirm they sound rather than merely parse. --- docs/EXAMPLES.md | 282 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 277 insertions(+), 5 deletions(-) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 4e0a55a..b9985cf 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -5,11 +5,16 @@ button; in a checkout, drop any of them in a `.deck` file. The grammar reference shows *syntax* (`note …`); this page shows *songs*. -Everything here uses `gameBoyDmg`, `gbaDirectSound` or `basicOsc` — the three generators this player -synthesizes faithfully, so a Play button is not an approximation. The chip examples sound the same in -a browser as they do on a GBA; `basicOsc` is the way out of the console when you want one. Other -generator ids parse fine, but the player substitutes a plain oscillator and says so in -`song.substitutions`. +Every block on this page plays. The player carries the whole voice catalogue, so nothing here is +substituted for a plain oscillator — the chip examples sound the same in a browser as they do on a +GBA, and the rest sound like themselves. + +Press play and the code follows along: the step under the playhead is lit in each lane, and the +lines of every track sounding on that step are tinted. + +Two voices are the exception. `ttsVocal` and `meSpeakVocal` reach outside the audio graph for +speech, so they need something from the host and do not appear in an offline render — see +[Speech](#speech). ## Steps @@ -652,6 +657,273 @@ track Bass id bass gen reeseBass * 2 note 39 4 1.5 v 108 ``` +### Noise and metal + +`noiseBurst` is filtered noise with an envelope — a hat, a shaker, a rim. `cymbal`'s `tune` is the +frequency of its inharmonic bank in Hz, not a note, so it sits in the hundreds. + +```deck +deck 1 +bpm 132 + +track Hat id hat gen noiseBurst * 1 + gen attack 0.002 decay 0.07 tone 0.45 pitch_follow 0.25 + mix gain 0.3 pan 0.1 + step_pitch 70 + steps x . x . | x . x . | x . x . | x . x x + +track Crash id crash gen cymbal * 4 + gen tune 320 metallic 0.85 decay 1.6 highpass 6000 + mix gain 0.22 pan -0.2 + fx reverb_send 0.4 + note 72 0 2 v 96 + +track Ride id ride gen cymbal * 1 + gen tune 480 metallic 0.6 decay 0.35 highpass 9000 + mix gain 0.16 pan 0.25 + step_pitch 76 + steps x . . x | . . x . | x . . x | . . x . + +track Kick id kick gen drumSynth * 1 + gen tone sine pitch_env 30 pitch_decay 0.035 decay 0.3 drive 0.2 + mix gain 0.5 + step_pitch 36 + steps x . . . | x . . . | x . . . | x . x . +``` + +### Two-operator FM + +`fmTone` is one modulator on one carrier. `ratio` is the modulator's frequency relative to the note +and `mod_index` is how hard it pushes — low ratios and a low index give warmth, high ones give +bells and clangs. + +```deck +deck 1 +bpm 96 + +track Keys id keys gen fmTone * 4 + gen ratio 2 mod_index 3 carrier_wave sine mod_wave sine + adsr a 0.005 d 0.5 s 5 r 0.4 + mix gain 0.32 pan -0.15 + fx reverb_send 0.3 + note 60 0 1 v 88 + note 64 0 1 v 82 + note 67 0 1 v 80 + note 58 4 1 v 88 + note 62 4 1 v 82 + note 65 4 1 v 80 + +track Clang id clang gen fmTone * 4 + gen ratio 7.03 mod_index 8 carrier_wave sine mod_wave triangle + adsr a 0.002 d 1.6 s 1 r 1.2 + mix gain 0.2 pan 0.3 + fx reverb_send 0.55 cutoff 7000 + note 84 2 2 v 70 + note 79 10 2 v 66 +``` + +### Drift + +`aether` glides between whatever it is given and swells rather than striking. Long notes and a slow +tempo are the point. + +```deck +deck 1 +bpm 64 + +track Air id air gen aether * 4 + gen glide 0.4 waver 0.5 tone 0.3 swell 0.45 air 0.25 + mix gain 0.34 pan -0.2 + fx reverb_send 0.6 + note 64 0 6 v 74 + note 67 6 6 v 70 + note 71 12 4 v 76 + +track Low id low gen aether * 4 + gen glide 0.7 waver 0.3 tone 0.15 swell 0.6 air 0.1 + mix gain 0.3 pan 0.2 + fx reverb_send 0.5 cutoff 2200 + note 45 0 8 v 66 + note 43 8 8 v 68 +``` + +### Vowels + +`formantVocal` shapes a voice with the three formants of a vowel, and takes the vowel from the +note's lyric — `l A` for *father*, `l I` for *see*, `l U` for *who*. Thirteen are defined: `I`, `IH`, +`EY`, `E`, `AE`, `A`, `O`, `OH`, `OO`, `U`, `UH`, `ER`, `UX`. + +```deck +deck 1 +bpm 84 + +track Voice id vox gen formantVocal * 4 + gen glide 0.1 vib_depth 0.02 vib_rate 5 humanize 0.5 release 0.2 + mix gain 0.36 + fx reverb_send 0.4 + note 64 0 1.5 v 88 l A + note 67 1.5 1.5 v 84 l EY + note 69 3 1 v 86 l I + note 67 4 2 v 82 l OH + note 62 6 2 v 80 l U + note 64 8 3 v 86 l A + note 60 12 4 v 78 l ER + +track Under id und gen pad * 4 + gen wave1 triangle wave2 sine detune 10 cutoff 900 + adsr a 0.8 d 0.6 s 10 r 1.6 + mix gain 0.2 pan -0.25 + fx reverb_send 0.45 + note 45 0 7.6 v 64 + note 43 8 7.6 v 66 +``` + +### One chip, generically + +`chiptune` is the console-agnostic chip voice: a pulse with adjustable width, optional PWM, an +optional arpeggio, and bitcrush and lowpass for grit. Use it when you want the character without +committing to a particular machine's quirks. + +```deck +deck 1 +bpm 150 + +track Lead id lead gen chiptune * 2 + gen waveform pulse pulse_width 0.25 pwm_speed 1.5 bitcrush 0 lowpass 0 + adsr a 0.005 d 0.25 s 7 r 0.08 + mix gain 0.4 pan -0.2 + note 72 0 0.5 v 100 + note 76 0.5 0.5 v 94 + note 79 1 1 v 98 + note 77 2 0.5 v 92 + note 74 2.5 1.5 v 96 + note 72 4 2 v 100 + note 67 6 2 v 92 + +track Arp id arp gen chiptune * 2 + gen waveform pulse pulse_width 0.5 arp_rate 16 arp_semis 12 bitcrush 6 + adsr a 0.002 d 0.1 s 6 r 0.05 + mix gain 0.26 pan 0.25 + note 48 0 4 v 84 + note 46 4 4 v 84 +``` + +### The rest of the sync family + +`syncLead` sweeps once per note. `obSync` sweeps continuously at `sweep_rate` for a slow pulsing +pad, and `laserSync` drops instead of sweeping — `drop_amt` semitones at `drop_rate`. + +```deck +deck 1 +bpm 118 + +track Sweep id ob gen obSync * 4 + gen detune 15 sweep_rate 0.5 sweep_amt 24 cutoff 1200 resonance 2 filter_env 2400 filter_decay 0.8 + adsr a 0.1 d 0.4 s 10 r 0.5 + mix gain 0.32 pan -0.2 + fx reverb_send 0.35 + note 52 0 7.6 v 84 + note 50 8 7.6 v 86 + +track Zap id zap gen laserSync * 2 + gen drop_rate 0.8 drop_amt 36 slave_base 18 + adsr a 0.01 d 0.3 s 2 r 0.2 + mix gain 0.3 pan 0.3 + note 84 1 0.5 v 104 + note 84 5 0.5 v 100 + note 88 9 0.5 v 106 + note 81 13 0.5 v 98 +``` + +### Building a voice out of parts + +`patch` has no fixed architecture. Its `gen_block` names oscillators, noise, filters, shapers and +gains, wires them with `conn`, and drives any parameter with a breakpoint `env`. It is how you write +a voice the catalog does not have. + +```deck +deck 1 +bpm 110 + +track Pluck id pl gen patch * 2 + gen_block patch + osc o1 sawtooth note + osc o2 sawtooth note detune 9 + filter f1 lowpass q 6 freq 2400 + gain a1 0 + conn o1 f1 0.6 + conn o2 f1 0.5 + conn f1 a1 1 + conn a1 out 1 + env a1.gain set 0 0 lin 0.004 0.9 exp 0.35 0.001 + env f1.frequency set 0 3800 exp 0.3 700 + end gen_block + mix gain 0.36 pan -0.1 + fx reverb_send 0.3 + note 57 0 0.5 v 100 + note 64 0.5 0.5 v 92 + note 69 1 0.5 v 96 + note 64 1.5 0.5 v 88 + note 55 4 0.5 v 100 + note 62 4.5 0.5 v 92 + note 67 5 1 v 96 + +track Hat id ph gen patch * 1 + gen_block patch + noise n + filter f highpass freq 8000 + gain a 0 + conn n f 1 + conn f a 1 + conn a out 1 + env a.gain set 0 0.25 exp 0.05 0.001 + end gen_block + mix gain 0.22 pan 0.2 + step_pitch 70 + steps x . x . | x . x . | x . x . | x . x x +``` + +### Speech + +Two voices sing words rather than vowels, and both need something from the host that the other +thirty-one do not. + +- `ttsVocal` drives the browser's own speech synthesiser through the Web Speech API. It needs a + live browser with a voice installed. +- `meSpeakVocal` uses the meSpeak engine and needs its worker and voice data served by the host. + +Because both reach outside the audio graph, neither appears in an offline render — the command-line +renderer in [Rendering to audio](RENDERING.md) will produce silence for them. They are written the +same way as `formantVocal`, with the lyric carrying a word instead of a vowel: + +```deck +deck 1 +bpm 90 + +track Words id w gen ttsVocal * 4 + gen glide 0.1 + mix gain 0.4 + note 60 0 1 v 90 l hello + note 64 1 1 v 88 l there + note 62 2 2 v 86 l friend +``` + +`meSpeakVocal` takes the same shape, and adds a `voice` naming the meSpeak voice the host has +loaded: + +```deck +deck 1 +bpm 90 + +track Chant id ms gen meSpeakVocal * 4 + gen voice en pitch 50 speed 160 + mix gain 0.4 + fx reverb_send 0.3 + note 57 0 1.5 v 92 l one + note 60 1.5 1.5 v 88 l two + note 64 3 2 v 90 l three +``` + ## Mixing and effects `mix` places a track and sets its level; `fx` shapes it. Cutoff and resonance are a filter sweep's