@hraness/dawg 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to dawg are recorded here. Versions follow [semantic versioning](https://semver.org); releases are published as immutable GitHub Releases with a tarball, `SHA256SUMS` and a build provenance attestation.
4
+
5
+ ## 0.2.0
6
+
7
+ The first tagged release. Open a session in several terminals, give each window its own instrument, let an agent write parts, and every window stays on the same song.
8
+
9
+ ### Renamed from Track to dawg
10
+
11
+ The project, CLI and package are now **dawg**: the command is `dawg`, the daemon `dawgd`, the package `@hraness/dawg` and the repository [hraness/dawg](https://github.com/hraness/dawg) (the old `hraness/track` URLs redirect). Environment variables are `DAWG_*`, workspace state lives in `.dawg/`, config in `~/.config/dawg`, and gateway keys in the Keychain service `dawg` (new keys are named `dawg-<host>`).
12
+
13
+ ### Install
14
+
15
+ ```sh
16
+ curl -fsSL https://dawg.sh/install | sh
17
+ # or
18
+ bun add -g https://github.com/hraness/dawg/releases/download/v0.2.0/hraness-dawg-0.2.0.tgz
19
+ ```
20
+
21
+ ### Added
22
+
23
+ - **Stereo renderer.** Renders and `dawg render` WAVs are now interleaved stereo with equal-power pan, a ping-pong stereo delay and a stereo reverb, and stay byte-identical across runs.
24
+ - **Reverb send.** `reverb <mix> [size]` / `reverb off` per track, a deterministic Freeverb-style network (eight damped combs and four allpasses per channel). The agent's `set_effects` tool takes `reverb {mix, size}` or `null`.
25
+ - **More automation lanes.** `automate resonance|delay-feedback|delay-mix at <beat> <value>` (and `clear <lane> automation`), validated by the planner and available to the agent's `set_automation` tool.
26
+ - **Gapless audio engine.** Playback streams a seamless loop as raw PCM into one long-lived `ffplay` (or SoX `play`) process. Edits, tempo changes and seeks swap the buffer in place without restarting playback, and the write position stays anchored to the shared transport clock. On macOS without either, `afplay` replays a re-rendered loop. `dawg auth status` and `/auth` show the backend; `DAWG_AUDIO_BACKEND` and `DAWG_AUDIO_PLAYER` override it.
27
+ - **Drums and effects (#8).** A `kit` instrument with kick, rim, snare, clap, hats and tom, written with `hit <voice> at <beat>`, `pattern <voice> <beats...>` or `pattern <voice> every <step>`. A track named `drums` gets the kit automatically. Per-track low-pass filter (`filter <hz> [res]`), tempo-synced delay (`delay <beats> [fb] [mix]`), filter automation, `solo`/`unsolo` and `redo`.
28
+ - **Streaming agent (#9).** Requests that aren't direct commands go to a streaming, tool-calling agent on Vercel AI Gateway (`opus-5.5` or `sol-6.1`, switch with `/model`). It edits only through typed, validated tools, and each accepted call becomes its own revision. Esc cancels and keeps what was accepted. Enter steers the turn in progress.
29
+ - **dawgd (#10).** One local daemon per session, started by the first window. Every window sees the same revisions, presence and transport, so play in one window plays everywhere. It recovers from crashes, and if the daemon can't start, windows fall back to the file lock. `dawg sessions` lists the sessions in a workspace.
30
+ - **New terminal UI (#11).** A highway where notes stream toward a hit line, with sustain beams, hit bursts and drum lanes. It adds an activity strip of operation cards, a themed multiline prompt with steer and queue modes, and a transcript (`Ctrl+O` or `/log`). Themes are `/theme default|high-contrast|mono`. `/motion off` or `--reduce-motion` gives static states. `NO_COLOR` is respected.
31
+ - **Login and providers (#12).** `dawg login` creates an AI Gateway key with the Vercel CLI, or takes a pasted key. `dawg login --xcb` uses a Claude, Codex or Devin subscription through xcb. `dawg auth status` and `dawg logout` manage it. Keys are kept in the macOS Keychain or a 0600 file and never in `.dawg/`.
32
+ - **Named sessions (#13).** Sessions are named automatically from what you play, and you can rename them with `/rename <name>` (or hand the name back with `/rename --auto`). `/fork [name]` branches a song (`night drive` → `night drive 2`). `/sessions` lists them and `/resume` picks one. Plain `dawg` windows claim the next open track, so three windows on a three-track song restore drums, bass and keys, and a fourth gets a draft track.
33
+ - **`dawg render <out.wav>`** writes the current session (or `--session`, or `--import file.track.json`) to a WAV without starting audio. The same score always produces the same bytes, and the sha256 is printed.
34
+ - **`/status`** shows the session name, revision, composition digest and whether the window is on dawgd or the file fallback.
35
+ - An end-to-end suite that runs real `dawg` windows in PTYs against a live dawgd, now part of `bun run check` and CI.
36
+
37
+ ### Fixed
38
+
39
+ - In demo mode, `--track drums` seeded the melodic demo notes, which played as rim hits. It now seeds a kick, snare and hat groove.
40
+
41
+ ### Not yet
42
+
43
+ - dawg is not published to npm. Install from the GitHub Release tarball until a trusted publisher is configured.
44
+
45
+ ## 0.1.0
46
+
47
+ Initial release (untagged): a local-first terminal piano roll with a shared `.track` session, direct note, tempo, instrument, volume and pan commands, volume and pan automation, undo, deterministic WAV playback through `afplay` or `ffplay`, and `track.loop/v1` import and export.
package/DAWG.md ADDED
@@ -0,0 +1,142 @@
1
+ # dawg
2
+
3
+ dawg is a local-first terminal music workstation with a Pi-like agent loop. Each terminal window can focus on one track while a shared local session keeps the score, transport, and agent operations in sync. It is designed for `dawg` to feel like a coding agent session where the artifact is a loop you can hear and edit.
4
+
5
+ The first steel thread includes a typed `track.loop/v1` score, an append-only local session with a cross-window writer lock, a terminal piano-roll projection, a multiline prompt, a bounded streaming tool-calling agent on the Vercel AI Gateway, and a local PCM synthesizer. Provider and audio adapters remain behind explicit ports so the TUI can still be exercised without credentials or a sound device.
6
+
7
+ ## Run
8
+
9
+ ```sh
10
+ bun install
11
+ bun run dawg
12
+ ```
13
+
14
+ Run `dawg` from any directory. It creates `.dawg/session` on first use and reuses that session in later terminal windows. Use `dawg --new` for a separate composition, `dawg --session <id>` to attach explicitly, and `dawg --track bass` to focus a named track. Every window connects to `dawgd`, a per-session daemon the first window starts in the background. It is the single writer: windows send operations with a base revision and an idempotency key, duplicate keys are no-ops, a stale full composition receives a typed rebase diagnostic, and a stale `operations` intent (what the agent sends) is replayed on the current score when nothing it touches changed since its base (the notes it updates or removes, the tracks it rewrites or clears, tempo and length, and ids it creates; at most 64 revisions back, with the base recovered from the event log's `before`); anything else still gets the rebase diagnostic. Rebased events record `rebasedFrom` and the score they replayed on as `before`, so undo drops only that change, and on the file fallback the agent commits the full composition with the strict base check. Accepted commits are persisted through the same atomic snapshot store before being broadcast to every window. The daemon also owns the only transport and audio player, broadcasting play, pause, seek, and tempo with a timestamp so every window draws the same hit line. It keeps a presence table (`clientId`, `pid`, focused track) and can atomically claim the first unfocused track for a new window. The daemon exits 30 seconds after its last window closes, removes its socket on SIGTERM, and a crashed daemon's socket and lock are reclaimed by the next window. If `dawgd` cannot be started (or `DAWG_DAEMON=0`), windows fall back to the snapshot under the file lock, watching the session directory with `fs.watch` (so renames and edits from other windows arrive immediately) with a 1 s backstop poll, or a 200 ms poll where watching is unavailable, with presence kept in per-window heartbeat files. `dawg sessions` lists sessions in the current workspace. `dawg render <out.wav> [--session <name|id>] [--import <file>]` reads the session record from disk (no daemon, no audio) and writes a mono 16-bit WAV through the playback renderer; the same score always yields the same bytes, and the command prints the size and sha256. `/status` reports `status · <name> · rev <n> · <digest> · daemon|file`, where the digest is the 16-hex composition digest dawgd broadcasts. In demo mode a drum track is seeded with a one-bar kick, snare and hat groove instead of melodic notes.
15
+
16
+ ### Sessions, names and forks
17
+
18
+ Each session record carries bounded metadata alongside the score: `name` (1–40 printable characters), `nameSource` (`auto` or `user`), an optional `forkOf {sessionId, revision}`, the fingerprint the current auto-name was computed from, and a `version` counter. Records written before metadata existed load with an id-based auto name. Metadata writes are conditional (`expect {name?, nameSource?}`) and never touch the score revision or event log. Through `dawgd` they are a `meta` frame that the daemon applies, persists atomically and broadcasts; on the file fallback they run under the session lock, and polling windows pick up the newer `meta.version`. `/rename <name>` is unconditional and sets `nameSource=user`, so it wins over any auto-name computed against the old name, which arrives `stale` and is dropped. `/rename --auto` sets `nameSource=auto` and clears the stored fingerprint.
19
+
20
+ `/fork [name]` writes a new session with a snapshot of the composition at the current revision (not the event log), `forkOf` lineage and a numbered name: strip a trailing ` N` (N ≥ 2) from the parent, then take one more than the highest ` N` among sessions with that base. An explicit name that collides is suffixed the same way. The workspace pointer moves to the fork, so plain `dawg` resumes it. Undo does not stop at the fork point: the fork's history is its own events preceded by each ancestor's events up to the revision it was forked at, following `forkOf` at most 8 levels (stopping at cycles, missing parents or revisions beyond the parent's log) and keeping at most the store's event cap.
21
+
22
+ On launch, and after `/fork` or `/resume`, a window without `--track` sends an atomic `claim {draft: true}`. dawgd serializes claims on its event loop; the file fallback serializes them under the presence lock. The reply is the first track in score order that no live window has focused, or, when every track is taken, a fresh `track-N` id that is neither in the score nor focused by another window. A draft is only appended to the score (`track.attach`) on the window's first edit. `--session <name|id>` resolves an exact id, then an exact case-insensitive name, then a unique id prefix of at least four characters; ambiguous names list the candidates and exit.
23
+
24
+ The header shows the session name and, when more than one window is open, the window count. Renames, forks, claims and the all-tracks-open hint appear as activity cards. The session port exposes a structured sync status (`synced`, `syncing`, `conflict`, `offline`, `local`) that drives the header directly.
25
+
26
+ Auto-naming (`src/session/naming.ts`) is gated on a local musical fingerprint: tempo, a scale-fit key estimate from the pitch-class histogram (tonic and fifth weighted), sorted instrument families, register and density buckets, and effects, hashed into an order- and id-independent digest. After an accepted turn the namer waits for a quiet period, and skips the model when the fingerprint equals the one the current name came from or when fewer than three turns have passed since the last name (a structure change, such as a new instrument family, bypasses the turn gate). The prompt is one fingerprint line, the last two prompts truncated to 60 characters each, and the current name, with `max_tokens: 12`. The reply is untrusted: it is stripped to 2–4 lowercase words of at most 32 characters, and a reply equal to the current name keeps it (hysteresis). The write is conditional on the name and `nameSource` the request started from, and a newer request supersedes an older one, so a slow provider (xcb takes about 7 s) can never overwrite a user rename. Forks keep their ` N` suffix through auto-renames, and a name already used by another session is suffixed. The generator is an injectable `NameGenerator`; the default calls `generateText` from `src/agent/provider.ts` and falls back to the deterministic local name.
27
+
28
+ The `dawgd` protocol is newline-delimited JSON over a Unix socket in the session directory, or a hashed path under `$TMPDIR` when that path would exceed the platform socket-path limit. Every frame carries `v: 1`, frames are size-bounded, and every inbound frame is parsed from `unknown`. Clients send `hello`, `apply`, `transport`, `sync`, `focus`, `claim`, and `ping`; the daemon replies with `welcome`, `result`, `snapshot`, `claimed`, `pong`, and typed `error` frames, and pushes `commit`, `transport`, and `presence`.
29
+
30
+ The header (track · session · ▶/⏸ BPM · model · rev · sync) sits above the highway. The highway sits above the activity strip and the prompt. Notes stream toward the hit line, and velocity sets glyph density (`░▒▓█`) and saturation. Beat and bar rules get stronger at each level. Sustains draw as beams with a decaying tail and a short ghost after release. Each hit runs approach glow → flash/burst at the line → fade. The hit line pulses on the beat, and a sweep marks the loop wrap. Drum tracks use one lane per voice with a legend; lane projection is pluggable (`LaneProjection` in `tui/highway.ts`). By default every unmuted track is overlaid through its own projection and accent, the focused track drawn on top at full strength and the others dimmed; `/view focus` restores the single-track view. Animation is derived from transport time, so frame rate never changes timing. Frames render into a retained cell buffer, only changed rows are written, and output is capped at about 30 fps.
31
+
32
+ Keys: `Space` (empty prompt) toggles playback. `Enter` submits, or queues in QUEUE mode. `Shift+Enter`/`Ctrl+J` inserts a newline, `Alt+Enter` queues and `Ctrl+Q` toggles STEER/QUEUE. `Ctrl+Z`/`Ctrl+Y` undo and redo, and `Ctrl+O` or `/log` opens the transcript, which scrolls with ↑/↓, PgUp/PgDn and Home/End, and `/` cycles its filter (all, requests, ops, errors). `Esc` cancels an agent turn, closes the overlay or clears the draft. `Ctrl+L` redraws and `Ctrl+C` exits. Bracketed paste keeps multiline text intact.
33
+
34
+ Themes: `/theme default|high-contrast|mono`, `--theme`, `DAWG_THEME`. Color falls back through truecolor, 256-color, 16-color and monochrome. `NO_COLOR` and `TERM=dumb` are supported. `/motion off`, `--reduce-motion` and `DAWG_REDUCE_MOTION=1` switch to static states in the same positions.
35
+
36
+ Other code reports into the activity strip through `ActivityFeed` (`tui/activity.ts`): `pushCard(text, {tone, baseRevision, resultRevision, hint})`, `pushError(text)`, `setSpinner(label | undefined)`, `setQueueDepth(n)` and `applyAgentEvent(event)`, which accepts the agent's streaming `AgentEvent`s unchanged.
37
+
38
+ The local command path understands requests such as:
39
+
40
+ ```text
41
+ add C4 at 0 for 1
42
+ play
43
+ pause
44
+ tempo 128
45
+ instrument piano
46
+ volume 0.7
47
+ pan -0.4
48
+ automate volume at 0 0.2
49
+ automate volume at 4 1
50
+ automate pan at 0 -1
51
+ automate pan at 4 1
52
+ clear pan automation
53
+ track drums
54
+ instrument kit
55
+ hit kick at 0
56
+ hit snare at 1 vel 0.7
57
+ pattern kick 0 1 2 3
58
+ pattern hat every 0.5 from 0.25
59
+ clear hat
60
+ filter 1200
61
+ filter 800 0.6
62
+ filter off
63
+ delay 0.375 0.3
64
+ delay 0.75 0.4 0.5
65
+ delay off
66
+ reverb 0.3
67
+ reverb 0.4 0.8
68
+ reverb off
69
+ automate filter at 0 400
70
+ automate filter at 4 6000
71
+ automate resonance at 0 0.2
72
+ automate delay-feedback at 0 0.6
73
+ automate delay-mix at 4 0
74
+ clear filter automation
75
+ bars 8
76
+ extend 4 bars
77
+ clear automation
78
+ mute
79
+ solo
80
+ unsolo
81
+ clear
82
+ undo
83
+ redo
84
+ move note <id> to 2.5
85
+ duration note <id> 0.25
86
+ /tracks
87
+ /status
88
+ /export loop.track.json
89
+ /import loop.track.json
90
+ /model opus-5.5
91
+ ```
92
+
93
+ Drum tracks use the `kit` instrument (a track named `drums` gets it automatically). Notes on a kit track keep the score's MIDI pitch field, using General MIDI percussion numbers (kick 36, rim 37, snare 38, clap 39, closed hat 42, tom 45, open hat 46), so drum hits round-trip through `track.loop/v1` unchanged and the highway draws them in one lane per voice. Grammar, one command per prompt, beats in score beats:
94
+
95
+ ```text
96
+ hit <voice> [at] <beat> [vel <0..1>]
97
+ pattern <voice> <beat> [<beat> ...] [vel <0..1>] up to 64 beats
98
+ pattern <voice> every <step> [from <beat>] [vel <0..1>] step >= 0.125, fills the loop, at most 256 hits
99
+ clear <voice>
100
+ filter <cutoff 20..20000> [<resonance 0..1>] | filter off
101
+ delay <beats 0.0625..4> [<feedback 0..0.9> [<mix 0..1>]] | delay off
102
+ reverb <mix 0..1> [<size 0..1>] | reverb off size defaults to 0.5
103
+ automate filter at <beat> <cutoff> | clear filter automation
104
+ automate resonance at <beat> <0..1> | clear resonance automation
105
+ automate delay-feedback at <beat> <0..0.9> | clear delay-feedback automation
106
+ automate delay-mix at <beat> <0..1> | clear delay-mix automation
107
+ solo | unsolo
108
+ undo | redo
109
+ ```
110
+
111
+ Effects live on the track as optional `filter {cutoff, resonance}`, `delay {beats, feedback, mix}`, `reverb {mix, size}`, `filterAutomation`, `resonanceAutomation`, `delayFeedbackAutomation`, `delayMixAutomation`, and `solo` fields. Effect lanes modulate an existing effect: a resonance lane needs a filter and the delay lanes need a delay. Documents written before these fields existed still parse; out-of-range or non-finite values are rejected. Undo and redo append ordinary session events, so history is shared by every window and a new edit clears the redo stack.
112
+
113
+ Unrecognized prompts go to the agent whenever a provider is configured (`DAWG_AI=0` disables it). `dawg login` creates and stores an AI Gateway key; see **Providers and auth** below. `DAWG_MODEL=opus-5.5` or `DAWG_MODEL=sol-6.1` selects the initial friendly model label, and `/model opus-5.5` or `/model sol-6.1` switches it during a session. `DAWG_OPUS_MODEL` / `DAWG_SOL_MODEL` can map those labels to the provider IDs available in the account. By default the labels map to `anthropic/claude-opus-5.5` and `openai/gpt-6.1-sol`. Both IDs were checked against `GET https://ai-gateway.vercel.sh/v1/models` and are tagged `tool-use`. Labels outside the allowlist are rejected before any request is sent.
114
+
115
+ ### Agent turns
116
+
117
+ A turn calls `POST /v1/chat/completions` with `stream: true` and one JSON-schema tool for each operation family. The OpenAI-compatible SSE stream is parsed locally with `fetch`, so the agent adds no runtime dependency. Each request sends a compact, deterministic **composition brief** instead of raw logs. It contains revision, tempo, meter, bars, key, tracks with instrument, mix, note count and pitch range, the focused track's notes, recent accepted operations and the instrument list. It is capped at 12 KiB and never includes environment values.
118
+
119
+ When a tool call finishes streaming, it passes three checks: the tool's own argument checks, the planner's bounded operation validator, and a dry run of the score reducer. Only then is it committed through the session as a separate revision pinned to the revision it was planned against. If the call fails a check, or another window committed first (stale revision), dawg rejects it without changing the score. The model receives the diagnostic as the tool result and can correct itself. A turn is bounded to 8 steps, 32 tool calls, 256 KiB of streamed response and a 90 s timeout.
120
+
121
+ `runAgentTurn` (`src/agent/agent.ts`) emits structured progress events for the TUI: `step`, `text-delta`, `tool-start`, `tool-applied` (with `summary`, `baseRevision`, `resultRevision` and `trackId`), `tool-rejected` (with `diagnostic`), and a final `done` or `error` (`aborted`, `timeout`, `budget`, `provider`). To add an operation family, append a tool to `AGENT_TOOLS` in `src/agent/tools.ts`. The schema, dispatch and validation all come from that one entry.
122
+
123
+ ### Providers and auth
124
+
125
+ `src/agent/provider.ts` picks a backend per turn: `DAWG_PROVIDER`, then the choice saved by `dawg login` in `~/.config/dawg/config.json` (no secrets), then `auto` (a gateway key, else an available xcb account, else offline with a `dawg login` hint). Gateway keys resolve from `AI_GATEWAY_API_KEY`, then the macOS Keychain (`security find-generic-password -s dawg -a ai-gateway -w`), then `~/.config/dawg/credentials.json` (0600 under a 0700 directory, written atomically through a temp file and rename). Storing uses `security -i` with the command on stdin, so the key never appears on an argv. Keys must match `[A-Za-z0-9._-]{16,256}` and are shown only masked (`vck_…abcd`).
126
+
127
+ `dawg login` (`src/auth/login.ts`) runs `vercel whoami --format json`. If you are not logged in, it hands the terminal to `vercel login`, then runs `vercel ai-gateway api-keys create --name dawg-<host> --non-interactive [--limit <dollars>]`. That command prints only the key on stdout. dawg validates the key with `GET /v1/credits` and reports valid, rejected or unverified (offline). Every subprocess goes through the injectable `CommandRunner` in `src/auth/runner.ts`, which bounds output, writes stdin and on abort sends SIGTERM (SIGKILL after 15 s) and waits for exit. Tests script `vercel`, `security` and `xcb` through it.
128
+
129
+ The xcb provider (`src/agent/xcb.ts`, `src/agent/xcb-agent.ts`) calls `xcb --json generate` with one `{version:1, account, model, prompt, timeoutMs, maxOutputBytes}` request on stdin. xcb exposes zero tools and does not stream, so the prompt carries the system rules, the composition brief and the tool catalog as JSON schemas, and asks for exactly one `{ops:[{tool,args}], say?, done}` object. The reply is untrusted. dawg takes the first balanced JSON object in at most 64 KiB, allows at most 16 ops and caps `say` at 400 characters. Each op then goes through `executeCall`, the same argument checks, operation validator, reducer dry run and per-op revision commit used by the gateway loop, and emits the same `tool-applied`/`tool-rejected`/`text-delta` events. If a reply cannot be parsed, an op is rejected or `done` is false, dawg makes another call with the per-op results, up to 3 calls and within the normal turn budgets. Esc aborts the turn, which terminates the xcb child and keeps every accepted revision. Accounts come from `xcb --json generate --capabilities`, parsed field by field from `unknown`. An account is usable only when xcb reports it `available` with at least one model, which requires xcb's application qualification. An account whose `admission` is `pending` (xcb admits it on first use) is also usable; `denied` never is, and the field may be absent. dawg never runs qualification or the xcb installer.
130
+
131
+ `generateText(prompt, {maxTokens, signal?, timeoutMs?, selection?})` from `src/agent/provider.ts` is a tool-free one-shot completion for helpers like session naming. On the gateway it uses `anthropic/claude-haiku-4.5` with `max_tokens`. On xcb it uses the selected account with `maxOutputBytes ≈ 8 × maxTokens`. It returns at most 512 trimmed characters of untrusted text and throws when offline, so callers should fall back to a local default.
132
+
133
+ Playback renders the score to interleaved stereo 16-bit PCM with deterministic sine, piano, pluck, bass, saw, square, and triangle voices and a synthesized kit whose noise comes from a PRNG seeded by each note, so every render is byte-identical. Track volume and pan automation, the low-pass filter (with cutoff and resonance lanes), the delay send (with feedback and mix lanes), and the reverb send are applied per track; mute always silences a track and any solo silences unsoloed tracks. Pan uses an equal-power law (-1 left, 1 right). The delay is a stereo ping-pong (first repeat on the panned side, later repeats alternate) and the reverb is a Freeverb-style network of eight parallel damped combs and four series allpasses per channel, with the right channel's delay lines offset for width; both use only integer delay lengths and fixed coefficients, so renders stay deterministic.
134
+
135
+ Audio engine. With `dawgd` running only the daemon plays audio; on the file-lock fallback a per-session audio lock keeps multiple TUI windows from starting duplicate voices. The engine renders one loop with every tail (release, delay, reverb) folded back onto the loop start, so the buffer repeats seamlessly, and streams it as raw s16le stereo into one long-lived player process, paced by the wall clock with about 200 ms queued. An edit renders the new loop and swaps it in at the current loop position without restarting the player; a tempo change keeps the musical beat; a seek or a drift above 30 ms re-anchors the write position to the shared transport clock, offset by the queued audio, so the transport matches what you hear. Backends, in order: `ffplay -f s16le -i -`, then SoX `play -t raw -`, then (macOS) `afplay` re-rendering a loop-folded WAV rotated to the current beat on each edit, the only backend that restarts. `DAWG_AUDIO_BACKEND=ffplay|sox|afplay|none` forces one, `DAWG_AUDIO_PLAYER="cmd {rate} {channels}"` streams into any stdin player, and `DAWG_AUDIO=0` disables sound. `dawg auth status` and `/auth` print the detected backend. The renderer is deterministic and independently testable; a native or sample-backed instrument backend can replace it behind the same player port.
136
+ Set `DAWG_AUDIO=0` for headless sessions.
137
+
138
+ Use `DAWG_DEMO=1 bun run src/main.ts` for a deterministic non-interactive frame stream while developing the renderer.
139
+
140
+ ## Release
141
+
142
+ Bump `version` in `package.json` and add its section to `CHANGELOG.md` in a pull request, then merge it. When Check passes on `main`, the annotated `v<version>` tag, the immutable GitHub Release (tarball, `SHA256SUMS` and a provenance attestation) and the npm publish of `@hraness/dawg` follow automatically. See [docs/publishing.md](./docs/publishing.md).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hraness
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,165 @@
1
- # Temporary Holding Version
1
+ # dawg
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ dawg is a local-first terminal music workstation with an agent-driven piano roll. Run `dawg` in a project directory, open it in more than one terminal window, and each window can focus on a different track in the same composition.
4
+
5
+ The highway sits above a multiline prompt. Notes stream toward a hit line, sustains stretch across beats, and transport controls stay live while you ask the agent to add or reshape music. The session is stored locally in `.dawg`, so there is no account, login, hosted session, or required service for the core workflow.
6
+
7
+ ## Install
8
+
9
+ dawg requires [Bun](https://bun.sh) 1.3.14 or newer. Install it with the script from [dawg.sh](https://dawg.sh):
10
+
11
+ ```sh
12
+ curl -fsSL https://dawg.sh/install | sh
13
+ ```
14
+
15
+ or install the release tarball from GitHub directly:
16
+
17
+ ```sh
18
+ bun add -g https://github.com/hraness/dawg/releases/download/v0.2.0/hraness-dawg-0.2.0.tgz
19
+ dawg --help
20
+ ```
21
+
22
+ Each [release](https://github.com/hraness/dawg/releases) is immutable and ships the tarball, a `SHA256SUMS` file and a build provenance attestation. To check a download before installing it:
23
+
24
+ ```sh
25
+ gh release download v0.2.0 --repo hraness/dawg
26
+ shasum -a 256 -c SHA256SUMS
27
+ gh attestation verify hraness-dawg-0.2.0.tgz --repo hraness/dawg
28
+ bun add -g "$PWD/hraness-dawg-0.2.0.tgz"
29
+ ```
30
+
31
+ dawg is not published to npm yet. To run from source instead:
32
+
33
+ ```sh
34
+ git clone https://github.com/hraness/dawg.git
35
+ cd dawg
36
+ bun install --frozen-lockfile
37
+ bun run dawg
38
+ ```
39
+
40
+ Running `dawg` creates `.dawg/session` when needed and attaches to that session on later launches. Use `dawg --new` for a new composition, `dawg --session <name|id>` to attach explicitly, or `dawg --track bass` to focus a named track.
41
+
42
+ ### Sessions
43
+
44
+ Every window you open on a session takes the first track no other window has focused, in score order. Open three terminals on a three-track session and each one restores a different instrument. A fourth window gets a draft track (`track-4`, "all tracks open · new track") that is added to the score on its first edit, so idle windows never clutter the song. `--track` always wins over auto-claim.
45
+
46
+ Sessions have names. A new session starts as `untitled` and is named automatically from what you play (`a minor bass groove`, `dusty basement funk`). `/rename <name>` sets your own name and stops auto-naming for good; `/rename --auto` hands it back. A user rename always beats an auto-name that was still in flight, and every window updates. `/fork [name]` snapshots the current song into a new session (`night drive` → `night drive 2` → `night drive 3`; a fork of `night drive 2` is `night drive 3`) and switches this window to it. `/sessions` lists recent sessions, `/resume` opens a picker (↑/↓, Enter, Esc) and `/resume <n|name|id>` switches directly. Undo in a fork steps back past the fork point into the parent's history. `dawg sessions` prints the same list from the shell.
47
+
48
+ Auto-naming is cheap. dawg keeps a local musical fingerprint (tempo, key estimate, instruments, register, density and effects) and only asks a model when the music actually changed, at most once every few turns, after three quiet seconds. The request is about 120 tokens in and 12 out through the configured provider (gateway `anthropic/claude-haiku-4.5`, or xcb), runs in the background so it never blocks the prompt, and falls back to a local name such as `96 bpm drums` when offline or with `DAWG_AI=0`. In tests a typical 10-prompt session makes 2–3 naming calls.
49
+
50
+ ## Use
51
+
52
+ The first steel thread understands direct requests:
53
+
54
+ ```text
55
+ add C4 at 0 for 1
56
+ play
57
+ pause
58
+ tempo 128
59
+ instrument piano
60
+ volume 0.7
61
+ pan -0.4
62
+ automate volume at 0 0.2
63
+ automate volume at 4 1
64
+ automate pan at 0 -1
65
+ automate pan at 4 1
66
+ track drums
67
+ instrument kit
68
+ hit kick at 0
69
+ hit snare at 1 vel 0.7
70
+ pattern kick 0 1 2 3
71
+ pattern hat every 0.5 from 0.25
72
+ clear hat
73
+ filter 1200
74
+ filter 800 0.6
75
+ filter off
76
+ delay 0.375 0.3
77
+ delay 0.75 0.4 0.5
78
+ delay off
79
+ reverb 0.3
80
+ reverb 0.4 0.8
81
+ reverb off
82
+ automate filter at 0 400
83
+ automate filter at 4 6000
84
+ automate resonance at 0 0.2
85
+ automate delay-feedback at 0 0.6
86
+ automate delay-mix at 4 0
87
+ clear filter automation
88
+ bars 8
89
+ extend 4 bars
90
+ clear automation
91
+ mute
92
+ solo
93
+ unsolo
94
+ clear
95
+ undo
96
+ redo
97
+ move note <id> to 2.5
98
+ duration note <id> 0.25
99
+ /export loop.track.json
100
+ /import loop.track.json
101
+ /model opus-5.5
102
+ ```
103
+
104
+ A track named `drums` (or `kit`) starts with the `kit` instrument; `instrument kit` turns any track into a drum track. Drum voices are `kick` (`bd`), `snare` (`sd`), `clap` (`cp`), `rim` (`perc`), `tom`, `hat` (`hh`), and `openhat` (`oh`); the highway shows one lane per voice. `pattern <voice> <beats...>` takes up to 64 beats, `pattern <voice> every <step>` (step ≥ 0.125) fills the loop, `vel <0..1>` sets velocity, and `clear <voice>` removes only that voice. `filter <hz> [resonance]` is a per-track low-pass (20–20000 Hz, resonance 0–1), `delay <beats> [feedback] [mix]` is a tempo-synced stereo ping-pong echo send (0.0625–4 beats, feedback ≤ 0.9, mix 0–1), `reverb <mix> [size]` is an algorithmic stereo reverb send (mix 0–1, size 0–1, default 0.5; `reverb off` removes it), and `automate filter|resonance|delay-feedback|delay-mix at <beat> <value>` writes the cutoff (Hz), resonance (0–1), delay feedback (0–0.9) and delay mix (0–1) lanes. `solo` isolates the focused track in playback across every window; `redo` re-applies the last undone edit.
105
+
106
+ The screen has four parts. A one-line header shows track · session · ▶/⏸ BPM · key · model · revision · sync state. The highway overlays every unmuted track, each in its own stable accent, with the focused track bright and the others dimmed; `/view focus` shows only the focused track and `/view all` (the default) brings the rest back. Below it, an activity strip shows operation cards (`✓ +8 bass notes · rev 41→42 · ^z undo`), queue depth, a braille spinner while the agent works, and errors in red with an `✗` prefix. The prompt panel is filled with a background color. It wraps by grapheme, grows from 1 to 8 rows (capped at 30% of the screen, then scrolls internally) and keeps the draft when the terminal is resized. Narrow terminals collapse the header and hints, and below 24×8 the screen shows a resize hint.
107
+
108
+ | Key | Action |
109
+ | -------------------- | ---------------------------------------------------------------------------- |
110
+ | Space (empty prompt) | play / pause |
111
+ | Enter | submit (STEER) or queue (QUEUE mode) |
112
+ | Shift+Enter, Ctrl+J | newline |
113
+ | Alt+Enter | queue this prompt |
114
+ | Ctrl+Q | toggle the STEER / QUEUE mode pill |
115
+ | Ctrl+Z / Ctrl+Y | undo / redo |
116
+ | Ctrl+O or `/log` | transcript overlay: ↑/↓, PgUp/PgDn scroll, `/` filters requests, ops, errors |
117
+ | Esc | cancel the agent turn, close the overlay, or clear the draft |
118
+ | Ctrl+L | full redraw |
119
+ | Ctrl+C | exit |
120
+
121
+ A STEER submit runs ahead of queued work. Bracketed paste preserves multiline input.
122
+
123
+ `/theme default|high-contrast|mono` and `--theme <name>` (or `DAWG_THEME`) pick a theme. Semantic color tokens map to truecolor, 256, 16 or no color. `NO_COLOR` and `TERM=dumb` force monochrome. `/motion off`, `--reduce-motion` or `DAWG_REDUCE_MOTION=1` replace animations with static states in the same positions. Every color has a non-color cue as well: glyph density, `✓`/`✗`/`!` prefixes, the mode pill text and `▶`/`⏸`.
124
+
125
+ ## Auth
126
+
127
+ Run `dawg login` once to give the agent a model. With the Vercel CLI it signs you in (if needed) and creates an AI Gateway key named `dawg-<hostname>`; `--budget <dollars>` sets its spend limit. Without the CLI it prints `bun add -g vercel` and lets you paste a key instead (`dawg login --key`, hidden input, Enter opens the key page).
128
+
129
+ - `dawg login --xcb` uses a Claude, Codex or Devin subscription through [xcb](https://github.com/hraness/xcb) (`curl -fsSL https://xcb.sh/install.sh | sh`). It lists the accounts that `xcb --json generate --capabilities` reports as available and saves your pick; inside the TUI, `/login --xcb` opens the same choice as a picker of accounts and models. An account whose admission xcb reports as `pending` counts as available, and its first turn shows `admitting account…` while xcb admits it. The provider is re-resolved when `~/.config/dawg` credentials or config change, so a login in another terminal applies on the next turn. An account only appears after xcb's [application qualification](https://github.com/hraness/xcb/blob/main/docs/application-api.md); if none qualify, the command prints the read-only `xcb --json qualify-application --inspect` line for each connected account.
130
+ - `dawg auth status` (or `/auth` in the TUI; `--check` verifies the key online) shows the provider, a masked key such as `vck_…abcd` and its source, plus the audio backend. `dawg logout` removes the stored key and the provider choice. `/login` works in the TUI too; flows that need hidden input or a browser tell you to use a shell.
131
+ - Keys go to the macOS Keychain (service `dawg`, account `ai-gateway`, passed to `security -i` on stdin so the key never appears in a process list) or to `~/.config/dawg/credentials.json` (0600, directory 0700). They are never written to `.dawg/`. `AI_GATEWAY_API_KEY` in the environment always wins. `DAWG_CREDENTIAL_STORE=file` skips the Keychain and `DAWG_CONFIG_DIR` moves the config directory.
132
+ - `DAWG_PROVIDER=gateway|xcb|auto` overrides the saved choice. `auto` (the default) uses the gateway when a key exists, then an available xcb account, otherwise direct commands only with a hint to run `dawg login`. `DAWG_AI=0` turns the agent off. The header shows the active provider, for example `opus-5.5 · gateway` or `devin/swe-2-high · xcb`.
133
+
134
+ On the gateway, unrecognized requests go to a streaming, tool-calling agent. Choose `DAWG_MODEL=opus-5.5` or `DAWG_MODEL=sol-6.1`, or switch with `/model`. By default these labels map to `anthropic/claude-opus-5.5` and `openai/gpt-6.1-sol` from the gateway catalog. Override them with `DAWG_OPUS_MODEL` and `DAWG_SOL_MODEL`. Other labels are rejected. xcb has no tool calling, so dawg asks for one JSON object of ops per call and runs each op through the same checks; it retries with diagnostics up to 3 calls per turn.
135
+
136
+ The agent edits the score only through typed tools: `add_notes`, `add_drums`, `remove_notes`, `update_notes`, `set_instrument`, `set_mix` (with solo), `set_effects` (filter, delay and reverb), `set_automation` (volume, pan, filter cutoff and resonance, delay feedback and mix), `extend_loop`, `set_tempo`, `create_track`, `transport` and `explain`. Each call is validated, then committed as its own revision, and the status line shows its result (for example `✓ +8 bass notes`). While the agent is working, Esc cancels and keeps every change accepted so far. Enter sends a steering message that the agent reads at its next step. A queued submit (Ctrl+Q queue mode) waits until the turn ends.
137
+
138
+ Playback renders deterministic stereo PCM with sine, piano, pluck, bass, saw, square, and triangle voices plus a synthesized drum kit (pitch-swept sine kick, seeded-noise snare and hats), applies per-track volume, equal-power pan (-1 left to 1 right), low-pass filter, ping-pong delay, and a Freeverb-style reverb, and honors mute and solo. Renders are byte-identical across runs. Playback is gapless: one long-lived player (`ffplay`, else SoX `play`) reads a seamless loop as raw PCM on stdin, and edits, tempo changes and seeks swap the buffer in place at the current position without restarting it, so the transport stays aligned with what you hear. On macOS without either, `afplay` replays a re-rendered loop on each edit. `dawg auth status` shows the backend; `DAWG_AUDIO_BACKEND=ffplay|sox|afplay|none` forces one, `DAWG_AUDIO_PLAYER="cmd {rate} {channels}"` streams to any stdin player, and `DAWG_AUDIO=0` runs headless. `dawg --export file.track.json` and `dawg --import file.track.json` exchange the bounded `track.loop/v1` document. `dawg render out.wav` writes the current session (or `--session <name|id>`, or `--import file.track.json`) to a WAV through the same renderer, without starting dawgd or playing audio; the same score always produces the same bytes, and the command prints the file's sha256. `/status` prints the session name, revision, composition digest and connection mode. `DAWG_DEMO=1 bun run src/main.ts` prints a deterministic renderer frame for development.
139
+
140
+ ## Architecture
141
+
142
+ - `core/` defines the bounded immutable `track.loop/v1` score and operations.
143
+ - `src/session/` provides an append-only local event log, atomic snapshots, and `dawgd`: one local daemon per session (`src/daemon.ts`), started automatically by the first window. Windows connect over a Unix socket, send idempotent intents, and receive accepted changes, presence, and one shared transport clock. If the daemon cannot start, windows fall back to the file-lock path and say so in the status line. `dawg sessions` lists the workspace's sessions with revision, update time, and live daemon.
144
+ - `src/agent/` runs the bounded streaming tool-calling agent: the SSE gateway client, the tool registry, the composition brief, and operation validation.
145
+ - `src/audio/` owns the transport clock, deterministic instrument-bank WAV rendering, and per-session playback lock. When `dawgd` is running it is the only process that plays audio.
146
+ - `tui/` owns terminal capability detection, semantic colors, animation phases, piano-roll rendering, and the multiline prompt editor.
147
+
148
+ The runtime is intentionally adapter-shaped. The local synthesizer is deterministic and works without a sound device; native or sample-backed players can replace it behind the same score boundary.
149
+
150
+ See [DAWG.md](./DAWG.md) for the detailed command and interaction contract.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ bun install --frozen-lockfile
156
+ bun run check
157
+ ```
158
+
159
+ `bun run check` includes `test/e2e.test.ts`, which drives real `dawg` processes in real PTYs against a temporary workspace and a live dawgd: three windows converging on one revision and digest, shared transport, rename, auto-claim and drafts, undo and redo across windows, fork, `kill -9` recovery and a deterministic render. It needs no network or credentials. See [CHANGELOG.md](./CHANGELOG.md) for release history.
160
+
161
+ ## Release
162
+
163
+ Bump `version` in `package.json` and add its section to `CHANGELOG.md` in a pull request, then merge it. When Check passes on `main`, the annotated `v<version>` tag, the immutable GitHub Release (tarball, `SHA256SUMS` and a provenance attestation) and the npm publish of `@hraness/dawg` follow automatically. See [docs/publishing.md](./docs/publishing.md).
164
+
165
+ dawg is MIT licensed. Contributions should preserve bounded inputs, deterministic score operations, local session safety, and a working terminal fallback when color or animation is unavailable.
package/core/drums.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Drum vocabulary shared by the parser, renderer, highway, and planner.
3
+ *
4
+ * A drum track is an ordinary track whose instrument is a kit (`kit`,
5
+ * `drums`, `drum`, or `drumkit`). Its notes keep the score's MIDI `pitch`
6
+ * field, using General MIDI percussion numbers to select a voice, so drum
7
+ * hits round-trip through `track.loop/v1` without a new note shape.
8
+ */
9
+
10
+ export type DrumVoice =
11
+ "kick" | "snare" | "clap" | "rim" | "tom" | "hat" | "openhat";
12
+
13
+ export type DrumVoiceInfo = Readonly<{
14
+ voice: DrumVoice;
15
+ pitch: number;
16
+ label: string;
17
+ }>;
18
+
19
+ /** Ordered left to right as highway lanes. */
20
+ export const DRUM_VOICES: readonly DrumVoiceInfo[] = Object.freeze([
21
+ Object.freeze({ voice: "kick", pitch: 36, label: "kick" }),
22
+ Object.freeze({ voice: "snare", pitch: 38, label: "snare" }),
23
+ Object.freeze({ voice: "clap", pitch: 39, label: "clap" }),
24
+ Object.freeze({ voice: "rim", pitch: 37, label: "rim" }),
25
+ Object.freeze({ voice: "tom", pitch: 45, label: "tom" }),
26
+ Object.freeze({ voice: "hat", pitch: 42, label: "hat" }),
27
+ Object.freeze({ voice: "openhat", pitch: 46, label: "open" }),
28
+ ] as const);
29
+
30
+ export const DRUM_INSTRUMENTS = Object.freeze([
31
+ "kit",
32
+ "drums",
33
+ "drum",
34
+ "drumkit",
35
+ ] as const);
36
+
37
+ const VOICE_ALIASES: Readonly<Record<string, DrumVoice>> = Object.freeze({
38
+ kick: "kick",
39
+ bd: "kick",
40
+ snare: "snare",
41
+ sd: "snare",
42
+ clap: "clap",
43
+ cp: "clap",
44
+ rim: "rim",
45
+ rimshot: "rim",
46
+ perc: "rim",
47
+ tom: "tom",
48
+ lt: "tom",
49
+ hat: "hat",
50
+ hh: "hat",
51
+ hihat: "hat",
52
+ "hi-hat": "hat",
53
+ closedhat: "hat",
54
+ "closed-hat": "hat",
55
+ chh: "hat",
56
+ openhat: "openhat",
57
+ "open-hat": "openhat",
58
+ ohh: "openhat",
59
+ oh: "openhat",
60
+ open: "openhat",
61
+ });
62
+
63
+ /** Extra General MIDI numbers folded onto the nearest local voice. */
64
+ const PITCH_ALIASES: Readonly<Record<number, DrumVoice>> = Object.freeze({
65
+ 35: "kick",
66
+ 40: "snare",
67
+ 41: "tom",
68
+ 43: "tom",
69
+ 44: "hat",
70
+ 47: "tom",
71
+ 48: "tom",
72
+ 50: "tom",
73
+ });
74
+
75
+ export function isDrumInstrument(instrument: string | undefined): boolean {
76
+ if (typeof instrument !== "string") return false;
77
+ return (DRUM_INSTRUMENTS as readonly string[]).includes(
78
+ instrument.trim().toLowerCase(),
79
+ );
80
+ }
81
+
82
+ export function parseDrumVoice(value: string): DrumVoice | undefined {
83
+ return VOICE_ALIASES[value.trim().toLowerCase()];
84
+ }
85
+
86
+ export function drumVoicePitch(voice: DrumVoice): number {
87
+ return DRUM_VOICES.find((info) => info.voice === voice)!.pitch;
88
+ }
89
+
90
+ /** Any MIDI pitch resolves to a voice; unknown numbers play as `rim`. */
91
+ export function drumVoiceForPitch(pitch: number): DrumVoice {
92
+ return (
93
+ DRUM_VOICES.find((info) => info.pitch === pitch)?.voice ??
94
+ PITCH_ALIASES[pitch] ??
95
+ "rim"
96
+ );
97
+ }
98
+
99
+ export function drumLane(pitch: number): number {
100
+ const voice = drumVoiceForPitch(pitch);
101
+ return DRUM_VOICES.findIndex((info) => info.voice === voice);
102
+ }
package/core/loop.ts ADDED
@@ -0,0 +1,78 @@
1
+ import {
2
+ SCORE_VERSION,
3
+ TrackScore,
4
+ ScoreValidationError,
5
+ scoreFromJSON,
6
+ type Note,
7
+ type Track,
8
+ } from "./score.ts";
9
+
10
+ export const LOOP_FORMAT = "track.loop/v1" as const;
11
+
12
+ export type TrackLoopV1 = Readonly<{
13
+ format: typeof LOOP_FORMAT;
14
+ version: typeof SCORE_VERSION;
15
+ tempoBpm: number;
16
+ beatsPerBar: number;
17
+ bars: number;
18
+ ticksPerBeat: number;
19
+ key: string | null;
20
+ tracks: readonly Track[];
21
+ notes: readonly Note[];
22
+ }>;
23
+
24
+ /** Return the stable object form used by files and IPC messages. */
25
+ export function encodeLoopDocument(score: TrackScore): TrackLoopV1 {
26
+ if (!(score instanceof TrackScore))
27
+ throw new ScoreValidationError("encodeLoop requires a TrackScore");
28
+ return Object.freeze({
29
+ format: LOOP_FORMAT,
30
+ version: SCORE_VERSION,
31
+ tempoBpm: score.tempoBpm,
32
+ beatsPerBar: score.beatsPerBar,
33
+ bars: score.bars,
34
+ ticksPerBeat: score.ticksPerBeat,
35
+ key: score.key,
36
+ tracks: score.tracks,
37
+ notes: score.notes,
38
+ });
39
+ }
40
+
41
+ /** Encode with deterministic property and note ordering. */
42
+ export function encodeLoop(score: TrackScore): string {
43
+ return JSON.stringify(encodeLoopDocument(score));
44
+ }
45
+
46
+ export function decodeLoopDocument(value: unknown): TrackScore {
47
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
48
+ throw new ScoreValidationError("track.loop/v1 must be an object");
49
+ }
50
+ const envelope = value as Record<string, unknown>;
51
+ if (envelope.format !== LOOP_FORMAT)
52
+ throw new ScoreValidationError(
53
+ `unsupported loop format: ${String(envelope.format)}`,
54
+ );
55
+ if (envelope.version !== SCORE_VERSION)
56
+ throw new ScoreValidationError(
57
+ `unsupported loop version: ${String(envelope.version)}`,
58
+ );
59
+ return scoreFromJSON(envelope);
60
+ }
61
+
62
+ export function decodeLoop(serialized: unknown): TrackScore {
63
+ let value: unknown = serialized;
64
+ if (typeof serialized === "string") {
65
+ try {
66
+ value = JSON.parse(serialized) as unknown;
67
+ } catch (error) {
68
+ throw new ScoreValidationError(
69
+ `invalid track.loop/v1 JSON: ${error instanceof Error ? error.message : String(error)}`,
70
+ );
71
+ }
72
+ }
73
+ return decodeLoopDocument(value);
74
+ }
75
+
76
+ // Explicit aliases make the format boundary easy to discover from callers.
77
+ export const encodeTrackLoop = encodeLoop;
78
+ export const decodeTrackLoop = decodeLoop;