From ae228af872e633f9bd056e6a1cf5245744e980ca Mon Sep 17 00:00:00 2001 From: spacedevin Date: Tue, 1 Sep 2026 12:14:59 -0700 Subject: [PATCH 1/3] 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/3] 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 From edaf9c8ba4984517229c8409a0dd9ee62a3f4959 Mon Sep 17 00:00:00 2001 From: Space Devin <27974+spacedevin@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:52:41 -0700 Subject: [PATCH 3/3] docs: catch the docs up to three packages, and open the door to contributors (#30) The last three merges shipped @spacedevin/deck-synths, made the player play the whole 33-voice catalog, added the WAV renderer and lit the code as it plays. The docs still described the two-package world: the player README said most voices fall back to a plain oscillator, its AGENTS.md said it still carried its own generators, and the synths package was not on the site or in llms.txt at all. - site/build.mjs: Synths and Contributing sections, so llms.txt and llms-full.txt pick them up; blurb says three packages - README: community-facing rewrite with badges, a packages table, a quick start (element, code, parse, WAV), and a Contributing section - player README / AGENTS: the catalog is deck-synths, nothing is substituted, code lighting is documented, the cleanup contract is stated as the code has it - synths README gains the voices table and a new AGENTS.md - manifests: ./rendering export, homepage, bugs, keywords, sharper descriptions; versions and peer ranges untouched - new CONTRIBUTING.md, issue templates (bug, feature, new voice) and a PR template --- .github/ISSUE_TEMPLATE/bug_report.md | 35 +++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 26 ++++ .github/ISSUE_TEMPLATE/new_voice.md | 24 +++ .github/PULL_REQUEST_TEMPLATE.md | 20 +++ AGENTS.md | 19 ++- CONTRIBUTING.md | 125 ++++++++++++++++ README.md | 171 ++++++++++++++-------- examples/README.md | 6 +- package.json | 16 +- packages/player/AGENTS.md | 18 +-- packages/player/README.md | 34 +++-- packages/player/package.json | 6 +- packages/synths/AGENTS.md | 40 +++++ packages/synths/README.md | 25 ++++ packages/synths/package.json | 23 ++- site/build.mjs | 30 +++- 17 files changed, 534 insertions(+), 92 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/new_voice.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 packages/synths/AGENTS.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1cd8ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Something parses, plays, or renders wrong +title: '' +labels: bug +assignees: '' +--- + +**Which package** + +- [ ] `@spacedevin/deck` (parser / language) +- [ ] `@spacedevin/deck-synths` (a voice sounds wrong) +- [ ] `@spacedevin/deck-player` (transport, element, offline render) +- [ ] `deckfile` crate +- [ ] docs site / WAV CLI + +**The smallest `.deck` that shows it** + +```deck +deck 1 +bpm 120 + +track Lead id lead gen gameBoyDmg + note 60 0 1 v 100 +``` + +**What you expected** + +**What happened instead** + +Parse output, `song.errors`, console output, or a description of what you heard. + +**Environment** + +Package versions, browser or Node version, OS. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..211ee18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Docs + url: https://spacedevin.github.io/deck/ + about: The grammar, playable examples, rendering, and host integration. + - name: Discussions + url: https://github.com/spacedevin/deck/discussions + about: Questions, songs you made, ideas that aren't a bug or a feature yet. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..958de47 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request +about: Something the language, a voice, or the player should do +title: '' +labels: enhancement +assignees: '' +--- + +**What you want to write** + +Show the `.deck` you wish worked, or the API call you wish existed. + +```deck +``` + +**What it should do** + +**Which package it belongs in** + +Grammar changes go in `@spacedevin/deck` and need a conformance case. Sound goes in +`@spacedevin/deck-synths`. Sequencing, defaults, and the element go in `@spacedevin/deck-player`. +Not sure is a fine answer. + +**Anything else** + +Prior art, a host that already does it, a workaround you're using. diff --git a/.github/ISSUE_TEMPLATE/new_voice.md b/.github/ISSUE_TEMPLATE/new_voice.md new file mode 100644 index 0000000..9f134cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_voice.md @@ -0,0 +1,24 @@ +--- +name: New voice +about: Propose an instrument for the deck-synths catalog +title: 'voice: ' +labels: enhancement, synths +assignees: '' +--- + +**The voice** + +Name, generator id you'd propose, and what it sounds like — a chip, an instrument, a model. + +**Reference** + +What it should be compared against: real hardware, a recording, another synth, a paper. + +**Params** + +The `gen` keys a song would set, and sensible defaults. + +**Are you up for building it?** + +The recipe is in [CONTRIBUTING.md](../../CONTRIBUTING.md#add-a-voice): one pure function, one +registry entry, one example song. Happy to help either way. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4fc03d0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## What + + + +## Why + +## Which package + +- [ ] `@spacedevin/deck` (language) +- [ ] `@spacedevin/deck-synths` (voices) +- [ ] `@spacedevin/deck-player` (host) +- [ ] docs / site / CI only + +## Checklist + +- [ ] `npm test` passes (and `npm test -w @spacedevin/deck-player` if the player changed) +- [ ] If the parser changed: `npm run conformance:update` and I reviewed the diff +- [ ] If a voice was added: it's in `Registry.tish`, `Dispatch.tish`, the synths README table, and has a song in `docs/EXAMPLES.md` +- [ ] Docs say what the code now does diff --git a/AGENTS.md b/AGENTS.md index e8610c2..7e6d0ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,18 @@ Language-only package for the **`.deck`** patch language. ## Repo layout -This repo publishes **two** packages. The rules below are this one's. +This repo publishes **three** packages. The rules below are this one's. | Path | Package | What | |------|---------|------| | `/` (this file) | `@spacedevin/deck` | the language: tokenize, parse, registries, highlight | -| `packages/player/` | `@spacedevin/deck-player` | the host: Web Audio playback, transport, `` | +| `packages/synths/` | `@spacedevin/deck-synths` | the instrument catalog: 33 Web Audio voices + dispatch | +| `packages/player/` | `@spacedevin/deck-player` | the host: Song IR, transport, ``, offline render | The "out of scope" list below — **including audio** — is about `@spacedevin/deck`. `src/` stays -audio-free; everything that list excludes lives in `packages/player/`, which has its own -[AGENTS.md](packages/player/AGENTS.md). The player depends on this package and never the reverse. +audio-free; everything that list excludes lives in `packages/synths/` and `packages/player/`, each +with its own AGENTS.md ([synths](packages/synths/AGENTS.md), [player](packages/player/AGENTS.md)). +Dependencies point one way: player → synths → deck, never the reverse. ## In scope @@ -52,7 +54,8 @@ audio-free; everything that list excludes lives in `packages/player/`, which has `site/build.mjs` renders the markdown **already in this repo** to [spacedevin.github.io/deck](https://spacedevin.github.io/deck/) on every push to `main`. -Adding a page means **adding a `.md` file** under `docs/` or `packages/player/` — there is no route, +Adding a page means **adding a `.md` file** under `docs/`, `packages/player/` or `packages/synths/` +— there is no route, nav entry, or registration to update. The title comes from `title:` frontmatter, else a per-section override in `site/build.mjs`, else the first `#` heading, else the filename; `description:` becomes the lede. @@ -88,10 +91,14 @@ Sources are read **in place**. `docs/*.md` are package exports and ship in the t | [README.md](README.md) | Install + API map | | [docs/DECK_GRAMMAR.md](docs/DECK_GRAMMAR.md) | **Canonical** language reference | | [docs/AST.md](docs/AST.md) | What `parseProgram` / `parseTrackBody` return | -| [docs/EXAMPLES.md](docs/EXAMPLES.md) | Complete runnable songs | +| [docs/EXAMPLES.md](docs/EXAMPLES.md) | Complete runnable songs, one per voice | +| [docs/RENDERING.md](docs/RENDERING.md) | The WAV CLI and `renderDeckToBuffer()` | | [docs/DECK_EXTENSION.md](docs/DECK_EXTENSION.md) | gen_block dialect registration + common dialects | | [docs/HOST.md](docs/HOST.md) | How a host boots registries | | [examples/](examples/) | Runnable parse / boot / helper demos | +| [packages/synths/README.md](packages/synths/README.md) | The voice catalog and its contract | +| [packages/player/README.md](packages/player/README.md) | Playback API and `` | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup, conventions, how-to recipes | Host apps (e.g. Deckard) may document UI, apply clamps, ownership, and their generator id tables — not a second copy of the language. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..783fa8f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing + +Thanks for looking. This repo is small on purpose, and most useful contributions are small too: a +song, a voice, a grammar case, a doc fix. This page is the setup and the recipes. + +## Setup + +You need **Node 22+** and the [Tish](https://github.com/tishlang/tish) compiler, which comes in as a +dev dependency. Chrome or Chromium is only needed for the WAV renderer. + +```bash +git clone https://github.com/spacedevin/deck +cd deck +npm install +npm test +``` + +`npm test` builds the language package, runs the API and grammar suite with a 100% line-coverage +gate, the conformance corpus, every song in `docs/EXAMPLES.md`, and the Tish and JS smoke tests. + +The workspaces have their own tests: + +```bash +npm test -w @spacedevin/deck-player +npm run build -w @spacedevin/deck-synths +``` + +And the docs site, which is how you preview any markdown change: + +```bash +npm run site:serve # http://localhost:4321 +``` + +## How the repo is laid out + +| Path | Package | Owns | +|---|---|---| +| `/` | `@spacedevin/deck` | the language: tokenize, parse, registries, highlight. **No audio.** | +| `packages/synths/` | `@spacedevin/deck-synths` | the 33 voices and the dispatch that picks one | +| `packages/player/` | `@spacedevin/deck-player` | Song IR, defaults and clamps, transport, offline render, `` | +| `conformance/` | — | the parse contract every implementation is checked against | +| `crate/` | `deckfile` | **generated** from `src/` by `npm run build:rust`; never edit by hand | +| `site/` | — | the docs-site generator; markdown is read in place from the paths above | + +Dependencies point one way: player → synths → deck. Each package has an `AGENTS.md` saying what +belongs in it and what does not. Read the one for the package you're touching; the boundaries are +the thing this repo cares most about. + +Tish is the source language and it has gotchas: there is **no `class` syntax** (the build emits JS +that doesn't parse), and `undefined` is not a value under `tish run`. `packages/player/AGENTS.md` +explains why the custom element is plain JS for that reason. + +## Commit messages + +Releases are cut by [sem](https://github.com/tishlang/sem) from Conventional Commits, so the type +you pick decides whether a version ships: + +| Type | Effect | +|---|---| +| `feat:` | minor release | +| `fix:`, `perf:` | patch release | +| `feat!:` or a `BREAKING CHANGE:` footer | major release | +| `docs:`, `chore:`, `ci:`, `test:`, `refactor:` | no release | + +Scope with the package when it helps: `feat(synths): …`, `fix(player): …`, `docs(examples): …`. +A green `main` cuts a prerelease with all three tarballs; promoting it publishes to npm and crates.io. +The PR title becomes the squash commit, so write it as the commit. + +## Recipes + +### Add a song to the examples + +1. Add a `deck` fenced block to `docs/EXAMPLES.md` under the right heading, with a sentence saying + what it demonstrates. +2. `npm run test:examples` — every block must parse without errors and produce at least one + sounding channel. That is also the rule the site uses to decide whether to show a play button. +3. `npm run site:serve` and press play on it. + +Grammar-reference snippets with `` belong in `docs/DECK_GRAMMAR.md`; complete songs +belong in `docs/EXAMPLES.md`. + +### Add a voice + +1. Create `packages/synths/src/.tish` exporting `play(ctx, bus, t, midi, vel, durSec, ch, bendSemis)`. + Build a short-lived Web Audio subgraph, connect its last node to `bus.input`, and disconnect the + nodes once the tail has passed (the existing voices schedule that themselves; look at + `GameBoyDmg.tish` for the shape). A voice may instead return `{ stopTime, disconnects }` and let + the player prune it per step. +2. Register it: an entry in `src/Registry.tish` (id, label, description, default `generatorParams`) + and a case in `src/Dispatch.tish`. Param aliases or a `gen_block` dialect go in `src/DeckIds.tish`. +3. Seed anything random. Two renders of one song must be identical. +4. Add a song for it to `docs/EXAMPLES.md` (recipe above) and a line to the voices table in + `packages/synths/README.md`. +5. `npm test` and `npm test -w @spacedevin/deck-player`. + +### Change the grammar + +1. Change `src/deckfile/*.tish`. The parser is parse-only: no defaults, no clamping, no range checks. + Those are host policy and belong in the player. +2. Update `docs/DECK_GRAMMAR.md` — it is the canonical reference — and `docs/AST.md` if the shape changed. +3. Regenerate the corpus with `npm run conformance:update` and **review the diff**. A new case means + every profile in `conformance/profiles.json` must say where it stands. +4. `npm run test:rust` to confirm the Rust emit still agrees. +5. Keywords for highlighting live in `src/deckfile/Highlight.tish`; the site picks them up from there. + +### Add or fix a doc page + +The site is a view over the markdown already in the repo. Drop a `.md` under `docs/`, +`packages/player/` or `packages/synths/` and it appears in the nav, in `llms.txt` and in +`llms-full.txt` on the next build. There is no route to register. Don't add YAML frontmatter to a +file that ships in an npm tarball (`README.md`, `AGENTS.md`); npm renders it as a stray heading. +Use the per-section title override in `site/build.mjs` instead. + +## Pull requests + +- Keep a PR to one package where you can; the template asks which. +- Tests pass, the conformance diff is reviewed if you touched the parser, and the docs say what the + code now does. +- No CHANGELOG edits: the release notes are generated from the commits. + +## Reporting a bug + +Open an [issue](https://github.com/spacedevin/deck/issues/new/choose). The most useful bug report +is the smallest `.deck` that shows it, plus what you expected to hear or parse. If the parsers +disagree with each other, that is a conformance case waiting to be written. diff --git a/README.md b/README.md index 001dcbe..c00ed02 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ -# @spacedevin/deck +# deck -Streamable **`.deck`** patch **language** for Tish hosts (e.g. Deckard). +**A tiny text language for writing music, and the packages that play it.** + +[![npm: @spacedevin/deck](https://img.shields.io/npm/v/@spacedevin/deck?label=%40spacedevin%2Fdeck)](https://www.npmjs.com/package/@spacedevin/deck) +[![npm: @spacedevin/deck-synths](https://img.shields.io/npm/v/@spacedevin/deck-synths?label=%40spacedevin%2Fdeck-synths)](https://www.npmjs.com/package/@spacedevin/deck-synths) +[![npm: @spacedevin/deck-player](https://img.shields.io/npm/v/@spacedevin/deck-player?label=%40spacedevin%2Fdeck-player)](https://www.npmjs.com/package/@spacedevin/deck-player) +[![crates.io: deckfile](https://img.shields.io/crates/v/deckfile?label=crates.io%3A%20deckfile)](https://crates.io/crates/deckfile) +[![CI](https://github.com/spacedevin/deck/actions/workflows/ci.yml/badge.svg)](https://github.com/spacedevin/deck/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +**▶ [Hear it on the docs site](https://spacedevin.github.io/deck/)** · [Examples](docs/EXAMPLES.md) · [Grammar](docs/DECK_GRAMMAR.md) · [Contributing](CONTRIBUTING.md) ```deck deck 1 @@ -26,20 +35,57 @@ track Kick id kick gen gbaDirectSound steps x . . . x . . x x . . . x . . . ``` -That is a whole song: a tempo, three tracks, and what each one plays — melody as notes on -a beat grid, drums as a step pattern. **[Press play on it](https://spacedevin.github.io/deck/)** -— the docs site synthesises it in the browser with -[`@spacedevin/deck-player`](packages/player/), the same engine that drives the GBA build. +That is a whole song: a tempo, three tracks, and what each one plays — melody as notes on a beat +grid, drums as a step pattern. Press play on the [docs site](https://spacedevin.github.io/deck/) and +the browser synthesises it while the code lights up: the step under the playhead in each lane, and +the lines of every track sounding on it. -More in **[Examples](docs/EXAMPLES.md)**; the full surface in the -**[grammar](docs/DECK_GRAMMAR.md)**. +`.deck` is line-oriented and streamable, so it can be typed, diffed, generated, and sent over a wire +a line at a time. Times are in quarter-note beats; one bar is 4 beats or 16 sixteenth steps. The +parser is deliberately parse-only — absent optionals stay `null` and nothing is clamped — because +defaults and ranges are the host's policy. -## Install +## The packages -```bash -npm install @spacedevin/deck +| Package | What it is | Install | +|---|---|---| +| [`@spacedevin/deck`](.) | The language: tokenize, parse, format, registries, highlight classification. No audio. | `npm i @spacedevin/deck` | +| [`@spacedevin/deck-synths`](packages/synths/) | The instrument catalog: 33 Web Audio voices — Game Boy, NES, C64 SID, YM2612, SPC700, FM, drums, hard sync, bowed and plucked models, vocals. | `npm i @spacedevin/deck-synths` | +| [`@spacedevin/deck-player`](packages/player/) | The host: Song IR with defaults and clamps, a lookahead transport, offline render, and a `` element. | `npm i @spacedevin/deck-player` | + +Dependencies point one way — player → synths → deck — so the language stays audio-free and the +voices can be reused by any host. The same Tish source also emits a Rust crate, +[`deckfile`](https://crates.io/crates/deckfile), checked against the same conformance corpus as the JS build. + +## Quick start + +**Play it in a page.** No framework, no build step: + +```html + + + +deck 1 +bpm 120 +track Lead id lead gen gameBoyDmg + note 60 0 0.5 v 100 + + + +``` + +**Play it from code:** + +```js +import { createDeckPlayer } from '@spacedevin/deck-player' + +let player = createDeckPlayer() +let song = player.load(source) // returns the Song, with errors / substitutions / ignored +button.onclick = () => player.play() // an AudioContext needs a user gesture ``` +**Parse it only:** + ```tish import { parseProgram, registerGeneratorIdAliases, registerGenBlockDialect } from "@spacedevin/deck" @@ -49,15 +95,36 @@ registerGeneratorIdAliases({ matrix_fm: "matrixFm" }, { matrixFm: "matrix_fm" }) let ast = parseProgram(source) ``` -## Examples - -Runnable demos in [`examples/`](examples/) (parse, host boot, helpers): +**Render it to a WAV.** From a checkout of this repo, with Chrome or Chromium installed: ```bash -npm run examples +node scripts/render-wav.mjs song.deck -o song.wav ``` -## In scope +The voices are Web Audio, so the renderer drives a headless Chrome and an `OfflineAudioContext`. +It is deterministic and faster than real time. Details and flags in [Rendering](docs/RENDERING.md). + +## Docs + +**[spacedevin.github.io/deck](https://spacedevin.github.io/deck/)** — the same markdown, as a site, +with a play button on every complete song. + +- **[Language grammar](docs/DECK_GRAMMAR.md)** — canonical `.deck` surface +- **[Examples](docs/EXAMPLES.md)** — a complete, playable song for every one of the 33 voices +- **[Rendering](docs/RENDERING.md)** — the WAV CLI and `renderDeckToBuffer()` +- **[AST shape](docs/AST.md)** — what `parseProgram` / `parseTrackBody` return +- **[gen_block extensions](docs/DECK_EXTENSION.md)** — dialect registration + common `patch` / `matrix_fm` +- **[Host integration](docs/HOST.md)** — boot order, registries, what hosts implement +- **[Synths](packages/synths/README.md)** — the voice contract and catalog +- **[Player](packages/player/README.md)** — playback API and the element +- **[AGENTS.md](AGENTS.md)** — in/out of scope for package edits + +For LLM readers there is an [llms.txt](https://spacedevin.github.io/deck/llms.txt) and a +single-file [llms-full.txt](https://spacedevin.github.io/deck/llms-full.txt), generated from the same +pages. npm also exports `./grammar`, `./ast`, `./examples`, `./rendering`, `./extension` and `./host` +to those markdown files. + +## What the language package covers | Area | API | |------|-----| @@ -72,33 +139,19 @@ npm run examples | gen_block | `parseGenBlock`, `registerGenBlockDialect` | | Highlight | `classifyLine`, `isKeyword`, `registerHighlightKeywords` | -## Out of scope (host) - -Apply/emit to project IR · session/co-DJ · audio engines · instrument catalogs · builtin macro catalogs · HTML highlight CSS · graph editor mutators. +Out of scope for the language package, and owned by hosts: apply/emit to a project IR, sessions, +audio engines, instrument catalogs, builtin macro catalogs, highlight CSS, graph editors. -## Playback - -Hearing a `.deck` file is a host job, so it is a second package in this repo: -**[`@spacedevin/deck-player`](packages/player/)** — Web Audio chip synths, a lookahead transport, and -a `` element. +Runnable demos of the parse and host-boot API live in [`examples/`](examples/): ```bash -npm install @spacedevin/deck-player -``` - -```js -import { createDeckPlayer } from '@spacedevin/deck-player' -let player = createDeckPlayer() -player.load(source) -player.play() +npm run examples ``` -It depends on this package and never the reverse — the language stays audio-free. - ## Rust -The same `src/index.tish` also emits a Rust library crate, so a Rust consumer (tish-gba's build-time -bake) parses `.deck` with this parser rather than its own: +The same `src/index.tish` emits a Rust library crate, so a Rust consumer (tish-gba's build-time bake) +parses `.deck` with this parser rather than its own: ```bash npm run build:rust # -> crate/ (crates.io: `deckfile`) @@ -112,42 +165,44 @@ let ast = deckfile::parseProgram(value); // the raw AST, same shape as JS One source, three targets — Tish, JS, Rust — checked against one corpus. -## Docs +## Contributing -**[spacedevin.github.io/deck](https://spacedevin.github.io/deck/)** — the same markdown, as a site. +Contributions are welcome, and small ones are a fine place to start. Good first contributions: -- **[Language grammar](docs/DECK_GRAMMAR.md)** — canonical `.deck` surface -- **[Examples](docs/EXAMPLES.md)** — complete runnable songs (playable on the site) -- **[AST shape](docs/AST.md)** — what `parseProgram` / `parseTrackBody` return -- **[gen_block extensions](docs/DECK_EXTENSION.md)** — dialect registration + common `patch` / `matrix_fm` -- **[Host integration](docs/HOST.md)** — boot order, registries, what hosts implement -- **[AGENTS.md](AGENTS.md)** — in/out of scope for package edits - -npm also exports `./grammar`, `./ast`, `./examples`, `./extension` and `./host` to those markdown files. - -## Release +- **A new example** in [docs/EXAMPLES.md](docs/EXAMPLES.md) — every block there is tested and playable +- **A new voice** in [packages/synths/](packages/synths/) — one pure function, one registry entry, one example +- **A conformance case** in [conformance/](conformance/) when you find an input the parsers disagree on +- **A doc fix** — the site is built from the markdown in this repo, so a PR is the whole change -Version bumps come from [sem](https://github.com/tishlang/sem) — Conventional Commits drive semver -(`feat`/`fix`/`perf`/`BREAKING` release; `chore`/`docs`/`ci` do not). Config: [.semrc.json](.semrc.json). +[CONTRIBUTING.md](CONTRIBUTING.md) has the setup, the test commands, the commit conventions, and a +recipe for each of those. Bugs and ideas go in +[issues](https://github.com/spacedevin/deck/issues); there are templates for a bug, a feature, and a +new voice. -A green `main` cuts a **prerelease** carrying both npm tarballs. Promoting it to a full release fires -`npm-release.yml` and `crates-release.yml`, so nothing is published by the same run that decided to -publish it. - -## Test / coverage +## Development ```bash -npm test # build + API/grammar suite + conformance + tish smoke +npm install +npm test # build + API/grammar suite + conformance + examples + tish and JS smoke npm run test:coverage # c8 on dist/deck.js — 100% lines / functions / statements npm run test:conformance # the cross-implementation corpus -npm run examples # runnable demos +npm test -w @spacedevin/deck-player +npm run site:serve # the docs site on :4321 ``` **[`conformance/`](conformance/)** is the contract between implementations: the same `.deck` inputs and expected parses are run by the JS build, the Rust crate emitted from the same Tish source, and any restricted host (via a profile). It is what makes drift a test failure rather than a surprise. -Branch % is lower (~60%) because the Tish→JS emit adds many `?? null` / typeof guards that are defensive noise, not language logic. Line coverage is the gate in CI. +Branch coverage is lower (~60%) because the Tish→JS emit adds many `?? null` / typeof guards that +are defensive noise, not language logic. Line coverage is the gate in CI. + +## Releases + +Versions come from [sem](https://github.com/tishlang/sem): Conventional Commits drive semver +(`feat` / `fix` / `perf` / `BREAKING` release; `chore` / `docs` / `ci` do not). A green `main` cuts a +**prerelease** carrying all three npm tarballs; promoting it to a full release publishes to npm and +crates.io. The [Releases page](https://github.com/spacedevin/deck/releases) is the changelog. ## License diff --git a/examples/README.md b/examples/README.md index d106446..7054c5e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,10 @@ # Examples -Runnable `.tish` demos for `@spacedevin/deck`. From the package root: +Runnable `.tish` demos of the `@spacedevin/deck` **API** — parsing, host boot, helpers. Looking for +`.deck` **songs** instead? Those are in [docs/EXAMPLES.md](../docs/EXAMPLES.md), one per voice, each +playable on the site. + +From the package root: ```bash npm run examples diff --git a/package.json b/package.json index f256c6c..9fa1379 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@spacedevin/deck", "version": "0.1.0", "type": "module", - "description": ".deck language only — tokenize, parse, format, registries, highlight classify", + "description": ".deck music language — tokenize, parse, format, registries and highlight classification. One Tish source, three targets (Tish, JS, Rust)", "license": "MIT", "author": "spacedevin", "repository": { @@ -37,6 +37,7 @@ "./examples": "./docs/EXAMPLES.md", "./extension": "./docs/DECK_EXTENSION.md", "./host": "./docs/HOST.md", + "./rendering": "./docs/RENDERING.md", "./package.json": "./package.json" }, "files": [ @@ -48,6 +49,7 @@ "conformance/", "README.md", "AGENTS.md", + "CONTRIBUTING.md", "LICENSE" ], "scripts": { @@ -86,7 +88,13 @@ "tish", "parser", "patch-language", - "live-coding" + "live-coding", + "music", + "chiptune", + "web-audio", + "gameboy", + "gba", + "tracker" ], "peerDependencies": { "@tishlang/tish": ">=3.2.2" @@ -96,5 +104,9 @@ "c8": "^10.1.3", "highlight.js": "^11.11.1", "marked": "^15.0.12" + }, + "homepage": "https://spacedevin.github.io/deck/", + "bugs": { + "url": "https://github.com/spacedevin/deck/issues" } } diff --git a/packages/player/AGENTS.md b/packages/player/AGENTS.md index fd0e027..d0485e2 100644 --- a/packages/player/AGENTS.md +++ b/packages/player/AGENTS.md @@ -42,16 +42,14 @@ lockstep with the language and this package. All 33 voices live there, including (`play*(ctx, bus, t, midi, vel, durSec, ch, bendSemis)`) that connects its last node to `bus.input`. Add a voice there, not here. -This package still carries its own `gameBoyDmg`, `gbaDirectSound` and `basicOsc` under -`src/generators/`, and that is the remaining duplication to remove. They are not interchangeable -with the catalog's copies yet, for one load-bearing reason: **these return -`{ stopTime, disconnects }` and let the caller prune, while the catalog's voices self-clean with a -wall-clock `setTimeout`.** The return contract is what makes `renderDeckToBuffer` and the Node tests -work at all — a timer has no meaning inside an `OfflineAudioContext`. Consolidating means retrofitting -all 33 to the return contract first, and moving the timer policy to the host's call site. - -A generator id this package has no voice for falls back to `basicOsc` so a song still plays, and is -reported in `song.substitutions`. +Voices clean up after themselves: each schedules its own disconnects once its tail has passed. A +voice may instead return `{ stopTime, disconnects }` and let this package prune it per step +(`pruneVoices` in `src/index.tish`); that path exists for voices that must not lean on a wall-clock +timer, since an `OfflineAudioContext` has none. `src/generators/` here holds only `Registry.tish`; +there are no local voice copies left. + +The catalog falls back to `basicOsc` for a generator id it has no voice for, so a song still plays. +This package surfaces that in `song.substitutions`. `ttsVocal` and `meSpeakVocal` need the Web Speech API and a `mespeak` worker respectively, so they stay out of scope here regardless. diff --git a/packages/player/README.md b/packages/player/README.md index e9c5c35..97f315c 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -3,8 +3,9 @@ Web Audio playback for **`.deck`** — chip-tune synths, a lookahead transport, and a `` element. -[`@spacedevin/deck`](../..) parses the language. This package is the **host**: it applies the -defaults and clamps the parser deliberately leaves out, and it makes sound. +[`@spacedevin/deck`](../..) parses the language and [`@spacedevin/deck-synths`](../synths/) holds +the voices. This package is the **host**: it applies the defaults and clamps the parser deliberately +leaves out, sequences the song, and makes sound through the full 33-voice catalog. ## Install @@ -12,6 +13,8 @@ defaults and clamps the parser deliberately leaves out, and it makes sound. npm install @spacedevin/deck-player ``` +`@spacedevin/deck` and `@spacedevin/deck-synths` are peer dependencies and install alongside it. + ## Use ```js @@ -48,6 +51,15 @@ track Lead id lead gen gameBoyDmg ``` +### Code lighting + +If the source inside the element is highlighted HTML rather than plain text, the element will light +it as it plays: the step under the playhead in each `steps` lane, and every line of a track that is +sounding on that step. It looks for the attributes the site highlighter emits — `data-track` on each +line and `data-step` on each step token — so any highlighter that adds those gets the same +behaviour. The docs site is the reference: `site/highlight.mjs` emits them, `site/style.css` styles +the `dk-now` (lit step) and `dk-live` (sounding line) classes it toggles. + ## Hear it A whole song is three kinds of line: a tempo, a track, and some notes. On the docs site this block @@ -95,28 +107,32 @@ track Kick id kick gen gbaDirectSound `load()` returns the Song, including three things worth showing a user: - **`errors`** — parse errors plus host errors (a malformed `gen` line, a bad `wave` table) -- **`substitutions`** — generators that were swapped for `basicOsc` (see below) +- **`substitutions`** — generator ids the catalog has no voice for, swapped for `basicOsc` so the song still plays - **`ignored`** — language features present in the source that this package doesn't sequence yet: clips/session, `song`/`follow` arrangement, `auto` automation, `master_mix`, `@` directives ## Sound -The synths are a source-level port of [Deckard](https://deckard.lol)'s, which are themselves checked -against tish-gba's build-time bake — so a `.deck` sounds the same in a browser as it does on a GBA. -That means real hardware behaviour, not an impression of it: +The voices are [`@spacedevin/deck-synths`](../synths/) — all 33 of them, the same catalog +[Deckard](https://deckard.lol) plays through, which is itself checked against tish-gba's build-time +bake. So a `.deck` sounds the same here as it does in Deckard or on a GBA, and nothing is swapped for +a stand-in. The chip voices model real hardware behaviour, not an impression of it: - **`gameBoyDmg`** — the four duty tables in an 8-sample buffer pitched by `playbackRate`; a genuine 15/7-bit LFSR for noise; wave RAM quantized to 4 bits; the 64 Hz / 32 Hz frequency floors; the 15-step volume envelope - **`gbaDirectSound`** — a 32-sample table (so high notes alias like the real software mixer), an 8-bit DAC as a 256-step staircase, and the ~16 kHz mixing roll-off +- **`nes2a03`, `c64sid`, `ym2612`, `sn76489`, `spc700`** — and the rest of the chip family, plus + FM, drums, hard sync, bowed and plucked models. The full list is in the + [synths README](../synths/README.md); [Examples](../../docs/EXAMPLES.md) has a playable song for each - **`wave <32 hex digits>`** / **`wave harmonics …`** — named wave RAM tables, written as samples or as harmonic amplitudes; the language resolves both to the same 32 levels - **`layer`** — stem gating via `setIntensity()` -Everything else — `matrixFm`, `patch`, `nes2a03`, `c64sid`, and the rest — falls back to a plain -oscillator so a song still plays, and says so in `song.substitutions`. `ttsVocal` / `meSpeakVocal` -are out of scope: they need the Web Speech API. +Two voices reach outside the audio graph: `ttsVocal` needs the Web Speech API and `meSpeakVocal` +needs a worker the host serves. They play in a page that provides those and are silent in an +offline render. ## Notes diff --git a/packages/player/package.json b/packages/player/package.json index 52af703..d6a92f9 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -2,7 +2,7 @@ "name": "@spacedevin/deck-player", "version": "0.1.0", "type": "module", - "description": "Web Audio player for the .deck language \u2014 chip-tune synths, transport, and a element", + "description": "Web Audio player for the .deck language — the full deck-synths voice catalog, a lookahead transport, offline render, and a element", "license": "MIT", "author": "spacedevin", "repository": { @@ -74,5 +74,9 @@ "devDependencies": { "@spacedevin/deck": "file:../..", "@spacedevin/deck-synths": "file:../synths" + }, + "homepage": "https://spacedevin.github.io/deck/player/", + "bugs": { + "url": "https://github.com/spacedevin/deck/issues" } } diff --git a/packages/synths/AGENTS.md b/packages/synths/AGENTS.md new file mode 100644 index 0000000..2ad5b92 --- /dev/null +++ b/packages/synths/AGENTS.md @@ -0,0 +1,40 @@ +# @spacedevin/deck-synths + +The **instrument catalog** for `.deck` — the 33 Web Audio voices, and the dispatch that picks one. + +**Entry:** `src/index.tish` + +This package exists so that one set of voices serves every host: Deckard, `@spacedevin/deck-player`, +the docs site, the WAV renderer. The root [AGENTS.md](../../AGENTS.md) keeps audio out of the +language package; the player's [AGENTS.md](../player/AGENTS.md) keeps voice implementations out of +the player. Both of those exclusions land here. + +## In scope + +- Voices: one `src/.tish` per generator id, a pure `play(ctx, bus, t, midi, vel, durSec, ch, bendSemis)` + that builds a short-lived subgraph, connects it to `bus.input`, and disconnects its nodes once the + tail has passed. A voice may instead return `{ stopTime, disconnects }` and let the host prune it + per step, which is the path to take when a voice must not lean on a wall-clock timer +- `Registry.tish` — the catalog: id, label, description, default `generatorParams` +- `Dispatch.tish` — `dispatchPlayNote` by `ch.generatorId`, `basicOsc` fallback for unknown ids +- `DeckIds.tish` — teaching the language this catalog's ids, param aliases and gen_block dialects + (`ensureDeckGeneratorIds`), via the registries `docs/HOST.md` describes +- Shared DSP: `Duty.tish`, `AdsrAmpSchedule.tish`, `Midi.tish`, the `PatchGraph` / `MatrixFmGraph` parsers +- `SyncWorklet.tish` — the hard-sync processor from an inline Blob URL +- `BuiltinMacros.tish` — the builtin macro *contents* the language package deliberately leaves empty + +## Out of scope — do not add here + +- **Grammar.** New tokens, body heads or statements belong in `../../src/` and its conformance corpus. + Register vocabulary through the language's registries; never re-tokenize `.deck` text. +- **Sequencing.** Song IR, defaults and clamps, the transport, buses and the master chain are the + player's. A voice receives a resolved note; it does not decide when notes happen. +- **Assets to copy.** The worklet is inlined for that reason. `meSpeakVocal` is the one exception and + is documented as such. + +## Notes for editors + +- **No `class` syntax** in Tish — `tish build` emits JS that doesn't parse. Worklet processors are + written as a JS string for that reason. +- Deterministic by design: seed anything random so two renders of one song are identical. +- Two copies of `@spacedevin/deck` in one page means two dialect registries. Keep it a peer. diff --git a/packages/synths/README.md b/packages/synths/README.md index c4842a4..08896cc 100644 --- a/packages/synths/README.md +++ b/packages/synths/README.md @@ -33,6 +33,23 @@ play(ctx, bus, t, midi, vel, durSec, ch, bendSemis) `dispatchPlayNote` picks one by `ch.generatorId`; an unknown id falls back to `basicOsc`. Patch and envelope come from `ch.generatorParams` — the ADSR lives there, not on the channel root. +## The voices + +Every id below has a complete, playable song in [Examples](../../docs/EXAMPLES.md). + +| Family | Generator ids | +|---|---| +| Chip emulations | `gameBoyDmg` `gbaDirectSound` `nes2a03` `c64sid` `ym2612` `sn76489` `spc700` `chiptune` | +| FM and patches | `fmTone` `matrixFm` `patch` `tine` `bell` | +| Basic and bass | `basicOsc` `acid303` `sub808` `reeseBass` `pad` | +| Drums and hits | `drumSynth` `noiseBurst` `clap` `cymbal` | +| Hard sync | `syncLead` `syncChoir` `obSync` `laserSync` | +| Bowed, plucked, struck | `arco` `guitar` `halo` `aether` | +| Vocal | `formantVocal` `ttsVocal` `meSpeakVocal` | + +`generatorCatalog()` returns the same list with a label and description per voice, and the default +`generatorParams` each one expects. + ## Assets `ensureSyncWorklet` registers the hard-sync oscillator from an inlined Blob URL, so `syncLead`, @@ -42,6 +59,14 @@ envelope come from `ch.generatorParams` — the ADSR lives there, not on the cha `/mespeak-worker.js` and `/mespeak`; call `configureMeSpeak({ workerUrl, assetsBaseUrl })` if yours differ. `ttsVocal` needs the Web Speech API. +## Adding a voice + +One `.tish` file in `src/` exporting a `play` function with the signature above, an entry in +`src/Registry.tish` (id, label, description, default params) and `src/Dispatch.tish`, any param +aliases or gen_block dialect in `src/DeckIds.tish`, and a song in +[docs/EXAMPLES.md](../../docs/EXAMPLES.md) so the example test plays it. The full recipe is in +[CONTRIBUTING.md](../../CONTRIBUTING.md). + ## Known limits - Two hosts that each bundle their own copy of `@spacedevin/deck` end up with two dialect diff --git a/packages/synths/package.json b/packages/synths/package.json index 1f12214..450a16a 100644 --- a/packages/synths/package.json +++ b/packages/synths/package.json @@ -2,7 +2,7 @@ "name": "@spacedevin/deck-synths", "version": "0.1.0", "type": "module", - "description": "The .deck instrument catalog \u2014 chip, FM, drum, sync and vocal voices as a shared package", + "description": "The .deck instrument catalog — 33 Web Audio voices: chip emulations, FM, drums, hard sync, bowed and plucked models, vocals", "license": "MIT", "exports": { ".": { @@ -18,6 +18,7 @@ "src/", "dist/", "README.md", + "AGENTS.md", "LICENSE" ], "scripts": { @@ -50,5 +51,23 @@ }, "devDependencies": { "@spacedevin/deck": "file:../.." - } + }, + "homepage": "https://spacedevin.github.io/deck/synths/", + "bugs": { + "url": "https://github.com/spacedevin/deck/issues" + }, + "keywords": [ + "deck", + "tish", + "web-audio", + "chiptune", + "synth", + "fm", + "gameboy", + "gba", + "nes", + "c64", + "sid", + "ym2612" + ] } diff --git a/site/build.mjs b/site/build.mjs index c9e6192..fbd0b3c 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -61,10 +61,32 @@ const SECTIONS = [ // so frontmatter would show up as a stray rule and a giant "description:" heading on npmjs.com. titles: { 'README.md': 'Playback', 'AGENTS.md': 'Player scope' }, descriptions: { - 'README.md': 'Web Audio playback for .deck — chip-tune synths, a transport, and an element.', + 'README.md': 'Web Audio playback for .deck — the full voice catalog, a transport, and an element.', 'AGENTS.md': 'What belongs in the playback package, and what has to stay upstream.', }, }, + { + label: 'Synths', + dir: 'packages/synths', + slug: 'synths', + order: ['README.md', 'AGENTS.md'], + ignore: ['node_modules', 'dist', 'test'], + titles: { 'README.md': 'Synths', 'AGENTS.md': 'Synths scope' }, + descriptions: { + 'README.md': 'The 33-voice instrument catalog — chip emulations, FM, drums, sync, bowed and plucked models, vocals.', + 'AGENTS.md': 'What belongs in the catalog package: voices and dispatch, never grammar or transport.', + }, + }, + { + label: 'Contributing', + dir: '.', + slug: '', + only: ['CONTRIBUTING.md'], + titles: { 'CONTRIBUTING.md': 'Contributing' }, + descriptions: { + 'CONTRIBUTING.md': 'Dev setup, tests, commit conventions, and how to add a voice, a grammar case, or a doc page.', + }, + }, ] // ── helpers ─────────────────────────────────────────────────────────────────── @@ -370,8 +392,10 @@ function writeLlms (pages) { const index = [ `# ${SITE.title}`, '', - `> ${SITE.tagline}. Two packages: \`@spacedevin/deck\` parses the language, ` + - '`@spacedevin/deck-player` plays it through Web Audio.', + `> ${SITE.tagline}. Three packages: \`@spacedevin/deck\` parses the language, ` + + '`@spacedevin/deck-synths` is the 33-voice instrument catalog, and `@spacedevin/deck-player` ' + + 'sequences a song through those voices with Web Audio — in a page via ``, or ' + + 'offline to a WAV from the command line.', '', 'Line-oriented, streamable patch text. Times are in quarter-note beats; one bar = 4 beats = 16', 'sixteenth steps. The parser is deliberately parse-only — absent optionals stay `null` and no',