@hraness/dawg 0.2.0 → 0.3.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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
package/README.md
CHANGED
|
@@ -15,20 +15,28 @@ curl -fsSL https://dawg.sh/install | sh
|
|
|
15
15
|
or install the release tarball from GitHub directly:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
bun add -g https://github.com/hraness/dawg/releases/download/v0.
|
|
18
|
+
bun add -g https://github.com/hraness/dawg/releases/download/v0.3.0/hraness-dawg-0.3.0.tgz
|
|
19
19
|
dawg --help
|
|
20
20
|
```
|
|
21
21
|
|
|
22
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
23
|
|
|
24
24
|
```sh
|
|
25
|
-
gh release download v0.
|
|
25
|
+
gh release download v0.3.0 --repo hraness/dawg
|
|
26
26
|
shasum -a 256 -c SHA256SUMS
|
|
27
|
-
gh attestation verify hraness-dawg-0.
|
|
28
|
-
bun add -g "$PWD/hraness-dawg-0.
|
|
27
|
+
gh attestation verify hraness-dawg-0.3.0.tgz --repo hraness/dawg
|
|
28
|
+
bun add -g "$PWD/hraness-dawg-0.3.0.tgz"
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
dawg is
|
|
31
|
+
dawg is also on npm as [`@hraness/dawg`](https://www.npmjs.com/package/@hraness/dawg):
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm i -g @hraness/dawg
|
|
35
|
+
# or
|
|
36
|
+
bun add -g @hraness/dawg
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
To run from source instead:
|
|
32
40
|
|
|
33
41
|
```sh
|
|
34
42
|
git clone https://github.com/hraness/dawg.git
|
|
@@ -37,13 +45,13 @@ bun install --frozen-lockfile
|
|
|
37
45
|
bun run dawg
|
|
38
46
|
```
|
|
39
47
|
|
|
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.
|
|
48
|
+
Running `dawg` creates `.dawg/session` when needed (the first launch says `created .dawg/ · add it to .gitignore`) and attaches to that session on later launches. Use `dawg --new` for a new composition, `dawg --session <name|id>` to attach explicitly (an unknown name or id is an error, `no session named "…" · dawg sessions`, never a new session), or `dawg --track bass` to focus a named track. `dawg --version` prints the version; an unknown subcommand or option is rejected with usage before anything is written, and `dawg sessions --help`, `dawg render --help` and `dawg auth --help` print their own usage.
|
|
41
49
|
|
|
42
50
|
### Sessions
|
|
43
51
|
|
|
44
52
|
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
53
|
|
|
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
|
|
54
|
+
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 sessions in an overlay (one summary card stays in the strip), `/resume` opens a picker (↑/↓, Enter or a digit, Esc) and `/resume <n|name|id>` switches directly by any list index, name or id prefix. 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
55
|
|
|
48
56
|
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
57
|
|
|
@@ -63,7 +71,7 @@ automate volume at 0 0.2
|
|
|
63
71
|
automate volume at 4 1
|
|
64
72
|
automate pan at 0 -1
|
|
65
73
|
automate pan at 4 1
|
|
66
|
-
track drums
|
|
74
|
+
/track drums
|
|
67
75
|
instrument kit
|
|
68
76
|
hit kick at 0
|
|
69
77
|
hit snare at 1 vel 0.7
|
|
@@ -98,50 +106,91 @@ move note <id> to 2.5
|
|
|
98
106
|
duration note <id> 0.25
|
|
99
107
|
/export loop.track.json
|
|
100
108
|
/import loop.track.json
|
|
101
|
-
/model
|
|
109
|
+
/model pick a model (cost per prompt shown)
|
|
102
110
|
```
|
|
103
111
|
|
|
112
|
+
`/help` (or `?`) opens the command reference in an overlay, grouped as music, session, window and keys, with every command listed once in its canonical form: music words are bare (`tempo 96`, `pattern kick every 1`), app commands take a slash (`/tracks`, `/export`); bare `tracks`, `export`, `import` and `track` keep working as aliases. An unknown `/word` is rejected locally (`unknown command /foo · /help`), and a known verb with bad arguments gets usage (`pan 3 · pan takes -1…1 · pan -0.5`) instead of a model call. `/track <name>` focuses a track in this window, creating it when it is new; a track another live window has open stays theirs (`drums is open in another window`).
|
|
113
|
+
|
|
104
114
|
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
115
|
|
|
106
|
-
The screen has four parts. A one-line header shows
|
|
107
|
-
|
|
108
|
-
| Key
|
|
109
|
-
|
|
|
110
|
-
| Space (empty prompt)
|
|
111
|
-
| Enter
|
|
112
|
-
| Shift+Enter, Ctrl+J
|
|
113
|
-
| Alt+Enter
|
|
114
|
-
| Ctrl+Q
|
|
115
|
-
| Ctrl+Z / Ctrl+Y
|
|
116
|
-
| Ctrl+O
|
|
117
|
-
| Esc
|
|
118
|
-
| Ctrl+L
|
|
119
|
-
| Ctrl+C
|
|
116
|
+
The screen has four parts. A one-line header shows `dawg` · track · ▶/⏸ BPM · key · session · window count (when more than one) on the left and model · rev · sync state on the right; narrower terminals drop the least important segments first. 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. A track with no hits shows `main · empty · add C4 at 0 to start` in place of lane labels. Below it, an activity strip shows operation cards (`✓ +8 bass notes · rev 41→42 · ^z undo`; the undo hint accompanies the first three score edits), queue depth, a braille spinner while the agent works, and errors in red with an `✗` prefix in one shape, `<what> · <why> · <next step>` (`no such file · nope.json`). 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.
|
|
117
|
+
|
|
118
|
+
| Key | Action |
|
|
119
|
+
| --------------------- | ---------------------------------------------------------------------------- |
|
|
120
|
+
| Space (empty prompt) | play / pause |
|
|
121
|
+
| Enter | submit (STEER) or queue (QUEUE mode) |
|
|
122
|
+
| Shift+Enter, Ctrl+J | newline |
|
|
123
|
+
| Alt+Enter | queue this prompt |
|
|
124
|
+
| Ctrl+Q | toggle the STEER / QUEUE mode pill |
|
|
125
|
+
| Ctrl+Z / Ctrl+Y | undo / redo |
|
|
126
|
+
| Ctrl+O, `/transcript` | transcript overlay: ↑/↓, PgUp/PgDn scroll, `/` filters requests, ops, errors |
|
|
127
|
+
| Esc | cancel the agent turn, close the overlay, or clear the draft |
|
|
128
|
+
| Ctrl+L | full redraw |
|
|
129
|
+
| Ctrl+C | exit |
|
|
120
130
|
|
|
121
131
|
A STEER submit runs ahead of queued work. Bracketed paste preserves multiline input.
|
|
122
132
|
|
|
123
133
|
`/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
134
|
|
|
135
|
+
### Project files
|
|
136
|
+
|
|
137
|
+
`dawg init` turns the current directory into a project: `song.ts` and one `tracks/<slug>/track.ts` per track, written with a small typed SDK (`import { track, note, hit } from "dawg"`). Edit them in any editor (or let the agent edit them) and every open window applies the change as one revision; edit in the TUI and the affected file is reprinted. `dawg check` typechecks and evaluates the files and exits 1 with `file:line:col` diagnostics. See **Project files and SDK** in [DAWG.md](./DAWG.md).
|
|
138
|
+
|
|
125
139
|
## Auth
|
|
126
140
|
|
|
127
|
-
Run `dawg login`
|
|
141
|
+
Run `dawg login` (or just `dawg` the first time) to give the agent a model. dawg looks for what is already on the machine, in parallel and with a 4 s cap, then shows one picker: ↑/↓ or a number, Enter to choose, Esc to cancel. The first detected option is the default, and when exactly one is ready it just asks `Use <it>? [Y/n]`.
|
|
142
|
+
|
|
143
|
+
| Option | Detected from | Sign-in |
|
|
144
|
+
| -------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
145
|
+
| Vercel AI Gateway | `AI_GATEWAY_API_KEY`, a stored dawg key, `vercel whoami` | With the Vercel CLI: `vercel login` in the browser if needed, then a key named `dawg-<hostname>` is created (`--budget <dollars>` sets its limit). Without it: opens the keys page and takes a pasted key (hidden input). |
|
|
146
|
+
| OpenRouter | `OPENROUTER_API_KEY`, a stored dawg key | OpenRouter's OAuth PKCE flow: dawg opens the browser on a one-time `127.0.0.1` callback, checks the state, exchanges the code for a key and verifies it (5 min timeout; the URL is printed too). `--key` pastes a key instead. |
|
|
147
|
+
| ChatGPT/Codex subscription | [xcb](https://github.com/hraness/xcb) accounts with provider `codex` | Lists ready accounts and their models; runs `xcb setup codex` (browser sign-in) when none is ready. |
|
|
148
|
+
| Claude subscription | xcb accounts with provider `claude` | Same, with `xcb setup claude`. |
|
|
128
149
|
|
|
129
|
-
- `dawg login
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
- `
|
|
150
|
+
- Shortcuts: `dawg login gateway|openrouter|codex|claude` (`--account <id> --model <id>` for subscriptions), `dawg login --xcb` for any xcb account (Devin too), `dawg login --key` to paste a gateway key.
|
|
151
|
+
- The choice sticks. dawg saves the provider, model and account in `~/.config/dawg/config.json` (0600, written atomically, never a key) and reuses it on every start and `dawg login` without asking. `dawg login <provider>`, `/model` and `dawg logout [provider]` change or clear it. If the saved provider stops working (revoked key, account gone), dawg says so once and opens the picker; it never switches providers on its own.
|
|
152
|
+
- `/login` in the TUI suspends the screen, runs the same flow (browser and `vercel login` included) and redraws when done.
|
|
153
|
+
- `dawg model` (or `/model` in the TUI) picks a model for the active provider, grouped as frontier, fast and open weights, with type-to-filter and an estimated cost per prompt (`~$0.004/prompt`) from [models.dev](https://models.dev) pricing (OpenRouter's own `/models` prices on OpenRouter); subscriptions show `included`. On the gateway and OpenRouter only tool-calling models are listed; for subscriptions the list is what xcb reports for the account. `/model opus-5.5` sets one directly, and any `vendor/model` ID the provider serves works too.
|
|
154
|
+
- Under the prompt, `$0.12 session · $0.48 today · opus-5.5 · gateway` shows spend from the usage each response reports (the provider's own cost when given, else tokens × price). The daily total is shared across windows through `~/.config/dawg/usage.json`. Subscriptions show `subscription`.
|
|
155
|
+
- `dawg auth status` (also `/auth`; `--check` verifies keys online) lists all four options with detected, active and validated state, plus the installed xcb version and the audio backend. Keys appear only masked (`vck_…abcd`).
|
|
156
|
+
- Keys go to the macOS Keychain (service `dawg`, passed to `security -i` on stdin so a key never appears in a process list) or `~/.config/dawg/credentials.json` (0600, directory 0700). They are never written to `.dawg/`. Environment keys always win.
|
|
157
|
+
- Provider order: `DAWG_PROVIDER`, then the saved choice, then auto (AI Gateway, then OpenRouter, then a ready subscription). `DAWG_AI=0` turns the agent off. The header shows `<model> · <provider>`.
|
|
158
|
+
- Subscriptions need xcb 0.20.0 or newer. An account is usable when xcb reports it `available`; one still in automatic admission (`admission: pending`) works, but its first turn can take up to a minute longer. Accounts whose catalog is missing are refreshed once (`xcb accounts refresh`) during discovery.
|
|
133
159
|
|
|
134
|
-
On the gateway, unrecognized requests go to a streaming, tool-calling agent
|
|
160
|
+
On the gateway and OpenRouter, unrecognized requests go to a streaming, tool-calling agent through the OpenAI-compatible chat API. The default is `opus-5.5` (`anthropic/claude-opus-5.5`); `DAWG_MODEL=<alias|vendor/model>` overrides it, and an unknown value is an error at startup that lists the choices. 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
161
|
|
|
136
162
|
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
163
|
|
|
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; `
|
|
164
|
+
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=0` runs headless (see Environment for the other switches). `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 storage (`shared via dawgd` or `saved locally · no daemon`).
|
|
165
|
+
|
|
166
|
+
The agent can download a YouTube reference, split stems, analyze tempo and key, transcribe notes and lyrics, and import samples into `tracks/<slug>/`; `dawg media doctor` shows which local binaries or StemDeck it will use (see [DAWG.md](./DAWG.md#media-tools)).
|
|
167
|
+
|
|
168
|
+
## Environment
|
|
169
|
+
|
|
170
|
+
| Variable | Effect |
|
|
171
|
+
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
172
|
+
| `DAWG_AUDIO=0` | no sound; the transport still runs (the one audio kill switch) |
|
|
173
|
+
| `DAWG_AUDIO_BACKEND=ffplay\|sox\|afplay` | force a player (`none` is still accepted and means the same as `DAWG_AUDIO=0`) |
|
|
174
|
+
| `DAWG_AUDIO_PLAYER="cmd {rate} {channels}"` | stream raw PCM to any stdin player |
|
|
175
|
+
| `DAWG_AI=0` | agent off; unrecognized requests are rejected locally and naming stays local |
|
|
176
|
+
| `DAWG_PROVIDER=gateway\|openrouter\|codex\|claude\|xcb\|auto` | provider choice (default: the saved choice, else `auto`) |
|
|
177
|
+
| `DAWG_MODEL=<alias\|vendor/model>` | model for this run (unknown values are rejected) |
|
|
178
|
+
| `AI_GATEWAY_API_KEY`, `OPENROUTER_API_KEY` | win over any stored key |
|
|
179
|
+
| `XCB_BIN` | path to xcb for the subscription options |
|
|
180
|
+
| `DAWG_CREDENTIAL_STORE=file`, `DAWG_CONFIG_DIR` | skip the Keychain; move `~/.config/dawg` |
|
|
181
|
+
| `DAWG_STEMDECK_URL` | StemDeck for the media tools (default `http://127.0.0.1:8000`; else local yt-dlp, demucs, basic-pitch, whisper-cli) |
|
|
182
|
+
| `DAWG_DAEMON=0` | file-lock path, no dawgd |
|
|
183
|
+
| `DAWG_DEMO=1` | print one deterministic frame and exit (also `--demo`, or a non-TTY stdin); `DAWG_DEMO=1 bun run src/main.ts` is the development render |
|
|
184
|
+
| `DAWG_THEME`, `DAWG_REDUCE_MOTION=1` | theme (`default\|high-contrast\|mono`) and static motion |
|
|
185
|
+
| `NO_COLOR`, `TERM=dumb` | monochrome |
|
|
139
186
|
|
|
140
187
|
## Architecture
|
|
141
188
|
|
|
142
189
|
- `core/` defines the bounded immutable `track.loop/v1` score and operations.
|
|
143
190
|
- `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
191
|
- `src/agent/` runs the bounded streaming tool-calling agent: the SSE gateway client, the tool registry, the composition brief, and operation validation.
|
|
192
|
+
- `src/agent/workspace.ts` and `src/web/` give the agent bounded project file access (writes scoped to `song.ts` and the focused `tracks/<slug>/`) and web search and fetch with injectable network.
|
|
193
|
+
- `core/sdk/` and `src/project/` make a directory with `dawg.json` a project of typechecked TypeScript files (`song.ts`, `tracks/<slug>/track.ts`) kept in two-way sync with the session.
|
|
145
194
|
- `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
195
|
- `tui/` owns terminal capability detection, semantic colors, animation phases, piano-roll rendering, and the multiline prompt editor.
|
|
147
196
|
|
package/core/diff.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score diff: the shortest list of `ScoreOperation`s that turns score A into
|
|
3
|
+
* score B, so a project edit commits as ordinary session operations that
|
|
4
|
+
* rebase, undo and broadcast like everything else.
|
|
5
|
+
*
|
|
6
|
+
* Order: song settings, note removals, track removals, track additions,
|
|
7
|
+
* track moves, track patches, note updates, note additions. Each track and
|
|
8
|
+
* each note appears at most once per kind. `applyScoreOperations(a,
|
|
9
|
+
* diffScores(a, b))` deep-equals `b` (notes and tracks are stored in
|
|
10
|
+
* canonical order, so insertion order never matters).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
applyScoreOperation,
|
|
15
|
+
TrackScore,
|
|
16
|
+
type Note,
|
|
17
|
+
type ScoreOperation,
|
|
18
|
+
type Track,
|
|
19
|
+
type TrackPatch,
|
|
20
|
+
} from "./score.ts";
|
|
21
|
+
|
|
22
|
+
export class DiffError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "DiffError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EFFECTS = ["filter", "delay", "reverb", "sampler"] as const;
|
|
30
|
+
const LANES = [
|
|
31
|
+
"volumeAutomation",
|
|
32
|
+
"panAutomation",
|
|
33
|
+
"filterAutomation",
|
|
34
|
+
"resonanceAutomation",
|
|
35
|
+
"delayFeedbackAutomation",
|
|
36
|
+
"delayMixAutomation",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/** Operations turning `a` into `b`; empty when they are equal. */
|
|
40
|
+
export function diffScores(
|
|
41
|
+
a: TrackScore,
|
|
42
|
+
b: TrackScore,
|
|
43
|
+
): readonly ScoreOperation[] {
|
|
44
|
+
if (a.ticksPerBeat !== b.ticksPerBeat)
|
|
45
|
+
throw new DiffError(
|
|
46
|
+
`ticksPerBeat differs (${a.ticksPerBeat} → ${b.ticksPerBeat}); the session resolution is fixed`,
|
|
47
|
+
);
|
|
48
|
+
const ops: ScoreOperation[] = [];
|
|
49
|
+
if (a.beatsPerBar !== b.beatsPerBar)
|
|
50
|
+
ops.push({ type: "setMeter", beatsPerBar: b.beatsPerBar });
|
|
51
|
+
if (a.bars !== b.bars) ops.push({ type: "setBars", bars: b.bars });
|
|
52
|
+
if (a.tempoBpm !== b.tempoBpm)
|
|
53
|
+
ops.push({ type: "setTempo", tempoBpm: b.tempoBpm });
|
|
54
|
+
if (a.key !== b.key) ops.push({ type: "setKey", key: b.key });
|
|
55
|
+
|
|
56
|
+
const aTracks = new Map(a.tracks.map((track) => [track.id, track]));
|
|
57
|
+
const bTracks = new Map(b.tracks.map((track) => [track.id, track]));
|
|
58
|
+
const aNotes = new Map(a.notes.map((note) => [note.id, note]));
|
|
59
|
+
const bNotes = new Map(b.notes.map((note) => [note.id, note]));
|
|
60
|
+
|
|
61
|
+
const updates: ScoreOperation[] = [];
|
|
62
|
+
const additions: ScoreOperation[] = [];
|
|
63
|
+
for (const note of a.notes) {
|
|
64
|
+
if (!bTracks.has(note.trackId)) continue; // removed with its track
|
|
65
|
+
const next = bNotes.get(note.id);
|
|
66
|
+
if (!next || next.trackId !== note.trackId) {
|
|
67
|
+
ops.push({ type: "removeNote", noteId: note.id });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const patch = notePatch(note, next);
|
|
71
|
+
if (patch) updates.push({ type: "updateNote", noteId: note.id, patch });
|
|
72
|
+
}
|
|
73
|
+
for (const note of b.notes) {
|
|
74
|
+
const previous = aNotes.get(note.id);
|
|
75
|
+
if (
|
|
76
|
+
previous &&
|
|
77
|
+
previous.trackId === note.trackId &&
|
|
78
|
+
bTracks.has(note.trackId)
|
|
79
|
+
)
|
|
80
|
+
continue;
|
|
81
|
+
if (
|
|
82
|
+
previous &&
|
|
83
|
+
previous.trackId === note.trackId &&
|
|
84
|
+
!aTracks.has(note.trackId)
|
|
85
|
+
)
|
|
86
|
+
continue;
|
|
87
|
+
additions.push({ type: "addNote", note });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const track of a.tracks)
|
|
91
|
+
if (!bTracks.has(track.id))
|
|
92
|
+
ops.push({ type: "removeTrack", trackId: track.id });
|
|
93
|
+
const order = a.tracks
|
|
94
|
+
.filter((track) => bTracks.has(track.id))
|
|
95
|
+
.map((t) => t.id);
|
|
96
|
+
for (const track of b.tracks)
|
|
97
|
+
if (!aTracks.has(track.id)) {
|
|
98
|
+
ops.push({ type: "addTrack", track });
|
|
99
|
+
order.push(track.id);
|
|
100
|
+
}
|
|
101
|
+
b.tracks.forEach((track, index) => {
|
|
102
|
+
if (order[index] === track.id) return;
|
|
103
|
+
const from = order.indexOf(track.id);
|
|
104
|
+
order.splice(from, 1);
|
|
105
|
+
order.splice(index, 0, track.id);
|
|
106
|
+
ops.push({ type: "moveTrack", trackId: track.id, index });
|
|
107
|
+
});
|
|
108
|
+
for (const track of b.tracks) {
|
|
109
|
+
const previous = aTracks.get(track.id);
|
|
110
|
+
if (!previous) continue;
|
|
111
|
+
const patch = trackPatch(previous, track);
|
|
112
|
+
if (patch) ops.push({ type: "updateTrack", trackId: track.id, patch });
|
|
113
|
+
}
|
|
114
|
+
ops.push(...updates, ...additions);
|
|
115
|
+
return Object.freeze(ops);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Applies operations in order; the inverse of `diffScores`. */
|
|
119
|
+
export function applyScoreOperations(
|
|
120
|
+
score: TrackScore,
|
|
121
|
+
operations: readonly ScoreOperation[],
|
|
122
|
+
): TrackScore {
|
|
123
|
+
return operations.reduce(applyScoreOperation, score);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function notePatch(
|
|
127
|
+
a: Note,
|
|
128
|
+
b: Note,
|
|
129
|
+
):
|
|
130
|
+
| Readonly<
|
|
131
|
+
Partial<Pick<Note, "startTick" | "durationTicks" | "pitch" | "velocity">>
|
|
132
|
+
>
|
|
133
|
+
| undefined {
|
|
134
|
+
const patch: Record<string, number> = {};
|
|
135
|
+
for (const key of [
|
|
136
|
+
"startTick",
|
|
137
|
+
"durationTicks",
|
|
138
|
+
"pitch",
|
|
139
|
+
"velocity",
|
|
140
|
+
] as const)
|
|
141
|
+
if (a[key] !== b[key]) patch[key] = b[key];
|
|
142
|
+
return Object.keys(patch).length > 0 ? patch : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function trackPatch(a: Track, b: Track): TrackPatch | undefined {
|
|
146
|
+
const patch: Record<string, unknown> = {};
|
|
147
|
+
for (const key of ["name", "instrument", "muted", "volume", "pan"] as const)
|
|
148
|
+
if (a[key] !== b[key]) patch[key] = b[key];
|
|
149
|
+
if ((a.solo ?? false) !== (b.solo ?? false)) patch.solo = b.solo ?? false;
|
|
150
|
+
for (const key of EFFECTS)
|
|
151
|
+
if (!deepEqual(a[key], b[key])) patch[key] = b[key] ?? null;
|
|
152
|
+
for (const key of LANES)
|
|
153
|
+
if (!deepEqual(a[key] ?? [], b[key] ?? [])) patch[key] = b[key] ?? [];
|
|
154
|
+
return Object.keys(patch).length > 0 ? (patch as TrackPatch) : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Structural equality for plain JSON-like data. */
|
|
158
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
159
|
+
if (Object.is(a, b)) return true;
|
|
160
|
+
if (
|
|
161
|
+
typeof a !== "object" ||
|
|
162
|
+
typeof b !== "object" ||
|
|
163
|
+
a === null ||
|
|
164
|
+
b === null
|
|
165
|
+
)
|
|
166
|
+
return false;
|
|
167
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
168
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
169
|
+
if (a.length !== b.length) return false;
|
|
170
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
171
|
+
}
|
|
172
|
+
const left = a as Record<string, unknown>;
|
|
173
|
+
const right = b as Record<string, unknown>;
|
|
174
|
+
const keys = Object.keys(left).filter((key) => left[key] !== undefined);
|
|
175
|
+
const other = Object.keys(right).filter((key) => right[key] !== undefined);
|
|
176
|
+
if (keys.length !== other.length) return false;
|
|
177
|
+
return keys.every((key) => deepEqual(left[key], right[key]));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Gives the notes of `evaluated` (fresh from `song.ts`, content-hash ids)
|
|
182
|
+
* the ids of matching notes in `current` (the session, whose ids the TUI
|
|
183
|
+
* and agent may have chosen) so `diffScores(current, result)` updates
|
|
184
|
+
* notes in place instead of replacing them. A note matches by exact
|
|
185
|
+
* content first, then by track, pitch and start.
|
|
186
|
+
*/
|
|
187
|
+
export function adoptNoteIds(
|
|
188
|
+
current: TrackScore,
|
|
189
|
+
evaluated: TrackScore,
|
|
190
|
+
): TrackScore {
|
|
191
|
+
const exact = new Map<string, Note[]>();
|
|
192
|
+
const loose = new Map<string, Note[]>();
|
|
193
|
+
for (const note of current.notes) {
|
|
194
|
+
push(exact, exactKey(note), note);
|
|
195
|
+
push(loose, looseKey(note), note);
|
|
196
|
+
}
|
|
197
|
+
const taken = new Set<string>();
|
|
198
|
+
const adopted: Note[] = [];
|
|
199
|
+
const pending: Note[] = [];
|
|
200
|
+
for (const note of evaluated.notes) {
|
|
201
|
+
const match = take(exact, exactKey(note), taken);
|
|
202
|
+
if (match) {
|
|
203
|
+
taken.add(match.id);
|
|
204
|
+
adopted.push(match.id === note.id ? note : { ...note, id: match.id });
|
|
205
|
+
} else pending.push(note);
|
|
206
|
+
}
|
|
207
|
+
for (const note of pending) {
|
|
208
|
+
const match = take(loose, looseKey(note), taken);
|
|
209
|
+
if (match) {
|
|
210
|
+
taken.add(match.id);
|
|
211
|
+
adopted.push({ ...note, id: match.id });
|
|
212
|
+
} else adopted.push(note);
|
|
213
|
+
}
|
|
214
|
+
// A fresh content id can collide with an adopted one; keep ids unique.
|
|
215
|
+
const used = new Set<string>();
|
|
216
|
+
const unique = adopted.map((note) => {
|
|
217
|
+
let id = note.id;
|
|
218
|
+
for (let n = 2; used.has(id); n += 1) id = `${note.id.slice(0, 60)}-${n}`;
|
|
219
|
+
used.add(id);
|
|
220
|
+
return id === note.id ? note : { ...note, id };
|
|
221
|
+
});
|
|
222
|
+
return new TrackScore({ ...evaluated.toJSON(), notes: unique });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function exactKey(note: Note): string {
|
|
226
|
+
return `${note.trackId}|${note.pitch}|${note.startTick}|${note.durationTicks}|${note.velocity}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function looseKey(note: Note): string {
|
|
230
|
+
return `${note.trackId}|${note.pitch}|${note.startTick}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function push(map: Map<string, Note[]>, key: string, note: Note): void {
|
|
234
|
+
const list = map.get(key);
|
|
235
|
+
if (list) list.push(note);
|
|
236
|
+
else map.set(key, [note]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function take(
|
|
240
|
+
map: Map<string, Note[]>,
|
|
241
|
+
key: string,
|
|
242
|
+
taken: Set<string>,
|
|
243
|
+
): Note | undefined {
|
|
244
|
+
const list = map.get(key);
|
|
245
|
+
if (!list) return undefined;
|
|
246
|
+
const index = list.findIndex((note) => !taken.has(note.id));
|
|
247
|
+
if (index < 0) return undefined;
|
|
248
|
+
return list.splice(index, 1)[0];
|
|
249
|
+
}
|
package/core/key.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key estimation from a 12-bin pitch-class histogram. Shared by session
|
|
3
|
+
* auto-naming (`src/session/naming.ts`) and audio analysis
|
|
4
|
+
* (`src/media/analyze.ts`), so both agree on what "a minor" means.
|
|
5
|
+
*/
|
|
6
|
+
export const KEY_NOTE_NAMES = [
|
|
7
|
+
"c",
|
|
8
|
+
"c#",
|
|
9
|
+
"d",
|
|
10
|
+
"eb",
|
|
11
|
+
"e",
|
|
12
|
+
"f",
|
|
13
|
+
"f#",
|
|
14
|
+
"g",
|
|
15
|
+
"ab",
|
|
16
|
+
"a",
|
|
17
|
+
"bb",
|
|
18
|
+
"b",
|
|
19
|
+
] as const;
|
|
20
|
+
const MAJOR = [0, 2, 4, 5, 7, 9, 11];
|
|
21
|
+
const MINOR = [0, 2, 3, 5, 7, 8, 10];
|
|
22
|
+
|
|
23
|
+
/** Best-fitting major or minor scale for a pitch-class histogram, or null. */
|
|
24
|
+
export function estimateKey(histogram: readonly number[]): string | null {
|
|
25
|
+
if (histogram.length < 12) return null;
|
|
26
|
+
const total = histogram.reduce((sum, value) => sum + value, 0);
|
|
27
|
+
if (!(total > 0)) return null;
|
|
28
|
+
let best: { key: string; score: number } | undefined;
|
|
29
|
+
for (let tonic = 0; tonic < 12; tonic += 1) {
|
|
30
|
+
for (const [mode, scale] of [
|
|
31
|
+
["major", MAJOR],
|
|
32
|
+
["minor", MINOR],
|
|
33
|
+
] as const) {
|
|
34
|
+
let fit = 0;
|
|
35
|
+
for (const step of scale) fit += histogram[(tonic + step) % 12]!;
|
|
36
|
+
// Tonic and fifth weigh extra so relative keys separate.
|
|
37
|
+
fit += histogram[tonic]! * 0.75 + histogram[(tonic + 7) % 12]! * 0.25;
|
|
38
|
+
if (!best || fit > best.score + 1e-9)
|
|
39
|
+
best = { key: `${KEY_NOTE_NAMES[tonic]} ${mode}`, score: fit };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return best!.key;
|
|
43
|
+
}
|
package/core/pitch.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pitch-name helpers shared by the command parser, the SDK and the printer.
|
|
3
|
+
*
|
|
4
|
+
* Names are scientific pitch notation: a letter `A`–`G`, an optional `#` or
|
|
5
|
+
* `b`, and an octave from -1 to 9 (`C4` = 60, `A4` = 69). Parsing is
|
|
6
|
+
* case-insensitive; printing always uses sharps so a name round-trips to the
|
|
7
|
+
* same MIDI number.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SEMITONES: Readonly<Record<string, number>> = Object.freeze({
|
|
11
|
+
c: 0,
|
|
12
|
+
d: 2,
|
|
13
|
+
e: 4,
|
|
14
|
+
f: 5,
|
|
15
|
+
g: 7,
|
|
16
|
+
a: 9,
|
|
17
|
+
b: 11,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const NAMES = Object.freeze([
|
|
21
|
+
"C",
|
|
22
|
+
"C#",
|
|
23
|
+
"D",
|
|
24
|
+
"D#",
|
|
25
|
+
"E",
|
|
26
|
+
"F",
|
|
27
|
+
"F#",
|
|
28
|
+
"G",
|
|
29
|
+
"G#",
|
|
30
|
+
"A",
|
|
31
|
+
"A#",
|
|
32
|
+
"B",
|
|
33
|
+
] as const);
|
|
34
|
+
|
|
35
|
+
/** MIDI number for a pitch name, or NaN when the name is malformed or out of range. */
|
|
36
|
+
export function pitchToMidi(value: string): number {
|
|
37
|
+
if (typeof value !== "string") return Number.NaN;
|
|
38
|
+
const match = value.trim().match(/^([a-gA-G])([#b]?)(-?\d{1,2})$/);
|
|
39
|
+
if (!match) return Number.NaN;
|
|
40
|
+
const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
|
|
41
|
+
const midi =
|
|
42
|
+
(Number(match[3]) + 1) * 12 +
|
|
43
|
+
(SEMITONES[match[1]!.toLowerCase()] ?? 0) +
|
|
44
|
+
accidental;
|
|
45
|
+
return midi >= 0 && midi <= 127 ? midi : Number.NaN;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pitch name with sharps for a MIDI number 0..127 (`60` → `C4`). */
|
|
49
|
+
export function midiToPitch(midi: number): string {
|
|
50
|
+
if (!Number.isInteger(midi) || midi < 0 || midi > 127)
|
|
51
|
+
throw new RangeError(`MIDI pitch out of range: ${String(midi)}`);
|
|
52
|
+
return `${NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when `value` is a MIDI integer 0..127 or a parseable pitch name. */
|
|
56
|
+
export function isPitch(value: unknown): value is number | string {
|
|
57
|
+
if (typeof value === "number")
|
|
58
|
+
return Number.isInteger(value) && value >= 0 && value <= 127;
|
|
59
|
+
return typeof value === "string" && Number.isFinite(pitchToMidi(value));
|
|
60
|
+
}
|