@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Sample playback for sampler tracks, with Strudel's semantics:
3
+ *
4
+ * - `begin`/`end` pick a 0..1 window of the file.
5
+ * - `speed` scales the playback rate (and so the pitch); negative reverses.
6
+ * - `loop` repeats the window (with a short crossfade) for the note's length.
7
+ * - `gain` scales the voice; velocity and track volume apply as for synths.
8
+ * - `choke` is Strudel's `cut`: a new hit in the same group stops the sounding
9
+ * voice with a short fade.
10
+ * - keyed mode repitches from `root` (rate × 2^((pitch − root)/12)) and holds
11
+ * for the note's length; oneshot mode maps voices to pitch slots from 36 in
12
+ * voice-name order and plays the whole window unless `loop` is set.
13
+ *
14
+ * Voices are mixed in mono into the track's dry buffer, so the track's
15
+ * filter, pan, delay, reverb and automation apply exactly as for synth tracks.
16
+ * Everything is plain arithmetic over decoded samples: identical files give
17
+ * identical output.
18
+ */
19
+ import {
20
+ SCORE_LIMITS,
21
+ samplerVoiceSlots,
22
+ type Note,
23
+ type SampleRef,
24
+ type Track,
25
+ type TrackScore,
26
+ } from "../../core/score.ts";
27
+ import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
28
+
29
+ /** Output level of a full-scale sample at velocity 1 and gain 1. */
30
+ export const SAMPLE_LEVEL = 0.7;
31
+ /** Anti-click fade-in on every voice start. */
32
+ const ATTACK_SECONDS = 0.001;
33
+ /** Fade at a natural end, a held release, a choke and a voice steal. */
34
+ const END_FADE_SECONDS = 0.003;
35
+ const RELEASE_SECONDS = 0.01;
36
+ const CHOKE_SECONDS = 0.005;
37
+ /** Loop seam crossfade (source time). */
38
+ const LOOP_CROSSFADE_SECONDS = 0.005;
39
+
40
+ export type SamplerTiming = Readonly<{
41
+ score: TrackScore;
42
+ sampleRate: number;
43
+ }>;
44
+
45
+ /** One planned voice: when it sounds and how it reads its sample. */
46
+ export type SamplerVoice = {
47
+ readonly voice: string;
48
+ readonly sample: DecodedSample;
49
+ readonly startTick: number;
50
+ readonly start: number;
51
+ /** Output frame the voice is silent from (exclusive). */
52
+ end: number;
53
+ /** Frames of linear fade ending at `end`. */
54
+ fade: number;
55
+ /** Source frames advanced per output frame (always positive). */
56
+ readonly step: number;
57
+ readonly reverse: boolean;
58
+ readonly loop: boolean;
59
+ /** Window in source frames. */
60
+ readonly regionStart: number;
61
+ readonly regionEnd: number;
62
+ readonly gain: number;
63
+ readonly choke: string | undefined;
64
+ };
65
+
66
+ function noteStartFrame(note: Note, timing: SamplerTiming): number {
67
+ const { score, sampleRate } = timing;
68
+ return Math.max(
69
+ 0,
70
+ Math.floor(
71
+ (((note.startTick / score.ticksPerBeat) * 60) / score.tempoBpm) *
72
+ sampleRate,
73
+ ),
74
+ );
75
+ }
76
+
77
+ function noteLengthFrames(note: Note, timing: SamplerTiming): number {
78
+ const { score, sampleRate } = timing;
79
+ return Math.max(
80
+ 1,
81
+ Math.floor(
82
+ (((note.durationTicks / score.ticksPerBeat) * 60) / score.tempoBpm) *
83
+ sampleRate,
84
+ ),
85
+ );
86
+ }
87
+
88
+ /** Which voice a note plays and the pitch ratio it plays at. */
89
+ function voiceResolver(
90
+ track: Track,
91
+ ): (pitch: number) => { voice: string; ratio: number } | undefined {
92
+ const sampler = track.sampler!;
93
+ if (sampler.mode === "oneshot") {
94
+ const byPitch = new Map<number, string>();
95
+ for (const [voice, slot] of samplerVoiceSlots(sampler))
96
+ byPitch.set(slot, voice);
97
+ return (pitch) => {
98
+ const voice = byPitch.get(pitch);
99
+ return voice === undefined ? undefined : { voice, ratio: 1 };
100
+ };
101
+ }
102
+ // Keyed: the voice whose root is the highest at or below the pitch (a
103
+ // multi-sampled instrument), else the lowest root.
104
+ const keyed = Object.entries(sampler.voices)
105
+ .map(([voice, ref]) => ({ voice, root: ref.root ?? 60 }))
106
+ .sort((a, b) => a.root - b.root || (a.voice < b.voice ? -1 : 1));
107
+ return (pitch) => {
108
+ let chosen = keyed[0];
109
+ for (const candidate of keyed)
110
+ if (candidate.root <= pitch) chosen = candidate;
111
+ if (!chosen) return undefined;
112
+ return { voice: chosen.voice, ratio: 2 ** ((pitch - chosen.root) / 12) };
113
+ };
114
+ }
115
+
116
+ /** Plans every voice a sampler track plays, with choke and polyphony applied. */
117
+ export function planSamplerVoices(
118
+ track: Track,
119
+ notes: readonly Note[],
120
+ bank: SampleBank,
121
+ timing: SamplerTiming,
122
+ ): SamplerVoice[] {
123
+ const sampler = track.sampler;
124
+ if (!sampler) return [];
125
+ const resolve = voiceResolver(track);
126
+ const ordered = [...notes].sort(
127
+ (a, b) =>
128
+ a.startTick - b.startTick ||
129
+ a.pitch - b.pitch ||
130
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
131
+ );
132
+ const { sampleRate } = timing;
133
+ const voices: SamplerVoice[] = [];
134
+ const lastInGroup = new Map<string, SamplerVoice>();
135
+ const maxVoices = SCORE_LIMITS.maxSamplerVoices;
136
+ const stop = (voice: SamplerVoice, at: number, seconds: number) => {
137
+ if (voice.end <= at) return;
138
+ const fade = Math.max(1, Math.round(seconds * sampleRate));
139
+ const end = Math.max(voice.start + 1, at + fade);
140
+ if (end >= voice.end) return;
141
+ voice.end = end;
142
+ voice.fade = Math.min(fade, end - voice.start);
143
+ };
144
+ for (const note of ordered) {
145
+ const target = resolve(note.pitch);
146
+ if (!target) continue;
147
+ const ref: SampleRef | undefined = sampler.voices[target.voice];
148
+ const sample = bank.voices.get(sampleKey(track.id, target.voice));
149
+ if (!ref || !sample) continue;
150
+ const speed = ref.speed ?? 1;
151
+ const step =
152
+ ((Math.abs(speed) * sample.sampleRate) / sampleRate) * target.ratio;
153
+ const regionStart = (ref.begin ?? 0) * sample.frames;
154
+ const regionEnd = (ref.end ?? 1) * sample.frames;
155
+ const natural = Math.max(1, Math.floor((regionEnd - regionStart) / step));
156
+ const held = noteLengthFrames(note, timing);
157
+ const release = Math.max(1, Math.round(RELEASE_SECONDS * sampleRate));
158
+ const loop = ref.loop === true;
159
+ let length: number;
160
+ let fade: number;
161
+ if (loop) {
162
+ length = held + release;
163
+ fade = release;
164
+ } else if (sampler.mode === "keyed" && held + release < natural) {
165
+ length = held + release;
166
+ fade = release;
167
+ } else {
168
+ length = natural;
169
+ fade = Math.max(1, Math.round(END_FADE_SECONDS * sampleRate));
170
+ }
171
+ const start = noteStartFrame(note, timing);
172
+ const voice: SamplerVoice = {
173
+ voice: target.voice,
174
+ sample,
175
+ startTick: note.startTick,
176
+ start,
177
+ end: start + length,
178
+ fade: Math.min(fade, length),
179
+ step,
180
+ reverse: speed < 0,
181
+ loop,
182
+ regionStart,
183
+ regionEnd,
184
+ gain: (ref.gain ?? 1) * Math.max(0, Math.min(1, note.velocity)),
185
+ choke: ref.choke,
186
+ };
187
+ if (voice.choke !== undefined) {
188
+ const previous = lastInGroup.get(voice.choke);
189
+ if (previous) stop(previous, start, CHOKE_SECONDS);
190
+ lastInGroup.set(voice.choke, voice);
191
+ }
192
+ // Polyphony: steal the oldest sounding voice beyond the limit.
193
+ const sounding = voices.filter((other) => other.end > start);
194
+ if (sounding.length >= maxVoices)
195
+ for (const other of sounding.slice(0, sounding.length - maxVoices + 1))
196
+ stop(other, start, CHOKE_SECONDS);
197
+ voices.push(voice);
198
+ }
199
+ return voices;
200
+ }
201
+
202
+ /** Linear-interpolated read, clamped to the sample. */
203
+ function readAt(mono: Float32Array, position: number): number {
204
+ const last = mono.length - 1;
205
+ if (position <= 0) return mono[0]!;
206
+ if (position >= last) return mono[last]!;
207
+ const base = Math.floor(position);
208
+ const a = mono[base]!;
209
+ return a + (mono[base + 1]! - a) * (position - base);
210
+ }
211
+
212
+ /**
213
+ * Mix planned voices into a mono buffer. `gainAt(tick)` is the track's
214
+ * volume (with automation) at a score tick.
215
+ */
216
+ export function renderSamplerVoices(
217
+ target: Float64Array,
218
+ voices: readonly SamplerVoice[],
219
+ timing: SamplerTiming,
220
+ gainAt: (tick: number) => number,
221
+ ): void {
222
+ const { sampleRate, score } = timing;
223
+ const samplesPerTick =
224
+ (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat);
225
+ const attack = Math.max(1, ATTACK_SECONDS * sampleRate);
226
+ for (const voice of voices) {
227
+ const { sample, step, regionStart, regionEnd, reverse, loop } = voice;
228
+ const mono = sample.mono;
229
+ const span = regionEnd - regionStart;
230
+ const crossfade = loop
231
+ ? Math.min(LOOP_CROSSFADE_SECONDS * sample.sampleRate, span / 4)
232
+ : 0;
233
+ const period = span - crossfade;
234
+ const end = Math.min(target.length, voice.end);
235
+ const fadeFrom = voice.end - voice.fade;
236
+ const level = SAMPLE_LEVEL * voice.gain;
237
+ for (let index = voice.start; index < end; index += 1) {
238
+ const elapsed = index - voice.start;
239
+ const travelled = elapsed * step;
240
+ let value: number;
241
+ if (!loop) {
242
+ if (travelled >= span) break;
243
+ value = readAt(
244
+ mono,
245
+ reverse ? regionEnd - 1 - travelled : regionStart + travelled,
246
+ );
247
+ } else {
248
+ const phase =
249
+ travelled < crossfade || period <= 0
250
+ ? travelled % Math.max(span, 1e-9)
251
+ : crossfade + ((travelled - crossfade) % period);
252
+ const at = (offset: number) =>
253
+ readAt(mono, reverse ? regionEnd - 1 - offset : regionStart + offset);
254
+ value = at(phase);
255
+ if (crossfade > 0 && phase >= period) {
256
+ const mix = (phase - period) / crossfade;
257
+ value = value * (1 - mix) + at(phase - period) * mix;
258
+ }
259
+ }
260
+ let envelope = Math.min(1, elapsed / attack);
261
+ if (index >= fadeFrom) envelope *= (voice.end - index) / voice.fade;
262
+ target[index]! +=
263
+ value *
264
+ envelope *
265
+ level *
266
+ gainAt(voice.startTick + elapsed / samplesPerTick);
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Seconds a sampler track rings past the loop end (bounded by the caller),
273
+ * so loop renders fold whole samples onto the start.
274
+ */
275
+ export function samplerTailSeconds(
276
+ score: TrackScore,
277
+ bank: SampleBank,
278
+ sampleRate: number,
279
+ ): number {
280
+ if (bank.voices.size === 0) return 0;
281
+ const loopEnd =
282
+ ((score.bars * score.beatsPerBar * 60) / score.tempoBpm) * sampleRate;
283
+ const timing = { score, sampleRate };
284
+ let latest = 0;
285
+ for (const track of score.tracks) {
286
+ if (!track.sampler) continue;
287
+ const notes = score.notes.filter((note) => note.trackId === track.id);
288
+ for (const voice of planSamplerVoices(track, notes, bank, timing))
289
+ latest = Math.max(latest, voice.end - loopEnd);
290
+ }
291
+ return latest / sampleRate;
292
+ }