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/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 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/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/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', 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; }