@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
@@ -14,8 +14,22 @@ import { measureLoudness, pcmChannels } from "./loudness.ts";
14
14
  import type { MasterReport } from "./master.ts";
15
15
  import { levelOf, type SoundLevel } from "./preview.ts";
16
16
  import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
17
+ import { statSync } from "node:fs";
18
+ import {
19
+ audioChoicePath,
20
+ readAudioChoice,
21
+ resolveOutputDevice,
22
+ } from "./devices.ts";
17
23
  import { playbackTime } from "./arrange.ts";
18
24
  import { transportMapFor, type TransportMap } from "./clock.ts";
25
+ import {
26
+ openNativePlayer,
27
+ probeNativeSink,
28
+ type AudioTiming,
29
+ type NativeProbe,
30
+ type PlayerClock,
31
+ type SinkLibrary,
32
+ } from "./native.ts";
19
33
 
20
34
  /**
21
35
  * How dawgd (or a file-mode window) makes sound.
@@ -30,7 +44,8 @@ import { transportMapFor, type TransportMap } from "./clock.ts";
30
44
  * s16le from stdin; tests use it to record the exact byte stream.
31
45
  * - `none` makes no sound; the transport clock still runs.
32
46
  */
33
- export type AudioBackend = "ffplay" | "sox" | "afplay" | "command" | "none";
47
+ export type AudioBackend =
48
+ "native" | "ffplay" | "sox" | "afplay" | "command" | "none";
34
49
 
35
50
  export type AudioBackendInfo = Readonly<{
36
51
  backend: AudioBackend;
@@ -40,6 +55,10 @@ export type AudioBackendInfo = Readonly<{
40
55
  command?: readonly string[];
41
56
  /** Human-readable reason the backend was chosen. */
42
57
  detail: string;
58
+ /** The loaded sink, for the `native` backend. */
59
+ native?: SinkLibrary;
60
+ /** Why the native sink is not in use, when another backend was picked. */
61
+ nativeUnavailable?: string;
43
62
  }>;
44
63
 
45
64
  type Which = (binary: string) => string | null;
@@ -49,8 +68,26 @@ export type DetectOptions = Readonly<{
49
68
  which?: Which;
50
69
  platform?: NodeJS.Platform;
51
70
  sampleRate?: number;
71
+ /** Native sink probe; `false` skips it (tests). Default: the real one. */
72
+ native?: (() => NativeProbe) | false;
52
73
  }>;
53
74
 
75
+ let cachedProbe: NativeProbe | undefined;
76
+
77
+ /**
78
+ * Replace the process's native probe (a test preload's fake sink, so a PTY
79
+ * run can list and switch devices without hardware).
80
+ */
81
+ export function setNativeProbe(probe: NativeProbe | undefined): void {
82
+ cachedProbe = probe;
83
+ }
84
+
85
+ /** The real probe, once per process (dlopen is not repeated). */
86
+ function defaultNativeProbe(): NativeProbe {
87
+ cachedProbe ??= probeNativeSink();
88
+ return cachedProbe;
89
+ }
90
+
54
91
  /** Streaming argv for the known stdin players. */
55
92
  export function streamingCommand(
56
93
  backend: "ffplay" | "sox",
@@ -99,8 +136,9 @@ export function streamingCommand(
99
136
 
100
137
  /**
101
138
  * Pick the audio backend. Order: DAWG_AUDIO=0 (none), DAWG_AUDIO_PLAYER
102
- * (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then ffplay,
103
- * sox `play`, and afplay (macOS) in that order.
139
+ * (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then the
140
+ * native sink, ffplay, sox `play`, and afplay (macOS) in that order. When
141
+ * the native sink cannot load, the fallback's detail says why.
104
142
  */
105
143
  export function detectAudioBackend(
106
144
  options: DetectOptions = {},
@@ -128,13 +166,40 @@ export function detectAudioBackend(
128
166
  }
129
167
  const forced = env.DAWG_AUDIO_BACKEND?.trim().toLowerCase();
130
168
  const candidates: AudioBackend[] =
169
+ forced === "native" ||
131
170
  forced === "ffplay" ||
132
171
  forced === "sox" ||
133
172
  forced === "afplay" ||
134
173
  forced === "none"
135
174
  ? [forced]
136
- : ["ffplay", "sox", "afplay"];
175
+ : ["native", "ffplay", "sox", "afplay"];
176
+ let unavailable: string | undefined;
177
+ const why = (info: AudioBackendInfo): AudioBackendInfo =>
178
+ unavailable
179
+ ? {
180
+ ...info,
181
+ detail: `${info.detail} · native sink unavailable: ${unavailable}`,
182
+ nativeUnavailable: unavailable,
183
+ }
184
+ : info;
137
185
  for (const candidate of candidates) {
186
+ if (candidate === "native") {
187
+ if (options.native === false) continue;
188
+ const probe = options.native
189
+ ? options.native()
190
+ : env === process.env && options.platform === undefined
191
+ ? defaultNativeProbe()
192
+ : probeNativeSink({ env, platform });
193
+ if (probe.ok)
194
+ return {
195
+ backend: "native",
196
+ streaming: true,
197
+ native: probe.library,
198
+ detail: `native sink ${probe.detail} (device callback)`,
199
+ };
200
+ unavailable = probe.reason;
201
+ continue;
202
+ }
138
203
  if (candidate === "none")
139
204
  return {
140
205
  backend: "none",
@@ -144,26 +209,26 @@ export function detectAudioBackend(
144
209
  if (candidate === "ffplay" || candidate === "sox") {
145
210
  const binary = which(candidate === "ffplay" ? "ffplay" : "play");
146
211
  if (!binary) continue;
147
- return {
212
+ return why({
148
213
  backend: candidate,
149
214
  streaming: true,
150
215
  command: streamingCommand(candidate, binary, sampleRate),
151
216
  detail: `${binary} (gapless stream)`,
152
- };
217
+ });
153
218
  }
154
219
  const binary = platform === "darwin" ? which("afplay") : null;
155
220
  if (binary)
156
- return {
221
+ return why({
157
222
  backend: "afplay",
158
223
  streaming: false,
159
224
  detail: `${binary} (re-render on edit; install ffmpeg or sox for gapless playback)`,
160
- };
225
+ });
161
226
  }
162
- return {
227
+ return why({
163
228
  backend: "none",
164
229
  streaming: false,
165
230
  detail: "no player found (install ffmpeg or sox)",
166
- };
231
+ });
167
232
  }
168
233
 
169
234
  /** One status line for `dawg auth status` and the header. */
@@ -205,8 +270,21 @@ type PlayerProcess = {
205
270
  readonly stdin: Sink;
206
271
  readonly exited: Promise<number>;
207
272
  kill(): void;
273
+ /** Device-clocked players (the native sink) pace the pump themselves. */
274
+ readonly clock?: PlayerClock;
208
275
  };
209
276
 
277
+ /** Play-mode lead on the native sink: the ring only covers pump jitter. */
278
+ export const NATIVE_PLAY_LEAD_MS = 15;
279
+ /** Pump interval on the native sink. */
280
+ export const NATIVE_TICK_MS = 5;
281
+
282
+ function envNumber(value: string | undefined): number | undefined {
283
+ if (value === undefined || value.trim() === "") return undefined;
284
+ const number = Number(value);
285
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
286
+ }
287
+
210
288
  export type AudioEngineOptions = Readonly<{
211
289
  lockPath?: string;
212
290
  info?: AudioBackendInfo;
@@ -228,19 +306,60 @@ export type AudioEngineOptions = Readonly<{
228
306
  respawnMs?: number;
229
307
  /** Player lifecycle notices worth a status line. */
230
308
  onStatus?: (status: AudioStatus) => void;
309
+ /** Play-mode lead (default 60 ms; 15 ms on the native sink). */
310
+ playLeadMs?: number;
311
+ /**
312
+ * Output device name (native sink; sox via AUDIODEV). Default:
313
+ * DAWG_AUDIO_DEVICE, then the saved choice (src/audio/devices.ts), then
314
+ * the system default. `null` forces the system default.
315
+ */
316
+ device?: string | null;
317
+ /**
318
+ * The saved choice to follow (`<config>/audio.json`): checked about once
319
+ * a second while playing and before each player starts, so a choice made
320
+ * in another window or the daemon's client moves playback. Defaults to
321
+ * the real file when the backend is auto-detected (no `info`, no
322
+ * `spawn`, no `device`); `false` never follows one.
323
+ */
324
+ choicePath?: string | false;
325
+ /** Native sink device buffer frames (default 128; 0 = device default). */
326
+ bufferFrames?: number;
231
327
  }>;
232
328
 
233
329
  export type AudioStatus = Readonly<{
234
- /** `restarting`: the player died and is being respawned; `stopped`: gave up. */
235
- state: "restarting" | "stopped";
330
+ /**
331
+ * `restarting`: the player died and is being respawned; `stopped`: gave
332
+ * up; `device`: the chosen output is gone, playback moved to the default.
333
+ */
334
+ state: "restarting" | "stopped" | "device";
236
335
  message: string;
237
336
  }>;
238
337
 
239
338
  /** Respawns of a dying player before playback is declared stopped. */
240
339
  const MAX_RESPAWNS = 3;
340
+ /** How often a playing engine checks the saved device choice. */
341
+ const CHOICE_CHECK_MS = 1000;
342
+
343
+ function fileMtime(path: string): number {
344
+ try {
345
+ return statSync(path).mtimeMs;
346
+ } catch {
347
+ return 0;
348
+ }
349
+ }
350
+
351
+ function cleanEnvDevice(): string | undefined {
352
+ return resolveOutputDevice(process.env, {});
353
+ }
241
354
  /** A player that ran this long before dying resets the respawn budget. */
242
355
  const RESPAWN_RESET_MS = 10_000;
243
- /** Lead grows to this multiple of the last render time. */
356
+ /**
357
+ * Default queue lead: the edit-to-audible floor while playing. 100 ms leaves
358
+ * 80 ms of slack over the 20 ms pump tick for main-thread stalls (a frame
359
+ * costs about 1 ms; play mode already runs at 60 ms).
360
+ */
361
+ const DEFAULT_LEAD_MS = 100;
362
+ /** Inline renders grow the lead to this multiple of the last render time. */
244
363
  const LEAD_RENDER_FACTOR = 1.5;
245
364
 
246
365
  type Loop = Readonly<{
@@ -334,8 +453,8 @@ type PlayRequest = {
334
453
  *
335
454
  * Timing: the engine is paced by the same monotonic clock as the transport.
336
455
  * Frame `n` of the stream belongs to wall time `start + n / rate`, and the
337
- * engine keeps a lead of audio queued ahead of now: at least `leadMs`, and
338
- * 1.5x the last render time when renders are slower than that. The device
456
+ * engine keeps a lead of audio queued ahead of now: `leadMs`, or 1.5x the
457
+ * last render time when renders run inline (no worker) and are slower. The device
339
458
  * plays the queue at its own rate, so what you hear trails the transport by
340
459
  * the player's fixed output latency and never accumulates drift from
341
460
  * re-renders. Renders run in a worker; an edit re-renders off-thread and
@@ -351,9 +470,18 @@ export class AudioEngine {
351
470
  private leadFrames: number;
352
471
  private readonly defaultLeadFrames: number;
353
472
  private readonly tickMs: number;
473
+ /** The lead play mode and auditions pin (see `setLeadMs`). */
474
+ public readonly playLeadMs: number;
354
475
  private readonly now: () => number;
355
476
  private readonly useTimer: boolean;
356
- private readonly spawnPlayer: (command: readonly string[]) => PlayerProcess;
477
+ private readonly customSpawn:
478
+ ((command: readonly string[]) => PlayerProcess) | undefined;
479
+ private readonly bufferFrames: number;
480
+ /** The output device the next player opens (undefined: system default). */
481
+ private outputDevice: string | undefined;
482
+ private readonly choicePath: string | false;
483
+ private choiceMtime: number;
484
+ private choiceCheckedMs = Number.NEGATIVE_INFINITY;
357
485
  private readonly renderer: LoopRenderer;
358
486
  private readonly respawnMs: number;
359
487
  private readonly onStatus: ((status: AudioStatus) => void) | undefined;
@@ -401,17 +529,40 @@ export class AudioEngine {
401
529
  options.info ?? detectAudioBackend({ sampleRate: this.sampleRate });
402
530
  this.lock = new PlaybackLock(options.lockPath);
403
531
  this.leadFrames = Math.round(
404
- ((options.leadMs ?? 200) * this.sampleRate) / 1000,
532
+ ((options.leadMs ?? DEFAULT_LEAD_MS) * this.sampleRate) / 1000,
405
533
  );
406
534
  this.defaultLeadFrames = this.leadFrames;
407
535
  this.fadeFrames = Math.max(
408
536
  1,
409
537
  Math.round(SWAP_FADE_SECONDS * this.sampleRate),
410
538
  );
411
- this.tickMs = options.tickMs ?? 20;
539
+ const native =
540
+ this.info.backend === "native" ? this.info.native : undefined;
541
+ this.tickMs = options.tickMs ?? (native ? NATIVE_TICK_MS : 20);
542
+ this.playLeadMs =
543
+ options.playLeadMs ??
544
+ envNumber(process.env.DAWG_PLAY_LEAD_MS) ??
545
+ (native ? NATIVE_PLAY_LEAD_MS : 60);
412
546
  this.now = options.now ?? (() => performance.now());
413
547
  this.useTimer = options.timer ?? true;
414
- this.spawnPlayer = options.spawn ?? spawnStdinPlayer;
548
+ this.choicePath =
549
+ options.choicePath ??
550
+ (options.info === undefined &&
551
+ options.spawn === undefined &&
552
+ options.device === undefined
553
+ ? audioChoicePath()
554
+ : false);
555
+ this.outputDevice =
556
+ options.device === null
557
+ ? undefined
558
+ : ((options.device?.trim() || undefined) ??
559
+ (this.choicePath
560
+ ? resolveOutputDevice(process.env, readAudioChoice(this.choicePath))
561
+ : cleanEnvDevice()));
562
+ this.choiceMtime = this.choicePath ? fileMtime(this.choicePath) : 0;
563
+ this.bufferFrames =
564
+ options.bufferFrames ?? envNumber(process.env.DAWG_AUDIO_BUFFER) ?? 128;
565
+ this.customSpawn = options.spawn;
415
566
  this.respawnMs = options.respawnMs ?? 250;
416
567
  this.onStatus = options.onStatus;
417
568
  this.renderer = new LoopRenderer({
@@ -452,6 +603,17 @@ export class AudioEngine {
452
603
  : Math.max(1, Math.round((ms * this.sampleRate) / 1000));
453
604
  }
454
605
 
606
+ /**
607
+ * A few words on how play mode sounds, for the play strip: the native
608
+ * sink, or the stdin player and why the sink is not in use.
609
+ */
610
+ public get audioNote(): string {
611
+ if (this.info.backend === "native") return "native sink";
612
+ return this.info.nativeUnavailable
613
+ ? `${this.info.backend} · no native sink`
614
+ : this.info.backend;
615
+ }
616
+
455
617
  /** Lead in milliseconds: how far ahead of now a new live voice sounds. */
456
618
  public get leadMs(): number {
457
619
  return (this.lead * 1000) / this.sampleRate;
@@ -619,14 +781,14 @@ export class AudioEngine {
619
781
 
620
782
  /** Frames kept queued ahead of the clock right now. */
621
783
  public get lead(): number {
622
- // Play mode and auditions keep their short lead: renders run
623
- // off-thread, so a slow render delays the swap, not the stream.
624
- const adaptive =
625
- (this.monitoring || this.leadPinned) && this.renderer.offThread
626
- ? 0
627
- : Math.round(
628
- (LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
629
- );
784
+ // Off-thread renders never stall the pump: a slow render delays the
785
+ // swap, not the stream, so only inline renders grow the lead (every
786
+ // frame of lead is a frame of edit latency).
787
+ const adaptive = this.renderer.offThread
788
+ ? 0
789
+ : Math.round(
790
+ (LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
791
+ );
630
792
  // The pump never queues more than a second, so the lead stays under it.
631
793
  return Math.min(
632
794
  Math.max(this.leadFrames, adaptive),
@@ -719,7 +881,18 @@ export class AudioEngine {
719
881
  );
720
882
  }
721
883
 
884
+ /**
885
+ * Stream timing on the native sink: the host time of the latest device
886
+ * callback, when a frame is heard, and the device latency. Undefined on
887
+ * the stdin players (their output latency is unknown).
888
+ */
889
+ public get timing(): AudioTiming | undefined {
890
+ return this.child?.clock?.timing();
891
+ }
892
+
722
893
  private queuedFrames(): number {
894
+ const clock = this.child?.clock;
895
+ if (clock) return clock.queuedFrames();
723
896
  const elapsed = Math.floor(
724
897
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
725
898
  );
@@ -781,10 +954,22 @@ export class AudioEngine {
781
954
  const child = this.child;
782
955
  const loop = this.loop;
783
956
  if (!child || (!loop && !this.monitoring)) return;
784
- const due =
785
- Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
786
- this.lead;
787
- let remaining = due - this.written;
957
+ if (this.followChoice()) return;
958
+ let remaining: number;
959
+ if (child.clock) {
960
+ // Device-clocked: top the ring up to the lead. Frames the device
961
+ // played as silence (an underrun) are skipped so the loop stays on
962
+ // the transport's beat.
963
+ const slip = child.clock.slipFrames();
964
+ if (slip > 0 && loop) this.cursor = (this.cursor + slip) % loop.frames;
965
+ if (slip > 0) this.endFades();
966
+ remaining = this.lead - child.clock.queuedFrames();
967
+ } else {
968
+ const due =
969
+ Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
970
+ this.lead;
971
+ remaining = due - this.written;
972
+ }
788
973
  if (remaining <= 0) return;
789
974
  // Never queue more than one second at once, e.g. after a stalled loop.
790
975
  if (remaining > this.sampleRate) {
@@ -864,6 +1049,8 @@ export class AudioEngine {
864
1049
 
865
1050
  /** Wall time (monotonic ms) stream frame `frame` sounds at. */
866
1051
  private frameMs(frame: number): number {
1052
+ const clock = this.child?.clock;
1053
+ if (clock) return clock.frameMs(frame);
867
1054
  return this.startMs + (frame * 1000) / this.sampleRate;
868
1055
  }
869
1056
 
@@ -971,6 +1158,134 @@ export class AudioEngine {
971
1158
  this.loop = next;
972
1159
  }
973
1160
 
1161
+ /** The output device players open; undefined is the system default. */
1162
+ public get device(): string | undefined {
1163
+ return this.outputDevice;
1164
+ }
1165
+
1166
+ /** Whether this backend can play on a chosen output. */
1167
+ public get choosesDevice(): boolean {
1168
+ return (
1169
+ this.customSpawn === undefined &&
1170
+ (this.info.backend === "native" || this.info.backend === "sox")
1171
+ );
1172
+ }
1173
+
1174
+ /**
1175
+ * Move playback to `device` (undefined: the system default). A running
1176
+ * player is replaced at the frame being heard, so the song carries on.
1177
+ */
1178
+ public setDevice(device: string | undefined): void {
1179
+ const next = device?.trim() || undefined;
1180
+ if (next === this.outputDevice) return;
1181
+ this.outputDevice = next;
1182
+ const child = this.child;
1183
+ if (!child || !this.choosesDevice) return;
1184
+ const loop = this.loop;
1185
+ const heard = this.heardFrame(loop);
1186
+ const at = this.now();
1187
+ const generation = this.generation;
1188
+ this.child = undefined;
1189
+ this.loop = undefined;
1190
+ if (this.timer) clearInterval(this.timer);
1191
+ this.timer = undefined;
1192
+ child.kill();
1193
+ this.starting = child.exited
1194
+ .catch(() => undefined)
1195
+ .then(() => this.lock.release())
1196
+ .then(async () => {
1197
+ if (generation !== this.generation || this.child) return;
1198
+ if (!loop && !this.monitoring) return;
1199
+ const frame = heard + ((this.now() - at) * this.sampleRate) / 1000;
1200
+ await this.start(loop, loop ? beatOfFrame(loop, frame) : 0);
1201
+ })
1202
+ .finally(() => {
1203
+ this.starting = undefined;
1204
+ });
1205
+ }
1206
+
1207
+ /**
1208
+ * Follow the saved choice when its file changed (at most once a second).
1209
+ * Returns true when that moved playback to another device.
1210
+ */
1211
+ private followChoice(force = false): boolean {
1212
+ if (!this.choicePath) return false;
1213
+ const now = this.now();
1214
+ if (!force && now - this.choiceCheckedMs < CHOICE_CHECK_MS) return false;
1215
+ this.choiceCheckedMs = now;
1216
+ const mtime = fileMtime(this.choicePath);
1217
+ if (mtime === this.choiceMtime) return false;
1218
+ this.choiceMtime = mtime;
1219
+ const next = resolveOutputDevice(
1220
+ process.env,
1221
+ readAudioChoice(this.choicePath),
1222
+ );
1223
+ if (next === this.outputDevice) return false;
1224
+ this.setDevice(next);
1225
+ return true;
1226
+ }
1227
+
1228
+ private heardFrame(loop: Loop | undefined): number {
1229
+ return loop
1230
+ ? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
1231
+ loop.frames
1232
+ : 0;
1233
+ }
1234
+
1235
+ /** Open a player on the chosen output, or the default when it is gone. */
1236
+ private spawnPlayer(command: readonly string[]): PlayerProcess {
1237
+ if (this.customSpawn) return this.customSpawn(command);
1238
+ this.followChoice(true);
1239
+ const native =
1240
+ this.info.backend === "native" ? this.info.native : undefined;
1241
+ const device = this.outputDevice;
1242
+ if (native) {
1243
+ const open = (name: string | undefined) =>
1244
+ openNativePlayer(native, {
1245
+ rate: this.sampleRate,
1246
+ channels: RENDER_CHANNELS,
1247
+ ...(name ? { device: name } : {}),
1248
+ bufferFrames: this.bufferFrames,
1249
+ now: this.now,
1250
+ });
1251
+ if (!device) return open(undefined);
1252
+ try {
1253
+ return open(device);
1254
+ } catch (error) {
1255
+ const player = open(undefined);
1256
+ this.deviceLost(device, error);
1257
+ return player;
1258
+ }
1259
+ }
1260
+ return spawnStdinPlayer(
1261
+ command,
1262
+ this.info.backend === "sox" && device ? { AUDIODEV: device } : undefined,
1263
+ );
1264
+ }
1265
+
1266
+ /** The chosen output is gone: play on the default and say so once. */
1267
+ private deviceLost(device: string, _error?: unknown): void {
1268
+ if (this.outputDevice !== device) return;
1269
+ this.outputDevice = undefined;
1270
+ this.onStatus?.({
1271
+ state: "device",
1272
+ message: `audio output "${device}" is unavailable · playing on the system default`,
1273
+ });
1274
+ }
1275
+
1276
+ /** After a player died: is its chosen output still there? */
1277
+ private deviceGone(): string | undefined {
1278
+ const device = this.outputDevice;
1279
+ if (!device || !this.choosesDevice) return undefined;
1280
+ if (this.info.backend === "sox") return device;
1281
+ try {
1282
+ const names = this.info.native?.devices(false) ?? [];
1283
+ return names.some((entry) => entry.name === device) ? undefined : device;
1284
+ } catch {
1285
+ return undefined;
1286
+ }
1287
+ }
1288
+
974
1289
  private async start(loop: Loop | undefined, beat: number): Promise<void> {
975
1290
  const generation = this.generation;
976
1291
  if (!(await this.lock.acquire())) return;
@@ -1010,11 +1325,9 @@ export class AudioEngine {
1010
1325
  const loop = this.loop;
1011
1326
  const ranMs = this.now() - this.startMs;
1012
1327
  // Frame the device was playing when the stream ended.
1013
- const heard = loop
1014
- ? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
1015
- loop.frames
1016
- : 0;
1328
+ const heard = this.heardFrame(loop);
1017
1329
  const diedAt = this.now();
1330
+ const lost = this.deviceGone();
1018
1331
  this.child = undefined;
1019
1332
  this.loop = undefined;
1020
1333
  if (this.timer) clearInterval(this.timer);
@@ -1031,10 +1344,13 @@ export class AudioEngine {
1031
1344
  }
1032
1345
  this.respawns += 1;
1033
1346
  const delay = this.respawnMs * 2 ** (this.respawns - 1);
1034
- this.onStatus?.({
1035
- state: "restarting",
1036
- message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
1037
- });
1347
+ // An unplugged output: one notice, then the default device.
1348
+ if (lost) this.deviceLost(lost);
1349
+ else
1350
+ this.onStatus?.({
1351
+ state: "restarting",
1352
+ message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
1353
+ });
1038
1354
  this.respawnTimer = setTimeout(() => {
1039
1355
  this.respawnTimer = undefined;
1040
1356
  if (generation !== this.generation || this.child || this.starting)
@@ -1049,9 +1365,13 @@ export class AudioEngine {
1049
1365
  }
1050
1366
  }
1051
1367
 
1052
- function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
1368
+ function spawnStdinPlayer(
1369
+ command: readonly string[],
1370
+ env?: Readonly<Record<string, string>>,
1371
+ ): PlayerProcess {
1053
1372
  if (command.length === 0) throw new Error("no player command");
1054
1373
  const child = Bun.spawn([...command], {
1374
+ ...(env ? { env: { ...process.env, ...env } } : {}),
1055
1375
  stdin: "pipe",
1056
1376
  stdout: "ignore",
1057
1377
  stderr: "ignore",
package/src/audio/fit.ts CHANGED
@@ -53,7 +53,12 @@ const onsetCache = new Map<string, readonly number[]>();
53
53
  /** Background jobs by cache key, with the window they fit (one per window). */
54
54
  const pending = new Map<
55
55
  string,
56
- { job: Generator<void, Float32Array>; window: string }
56
+ {
57
+ job: Generator<void, Float32Array>;
58
+ window: string;
59
+ /** Another stage's cache (0.7 autotune) takes the result instead. */
60
+ done?: (out: Float32Array) => void;
61
+ }
57
62
  >();
58
63
  let live = false;
59
64
  /** True when the current live render left a voice silent while fitting. */
@@ -191,6 +196,32 @@ export function shiftedBuffer(
191
196
  return out;
192
197
  }
193
198
 
199
+ /** True inside `withLiveFit` (0.7: other stages defer long work too). */
200
+ export function liveFitActive(): boolean {
201
+ return live;
202
+ }
203
+
204
+ /**
205
+ * Runs `job` between live blocks like a long fit and hands the result to
206
+ * `done` (the caller's own cache); `onFitReady` fires when it lands and
207
+ * the live render reports `fitting` until then (0.7 autotune).
208
+ */
209
+ export function deferLiveJob<T>(
210
+ key: string,
211
+ job: () => Generator<void, T>,
212
+ done: (out: T) => void,
213
+ ): void {
214
+ missed = true;
215
+ if (pending.has(key)) return;
216
+ // The scheduler only steps the job and hands its value to `done`.
217
+ pending.set(key, {
218
+ job: job() as unknown as Generator<void, Float32Array>,
219
+ window: key,
220
+ done: done as unknown as (out: Float32Array) => void,
221
+ });
222
+ schedule();
223
+ }
224
+
194
225
  function drain(job: Generator<void, Float32Array>): Float32Array {
195
226
  for (;;) {
196
227
  const step = job.next();
@@ -204,13 +235,14 @@ function schedule(): void {
204
235
  timer = undefined;
205
236
  const next = pending.entries().next();
206
237
  if (next.done) return;
207
- const [key, { job }] = next.value;
238
+ const [key, { job, done }] = next.value;
208
239
  const until = performance.now() + 8;
209
240
  for (;;) {
210
241
  const step = job.next();
211
242
  if (step.done) {
212
243
  pending.delete(key);
213
- remember(key, step.value);
244
+ if (done) done(step.value);
245
+ else remember(key, step.value);
214
246
  for (const listener of listeners) listener();
215
247
  break;
216
248
  }