@hraness/dawg 0.6.1 → 0.8.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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Audio clips (0.7): files placed on a track's timeline, summed into the
3
+ * track's dry buffer before its effect chain (sources.md section 7). The
4
+ * pure placement and section cuts live in core/clips.ts; this module reads
5
+ * the decoded audio, places it through the tempo map, resamples it to the
6
+ * render rate (4-point Hermite), applies the take's clock-drift stretch,
7
+ * reverse, gain and equal-power fades, and multiplies by the track's
8
+ * volume the way every voice does. A clip whose audio did not load renders
9
+ * silence (the loader reported it as a warning).
10
+ */
11
+ import {
12
+ CLIP_VOICE_PREFIX,
13
+ DEFAULT_CLIP_FADE,
14
+ clipSongTick,
15
+ resolveClipLengths,
16
+ } from "../../core/clips.ts";
17
+ import type { AudioClip, Take, Track, TrackScore } from "../../core/score.ts";
18
+ import { autotuneClip } from "./autotune.ts";
19
+ import { sampleKey, type DecodedSample, type SampleBank } from "./samples.ts";
20
+ import type { SampleWarp } from "./warp.ts";
21
+
22
+ /** What clip rendering reads of the render context. */
23
+ export type ClipContext = Readonly<{
24
+ sampleRate: number;
25
+ samples: number;
26
+ samplesPerTick: number;
27
+ warp?: SampleWarp;
28
+ }>;
29
+
30
+ /** True when a track has clips that sound. */
31
+ export function hasClips(track: Track | undefined): boolean {
32
+ return track?.clips?.some((clip) => !clip.mute) ?? false;
33
+ }
34
+
35
+ /** The decoded audio of a clip, if it loaded. */
36
+ export function clipAudio(
37
+ bank: SampleBank | undefined,
38
+ trackId: string,
39
+ clip: AudioClip,
40
+ ): DecodedSample | undefined {
41
+ if (!bank) return undefined;
42
+ const exact = bank.voices.get(
43
+ sampleKey(trackId, `${CLIP_VOICE_PREFIX}${clip.id}`),
44
+ );
45
+ if (exact) return exact;
46
+ // A section or form pass of a clip is `<id>~<n>`: it plays the audio
47
+ // the bank loaded for `<id>` from the unsliced score.
48
+ const pass = clip.id.lastIndexOf("~");
49
+ return pass > 0
50
+ ? bank.voices.get(
51
+ sampleKey(trackId, `${CLIP_VOICE_PREFIX}${clip.id.slice(0, pass)}`),
52
+ )
53
+ : undefined;
54
+ }
55
+
56
+ /**
57
+ * `score` with each loaded clip's missing `dur` resolved from its file
58
+ * (`resolveClipLengths`), so section, form and window cuts see its real
59
+ * end. A score without clips, or without a bank, comes back as it is.
60
+ */
61
+ export function withClipLengths(
62
+ score: TrackScore,
63
+ bank: SampleBank | undefined,
64
+ ): TrackScore {
65
+ if (!bank || !score.tracks.some((track) => track.clips)) return score;
66
+ return resolveClipLengths(score, (trackId, clip) => {
67
+ const audio = clipAudio(bank, trackId, clip);
68
+ return audio && audio.frames > 0
69
+ ? audio.frames / audio.sampleRate
70
+ : undefined;
71
+ });
72
+ }
73
+
74
+ /** Fractional render sample where a score tick sounds. */
75
+ function sampleAt(context: ClipContext, tick: number): number {
76
+ return context.warp
77
+ ? context.warp.sample(tick)
78
+ : tick * context.samplesPerTick;
79
+ }
80
+
81
+ /** The take a clip plays from, if any. */
82
+ function takeOf(track: Track, clip: AudioClip): Take | undefined {
83
+ if (clip.take === undefined) return undefined;
84
+ return track.takes?.find((take) => take.name === clip.take);
85
+ }
86
+
87
+ /** Catmull-Rom (4-point Hermite) read at a fractional frame, 0 outside. */
88
+ function readHermite(data: Float32Array, position: number): number {
89
+ const base = Math.floor(position);
90
+ const t = position - base;
91
+ const at = (index: number) =>
92
+ index >= 0 && index < data.length ? data[index]! : 0;
93
+ const y0 = at(base - 1);
94
+ const y1 = at(base);
95
+ const y2 = at(base + 1);
96
+ const y3 = at(base + 2);
97
+ const c1 = 0.5 * (y2 - y0);
98
+ const c2 = y0 - 2.5 * y1 + 2 * y2 - 0.5 * y3;
99
+ const c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
100
+ return ((c3 * t + c2) * t + c1) * t + y1;
101
+ }
102
+
103
+ /** Equal-power fade gain for `x` in 0..1 (sin of a quarter turn). */
104
+ export function equalPowerFade(x: number): number {
105
+ if (x <= 0) return 0;
106
+ if (x >= 1) return 1;
107
+ return Math.sin((x * Math.PI) / 2);
108
+ }
109
+
110
+ /**
111
+ * Sum `track`'s clips into `target` (the mono dry buffer). `gainAt(tick)`
112
+ * is the track volume with automation; `tickAt(sample)` maps a render
113
+ * sample back to a score tick for it. Returns the clips that did not
114
+ * load (they stay silent). With `score` and a `Track.autotune` (0.7), each
115
+ * clip plays retuned (`autotuneClip`, keyed by its offset and nudge);
116
+ * reversed clips play untuned.
117
+ */
118
+ export function renderClips(
119
+ target: Float64Array,
120
+ track: Track,
121
+ context: ClipContext,
122
+ bank: SampleBank | undefined,
123
+ gainAt: (tick: number) => number,
124
+ score?: TrackScore,
125
+ ): string[] {
126
+ const missing: string[] = [];
127
+ if (!track.clips) return missing;
128
+ const { sampleRate, samples } = context;
129
+ const tickAt = (index: number) =>
130
+ context.warp ? context.warp.tick(index) : index / context.samplesPerTick;
131
+ for (const clip of track.clips) {
132
+ if (clip.mute) continue;
133
+ const audio = clipAudio(bank, track.id, clip);
134
+ if (!audio || audio.frames === 0) {
135
+ missing.push(clip.id);
136
+ continue;
137
+ }
138
+ const take = takeOf(track, clip);
139
+ // A take recorded on a drifting clock (`ppm`) plays slightly faster or
140
+ // slower so it stays on the grid; `nudge` moves it in milliseconds.
141
+ const drift = 1 + (take?.ppm ?? 0) / 1e6;
142
+ const nudge = (take?.nudge ?? 0) / 1000;
143
+ const fileSeconds = audio.frames / audio.sampleRate;
144
+ const offset = Math.min(fileSeconds, clip.offset ?? 0);
145
+ const length = Math.max(
146
+ 0,
147
+ Math.min(
148
+ clip.dur ?? Number.POSITIVE_INFINITY,
149
+ (fileSeconds - offset) / drift,
150
+ ),
151
+ );
152
+ if (length <= 0) continue;
153
+ const start =
154
+ sampleAt(context, clipSongTick(clip, track.time)) + nudge * sampleRate;
155
+ const total = length * sampleRate;
156
+ const first = Math.max(0, Math.ceil(start));
157
+ const last = Math.min(samples, Math.ceil(start + total));
158
+ if (first >= last) continue;
159
+ const step = (audio.sampleRate / sampleRate) * drift;
160
+ const begin = offset * audio.sampleRate;
161
+ const span = length * audio.sampleRate * drift;
162
+ const fadeIn = Math.min(clip.fadeInTime ?? DEFAULT_CLIP_FADE, length / 2);
163
+ const fadeOut = Math.min(clip.fadeTime ?? DEFAULT_CLIP_FADE, length / 2);
164
+ const fadeInFrames = fadeIn * sampleRate;
165
+ const fadeOutFrames = fadeOut * sampleRate;
166
+ const gain = clip.gain ?? 1;
167
+ // 0.7 autotune: the clip's audio retuned at the song second its
168
+ // offset sounds (`start` already holds the nudge).
169
+ const tuned =
170
+ track.autotune && score && !clip.rev
171
+ ? autotuneClip(
172
+ score,
173
+ track,
174
+ {
175
+ sha256: audio.sha256,
176
+ sampleRate: audio.sampleRate,
177
+ mono: audio.mono,
178
+ },
179
+ start / sampleRate,
180
+ offset,
181
+ clip.id,
182
+ 0,
183
+ nudge,
184
+ )
185
+ : undefined;
186
+ const data = tuned ? tuned.mono : audio.mono;
187
+ const shiftFrom = tuned ? tuned.from : 0;
188
+ // Track volume (with automation) is read once per 32-sample block.
189
+ let blockGain = 0;
190
+ for (let index = first; index < last; index += 1) {
191
+ if ((index - first) % 32 === 0) blockGain = gainAt(tickAt(index));
192
+ const elapsed = index - start;
193
+ let position = elapsed * step;
194
+ if (position >= span) break;
195
+ position = clip.rev ? begin + span - 1 - position : begin + position;
196
+ let shape = gain * blockGain;
197
+ if (fadeInFrames > 0 && elapsed < fadeInFrames)
198
+ shape *= equalPowerFade(elapsed / fadeInFrames);
199
+ const remaining = total - elapsed;
200
+ if (fadeOutFrames > 0 && remaining < fadeOutFrames)
201
+ shape *= equalPowerFade(remaining / fadeOutFrames);
202
+ target[index]! += readHermite(data, position - shiftFrom) * shape;
203
+ }
204
+ }
205
+ return missing;
206
+ }
207
+
208
+ /**
209
+ * Stem-cache key part for a track's clips: each clip's sha256 (the one
210
+ * loaded, so an edited file re-renders), placement, cut, gain, fades and
211
+ * reverse, and the drift of the take it plays from.
212
+ */
213
+ export function clipsDigest(
214
+ track: Track,
215
+ bank: SampleBank | undefined,
216
+ guide = false,
217
+ ): string | undefined {
218
+ if (!track.clips || track.clips.length === 0) return undefined;
219
+ return (
220
+ (guide ? "guide|" : "") +
221
+ track.clips
222
+ .map((clip) => {
223
+ const take = takeOf(track, clip);
224
+ return [
225
+ clip.id,
226
+ clipAudio(bank, track.id, clip)?.sha256 ?? `missing:${clip.sha256}`,
227
+ clip.startTick,
228
+ clip.offset ?? "",
229
+ clip.dur ?? "",
230
+ clip.gain ?? "",
231
+ clip.fadeInTime ?? "",
232
+ clip.fadeTime ?? "",
233
+ clip.rev ? "r" : "",
234
+ clip.mute ? "m" : "",
235
+ take ? `${take.ppm ?? 0}/${take.nudge ?? 0}` : "",
236
+ ].join(",");
237
+ })
238
+ .join(";")
239
+ );
240
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The audio menu's one output and one input, saved per machine in
3
+ * `<config>/audio.json` (never in the project: a song moves between
4
+ * machines, its speakers do not). `DAWG_AUDIO_DEVICE` still overrides the
5
+ * output. Choosing a device needs the native sink, which lists and opens
6
+ * devices by name; sox can play on a named output (AUDIODEV) but cannot
7
+ * list them; ffplay and afplay always use the system default.
8
+ */
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { configDir } from "../auth/credentials.ts";
12
+ import type { AudioBackendInfo } from "./engine.ts";
13
+ import { NativeCapture, type SinkDevice, type SinkLibrary } from "./native.ts";
14
+
15
+ export type AudioChoice = Readonly<{
16
+ /** Output device name; absent is the system default. */
17
+ output?: string;
18
+ /** Input device name; absent is the system default. */
19
+ input?: string;
20
+ }>;
21
+
22
+ const MAX_NAME = 200;
23
+
24
+ export function audioChoicePath(
25
+ env: Readonly<Record<string, string | undefined>> = process.env,
26
+ ): string {
27
+ return join(configDir(env), "audio.json");
28
+ }
29
+
30
+ function cleanName(value: unknown): string | undefined {
31
+ if (typeof value !== "string") return undefined;
32
+ const name = value.trim();
33
+ return name && name.length <= MAX_NAME && name.toLowerCase() !== "default"
34
+ ? name
35
+ : undefined;
36
+ }
37
+
38
+ /** The saved choice; a missing or malformed file is the defaults. */
39
+ export function readAudioChoice(path = audioChoicePath()): AudioChoice {
40
+ try {
41
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
42
+ if (typeof parsed !== "object" || parsed === null) return {};
43
+ const record = parsed as Record<string, unknown>;
44
+ const output = cleanName(record.output);
45
+ const input = cleanName(record.input);
46
+ return { ...(output ? { output } : {}), ...(input ? { input } : {}) };
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /** Save one side of the choice (undefined is the system default). */
53
+ export function writeAudioChoice(
54
+ side: "output" | "input",
55
+ name: string | undefined,
56
+ path = audioChoicePath(),
57
+ ): AudioChoice {
58
+ const current = readAudioChoice(path);
59
+ const next: Record<string, string> = { ...current };
60
+ const clean = cleanName(name);
61
+ if (clean) next[side] = clean;
62
+ else delete next[side];
63
+ mkdirSync(join(path, ".."), { recursive: true });
64
+ const temp = `${path}.${process.pid}.tmp`;
65
+ writeFileSync(temp, `${JSON.stringify({ version: 1, ...next }, null, 2)}\n`);
66
+ renameSync(temp, path);
67
+ return next;
68
+ }
69
+
70
+ /** The output the engine opens: DAWG_AUDIO_DEVICE, then the saved choice. */
71
+ export function resolveOutputDevice(
72
+ env: Readonly<Record<string, string | undefined>> = process.env,
73
+ choice: AudioChoice = readAudioChoice(audioChoicePath(env)),
74
+ ): string | undefined {
75
+ return cleanName(env.DAWG_AUDIO_DEVICE) ?? choice.output;
76
+ }
77
+
78
+ /** What the backend can do with a device choice. */
79
+ export type DeviceSupport =
80
+ | Readonly<{ kind: "native"; library: SinkLibrary }>
81
+ | Readonly<{ kind: "sox" }>
82
+ | Readonly<{ kind: "none"; reason: string }>;
83
+
84
+ export function deviceSupport(info: AudioBackendInfo): DeviceSupport {
85
+ if (info.backend === "native" && info.native)
86
+ return { kind: "native", library: info.native };
87
+ if (info.backend === "sox") return { kind: "sox" };
88
+ const why = info.nativeUnavailable ?? info.detail;
89
+ const player =
90
+ info.backend === "none"
91
+ ? "audio is off"
92
+ : `${info.backend} plays on the system default`;
93
+ return {
94
+ kind: "none",
95
+ reason: `device choice needs the native sink, which lists and opens devices by name · ${player} · native sink: ${why}`,
96
+ };
97
+ }
98
+
99
+ /** Device names on the native sink; empty when enumeration fails. */
100
+ export function listDevices(
101
+ library: SinkLibrary,
102
+ input: boolean,
103
+ ): SinkDevice[] {
104
+ try {
105
+ return library.devices(input);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ export type AudioCommand =
112
+ | Readonly<{ kind: "show" }>
113
+ | Readonly<{
114
+ kind: "set";
115
+ side: "output" | "input";
116
+ name: string | undefined;
117
+ }>
118
+ | Readonly<{ kind: "test" }>;
119
+
120
+ /**
121
+ * `audio`, `audio out <name|default>`, `audio in <name|default>`,
122
+ * `audio test`. Undefined when the words do not parse.
123
+ */
124
+ export function parseAudioCommand(text: string): AudioCommand | undefined {
125
+ const words = text.trim();
126
+ if (!words) return { kind: "show" };
127
+ if (/^test$/i.test(words)) return { kind: "test" };
128
+ const set = words.match(/^(out|output|in|input)\s+(.+)$/i);
129
+ if (!set) return undefined;
130
+ const name = set[2]!.trim();
131
+ if (name.length > MAX_NAME) return undefined;
132
+ return {
133
+ kind: "set",
134
+ side: set[1]!.toLowerCase().startsWith("o") ? "output" : "input",
135
+ name: name.toLowerCase() === "default" ? undefined : name,
136
+ };
137
+ }
138
+
139
+ /** Match a typed name to a listed device: exact, then case-insensitive, then prefix. */
140
+ export function matchDevice(
141
+ devices: readonly SinkDevice[],
142
+ name: string,
143
+ ): SinkDevice | undefined {
144
+ const lower = name.toLowerCase();
145
+ return (
146
+ devices.find((device) => device.name === name) ??
147
+ devices.find((device) => device.name.toLowerCase() === lower) ??
148
+ (() => {
149
+ const prefixed = devices.filter((device) =>
150
+ device.name.toLowerCase().startsWith(lower),
151
+ );
152
+ return prefixed.length === 1 ? prefixed[0] : undefined;
153
+ })()
154
+ );
155
+ }
156
+
157
+ /** `Speakers` or `default (Speakers)`. */
158
+ export function deviceLabel(
159
+ name: string | undefined,
160
+ devices: readonly SinkDevice[],
161
+ ): string {
162
+ if (name) return name;
163
+ const system = devices.find((device) => device.default);
164
+ return system ? `default (${system.name})` : "default";
165
+ }
166
+
167
+ const BLIP_SECONDS = 0.12;
168
+
169
+ /** A short, soft sine blip (or a test tone): interleaved stereo. */
170
+ export function blipSamples(
171
+ rate: number,
172
+ seconds = BLIP_SECONDS,
173
+ hz = 880,
174
+ gain = 0.12,
175
+ ): Float32Array {
176
+ const frames = Math.round(rate * seconds);
177
+ const out = new Float32Array(frames * 2);
178
+ const ramp = Math.max(1, Math.round(rate * 0.01));
179
+ for (let frame = 0; frame < frames; frame += 1) {
180
+ const envelope = Math.min(1, frame / ramp, (frames - 1 - frame) / ramp);
181
+ const value = Math.sin((2 * Math.PI * hz * frame) / rate) * gain * envelope;
182
+ out[frame * 2] = value;
183
+ out[frame * 2 + 1] = value;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Play `samples` once on `device` through its own short-lived stream, so
190
+ * the song's player is untouched. Resolves after the sound has played.
191
+ */
192
+ export async function playOnce(
193
+ library: SinkLibrary,
194
+ device: string | undefined,
195
+ samples: Float32Array,
196
+ rate = 48_000,
197
+ wait: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
198
+ ): Promise<boolean> {
199
+ const endpoint = library.openOutput({
200
+ ...(device ? { device } : {}),
201
+ rate,
202
+ channels: 2,
203
+ bufferFrames: 0,
204
+ ringFrames: samples.length / 2 + rate,
205
+ });
206
+ if (!endpoint) return false;
207
+ try {
208
+ endpoint.write(samples);
209
+ await wait(Math.ceil((samples.length / 2 / rate) * 1000) + 80);
210
+ return true;
211
+ } finally {
212
+ endpoint.close();
213
+ }
214
+ }
215
+
216
+ /** Peak and RMS of interleaved samples, in dBFS (-Infinity for silence). */
217
+ export function inputLevel(samples: Float32Array): {
218
+ peakDb: number;
219
+ rmsDb: number;
220
+ } {
221
+ let peak = 0;
222
+ let sum = 0;
223
+ for (const sample of samples) {
224
+ const value = Math.abs(sample);
225
+ if (value > peak) peak = value;
226
+ sum += sample * sample;
227
+ }
228
+ const rms = samples.length > 0 ? Math.sqrt(sum / samples.length) : 0;
229
+ const db = (value: number) =>
230
+ value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
231
+ return { peakDb: db(peak), rmsDb: db(rms) };
232
+ }
233
+
234
+ /** `▇▇▇▇▅▁▁▁▁▁ -18 dBFS`: -60…0 dBFS across ten cells. */
235
+ export function levelMeter(db: number, cells = 10): string {
236
+ if (!Number.isFinite(db)) return `${"▁".repeat(cells)} silent`;
237
+ const fill = Math.max(0, Math.min(1, (db + 60) / 60)) * cells;
238
+ let bar = "";
239
+ for (let cell = 0; cell < cells; cell += 1)
240
+ bar += fill >= cell + 1 ? "▇" : fill > cell + 0.5 ? "▅" : "▁";
241
+ return `${bar} ${Math.round(db)} dBFS`;
242
+ }
243
+
244
+ /** Capture `seconds` from the input and measure it. */
245
+ export async function measureInput(
246
+ library: SinkLibrary,
247
+ device: string | undefined,
248
+ seconds = 1,
249
+ rate = 48_000,
250
+ wait: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
251
+ ): Promise<{ peakDb: number; rmsDb: number }> {
252
+ const capture = NativeCapture.open(library, {
253
+ rate,
254
+ channels: 1,
255
+ ...(device ? { device } : {}),
256
+ });
257
+ let samples: Float32Array;
258
+ try {
259
+ await wait(seconds * 1000);
260
+ } finally {
261
+ samples = capture.close();
262
+ }
263
+ return inputLevel(samples);
264
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `dawg doctor`: how dawg makes sound on this machine. The backend and why
3
+ * it was chosen, the native sink's verification (or why it fell back), the
4
+ * audio devices, the output and input saved from the audio menu, and the
5
+ * play-mode lead.
6
+ */
7
+ import {
8
+ AudioEngine,
9
+ detectAudioBackend,
10
+ type AudioBackendInfo,
11
+ } from "./engine.ts";
12
+ import {
13
+ audioChoicePath,
14
+ matchDevice,
15
+ readAudioChoice,
16
+ type AudioChoice,
17
+ } from "./devices.ts";
18
+ import { PREBUILT_DIR, nativeTarget, type SinkDevice } from "./native.ts";
19
+ import { DEFAULT_SAMPLE_RATE } from "./wav.ts";
20
+
21
+ export type DoctorReport = Readonly<{
22
+ backend: AudioBackendInfo["backend"];
23
+ detail: string;
24
+ native: Readonly<{
25
+ target: string | null;
26
+ dir: string;
27
+ loaded: boolean;
28
+ reason?: string;
29
+ }>;
30
+ playLeadMs: number;
31
+ outputs: readonly string[];
32
+ inputs: readonly string[];
33
+ /**
34
+ * The audio menu's saved choice (`audio out`/`audio in`): a name, or
35
+ * `default`; `missing` when the native sink lists devices and the saved
36
+ * one is not among them (playback falls back to the default).
37
+ */
38
+ chosen: Readonly<{
39
+ output: string;
40
+ input: string;
41
+ path: string;
42
+ /** `DAWG_AUDIO_DEVICE` overrides the saved output. */
43
+ override?: string;
44
+ missing?: readonly ("output" | "input")[];
45
+ }>;
46
+ }>;
47
+
48
+ export type DoctorOptions = Readonly<{
49
+ env?: Readonly<Record<string, string | undefined>>;
50
+ choice?: AudioChoice;
51
+ }>;
52
+
53
+ export function audioDoctor(
54
+ info = detectAudioBackend(),
55
+ options: DoctorOptions = {},
56
+ ): DoctorReport {
57
+ const env = options.env ?? process.env;
58
+ const path = audioChoicePath(env);
59
+ const choice = options.choice ?? readAudioChoice(path);
60
+ const engine = new AudioEngine({ info, worker: false, timer: false });
61
+ const playLeadMs = engine.playLeadMs;
62
+ void engine.dispose();
63
+ const devices = (input: boolean): SinkDevice[] | undefined => {
64
+ try {
65
+ return info.native?.devices(input);
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ };
70
+ const outputDevices = devices(false);
71
+ const inputDevices = devices(true);
72
+ const list = (found: readonly SinkDevice[] | undefined) =>
73
+ found?.map(
74
+ (d) =>
75
+ `${d.name}${d.default ? " (default)" : ""} · ${d.channels} ch · ${d.rate} Hz`,
76
+ ) ?? [];
77
+ const override = env.DAWG_AUDIO_DEVICE?.trim() || undefined;
78
+ const missing: ("output" | "input")[] = [];
79
+ const isMissing = (
80
+ name: string | undefined,
81
+ found: readonly SinkDevice[] | undefined,
82
+ ) => !!name && !!found && found.length > 0 && !matchDevice(found, name);
83
+ if (isMissing(override ?? choice.output, outputDevices))
84
+ missing.push("output");
85
+ if (isMissing(choice.input, inputDevices)) missing.push("input");
86
+ return {
87
+ backend: info.backend,
88
+ detail: info.detail,
89
+ native: {
90
+ target: nativeTarget() ?? null,
91
+ dir: PREBUILT_DIR,
92
+ loaded: info.backend === "native",
93
+ ...(info.nativeUnavailable ? { reason: info.nativeUnavailable } : {}),
94
+ },
95
+ playLeadMs,
96
+ outputs: list(outputDevices),
97
+ inputs: list(inputDevices),
98
+ chosen: {
99
+ output: choice.output ?? "default",
100
+ input: choice.input ?? "default",
101
+ path,
102
+ ...(override ? { override } : {}),
103
+ ...(missing.length > 0 ? { missing } : {}),
104
+ },
105
+ };
106
+ }
107
+
108
+ /** `chosen: output AirPods · input default`, with what overrides or is gone. */
109
+ function chosenLines(chosen: DoctorReport["chosen"]): string[] {
110
+ const missing = new Set(chosen.missing ?? []);
111
+ const side = (label: "output" | "input", name: string) =>
112
+ `${label} ${name}${missing.has(label) ? " (missing, using default)" : ""}`;
113
+ const lines = [
114
+ `chosen: ${side("output", chosen.override ?? chosen.output)} · ${side("input", chosen.input)} · ${chosen.path}`,
115
+ ];
116
+ if (chosen.override)
117
+ lines.push(
118
+ ` DAWG_AUDIO_DEVICE overrides the saved output (${chosen.output})`,
119
+ );
120
+ lines.push(" change it: audio out <name|default> · audio in <name|default>");
121
+ return lines;
122
+ }
123
+
124
+ export function formatAudioDoctor(report: DoctorReport): string[] {
125
+ const lines = [
126
+ `audio: ${report.backend} · ${report.detail}`,
127
+ report.native.loaded
128
+ ? `native sink: loaded (${report.native.target})`
129
+ : `native sink: not in use · ${report.native.reason ?? "another backend was forced"} · falls back to ffplay, sox or afplay`,
130
+ `play lead: ${report.playLeadMs} ms · render rate ${DEFAULT_SAMPLE_RATE} Hz`,
131
+ ...chosenLines(report.chosen),
132
+ ];
133
+ if (report.outputs.length > 0)
134
+ lines.push("outputs:", ...report.outputs.map((d) => ` ${d}`));
135
+ if (report.inputs.length > 0)
136
+ lines.push("inputs:", ...report.inputs.map((d) => ` ${d}`));
137
+ return lines;
138
+ }
139
+
140
+ export async function runDoctorCommand(
141
+ args: readonly string[],
142
+ stdout: { write(text: string): unknown },
143
+ ): Promise<number> {
144
+ if (args.some((arg) => arg === "--help" || arg === "-h")) {
145
+ stdout.write(
146
+ "usage: dawg doctor [--json] audio backend, native sink, devices, saved choice\n",
147
+ );
148
+ return 0;
149
+ }
150
+ const report = audioDoctor();
151
+ stdout.write(
152
+ args.includes("--json")
153
+ ? `${JSON.stringify(report, null, 2)}\n`
154
+ : `${formatAudioDoctor(report).join("\n")}\n`,
155
+ );
156
+ return 0;
157
+ }