@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  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/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  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 +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -9,12 +9,21 @@ import {
9
9
  type ClickLevel,
10
10
  } from "./click.ts";
11
11
  import type { LiveNotePcm } from "./live.ts";
12
+ import { RING_OUT_FADE_SECONDS } from "./instruments.ts";
12
13
  import { measureLoudness, pcmChannels } from "./loudness.ts";
13
14
  import type { MasterReport } from "./master.ts";
14
15
  import { levelOf, type SoundLevel } from "./preview.ts";
15
16
  import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
16
17
  import { playbackTime } from "./arrange.ts";
17
18
  import { transportMapFor, type TransportMap } from "./clock.ts";
19
+ import {
20
+ openNativePlayer,
21
+ probeNativeSink,
22
+ type AudioTiming,
23
+ type NativeProbe,
24
+ type PlayerClock,
25
+ type SinkLibrary,
26
+ } from "./native.ts";
18
27
 
19
28
  /**
20
29
  * How dawgd (or a file-mode window) makes sound.
@@ -29,7 +38,8 @@ import { transportMapFor, type TransportMap } from "./clock.ts";
29
38
  * s16le from stdin; tests use it to record the exact byte stream.
30
39
  * - `none` makes no sound; the transport clock still runs.
31
40
  */
32
- export type AudioBackend = "ffplay" | "sox" | "afplay" | "command" | "none";
41
+ export type AudioBackend =
42
+ "native" | "ffplay" | "sox" | "afplay" | "command" | "none";
33
43
 
34
44
  export type AudioBackendInfo = Readonly<{
35
45
  backend: AudioBackend;
@@ -39,6 +49,10 @@ export type AudioBackendInfo = Readonly<{
39
49
  command?: readonly string[];
40
50
  /** Human-readable reason the backend was chosen. */
41
51
  detail: string;
52
+ /** The loaded sink, for the `native` backend. */
53
+ native?: SinkLibrary;
54
+ /** Why the native sink is not in use, when another backend was picked. */
55
+ nativeUnavailable?: string;
42
56
  }>;
43
57
 
44
58
  type Which = (binary: string) => string | null;
@@ -48,8 +62,17 @@ export type DetectOptions = Readonly<{
48
62
  which?: Which;
49
63
  platform?: NodeJS.Platform;
50
64
  sampleRate?: number;
65
+ /** Native sink probe; `false` skips it (tests). Default: the real one. */
66
+ native?: (() => NativeProbe) | false;
51
67
  }>;
52
68
 
69
+ let cachedProbe: NativeProbe | undefined;
70
+ /** The real probe, once per process (dlopen is not repeated). */
71
+ function defaultNativeProbe(): NativeProbe {
72
+ cachedProbe ??= probeNativeSink();
73
+ return cachedProbe;
74
+ }
75
+
53
76
  /** Streaming argv for the known stdin players. */
54
77
  export function streamingCommand(
55
78
  backend: "ffplay" | "sox",
@@ -98,8 +121,9 @@ export function streamingCommand(
98
121
 
99
122
  /**
100
123
  * Pick the audio backend. Order: DAWG_AUDIO=0 (none), DAWG_AUDIO_PLAYER
101
- * (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then ffplay,
102
- * sox `play`, and afplay (macOS) in that order.
124
+ * (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then the
125
+ * native sink, ffplay, sox `play`, and afplay (macOS) in that order. When
126
+ * the native sink cannot load, the fallback's detail says why.
103
127
  */
104
128
  export function detectAudioBackend(
105
129
  options: DetectOptions = {},
@@ -127,13 +151,40 @@ export function detectAudioBackend(
127
151
  }
128
152
  const forced = env.DAWG_AUDIO_BACKEND?.trim().toLowerCase();
129
153
  const candidates: AudioBackend[] =
154
+ forced === "native" ||
130
155
  forced === "ffplay" ||
131
156
  forced === "sox" ||
132
157
  forced === "afplay" ||
133
158
  forced === "none"
134
159
  ? [forced]
135
- : ["ffplay", "sox", "afplay"];
160
+ : ["native", "ffplay", "sox", "afplay"];
161
+ let unavailable: string | undefined;
162
+ const why = (info: AudioBackendInfo): AudioBackendInfo =>
163
+ unavailable
164
+ ? {
165
+ ...info,
166
+ detail: `${info.detail} · native sink unavailable: ${unavailable}`,
167
+ nativeUnavailable: unavailable,
168
+ }
169
+ : info;
136
170
  for (const candidate of candidates) {
171
+ if (candidate === "native") {
172
+ if (options.native === false) continue;
173
+ const probe = options.native
174
+ ? options.native()
175
+ : env === process.env && options.platform === undefined
176
+ ? defaultNativeProbe()
177
+ : probeNativeSink({ env, platform });
178
+ if (probe.ok)
179
+ return {
180
+ backend: "native",
181
+ streaming: true,
182
+ native: probe.library,
183
+ detail: `native sink ${probe.detail} (device callback)`,
184
+ };
185
+ unavailable = probe.reason;
186
+ continue;
187
+ }
137
188
  if (candidate === "none")
138
189
  return {
139
190
  backend: "none",
@@ -143,26 +194,26 @@ export function detectAudioBackend(
143
194
  if (candidate === "ffplay" || candidate === "sox") {
144
195
  const binary = which(candidate === "ffplay" ? "ffplay" : "play");
145
196
  if (!binary) continue;
146
- return {
197
+ return why({
147
198
  backend: candidate,
148
199
  streaming: true,
149
200
  command: streamingCommand(candidate, binary, sampleRate),
150
201
  detail: `${binary} (gapless stream)`,
151
- };
202
+ });
152
203
  }
153
204
  const binary = platform === "darwin" ? which("afplay") : null;
154
205
  if (binary)
155
- return {
206
+ return why({
156
207
  backend: "afplay",
157
208
  streaming: false,
158
209
  detail: `${binary} (re-render on edit; install ffmpeg or sox for gapless playback)`,
159
- };
210
+ });
160
211
  }
161
- return {
212
+ return why({
162
213
  backend: "none",
163
214
  streaming: false,
164
215
  detail: "no player found (install ffmpeg or sox)",
165
- };
216
+ });
166
217
  }
167
218
 
168
219
  /** One status line for `dawg auth status` and the header. */
@@ -204,8 +255,21 @@ type PlayerProcess = {
204
255
  readonly stdin: Sink;
205
256
  readonly exited: Promise<number>;
206
257
  kill(): void;
258
+ /** Device-clocked players (the native sink) pace the pump themselves. */
259
+ readonly clock?: PlayerClock;
207
260
  };
208
261
 
262
+ /** Play-mode lead on the native sink: the ring only covers pump jitter. */
263
+ export const NATIVE_PLAY_LEAD_MS = 15;
264
+ /** Pump interval on the native sink. */
265
+ export const NATIVE_TICK_MS = 5;
266
+
267
+ function envNumber(value: string | undefined): number | undefined {
268
+ if (value === undefined || value.trim() === "") return undefined;
269
+ const number = Number(value);
270
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
271
+ }
272
+
209
273
  export type AudioEngineOptions = Readonly<{
210
274
  lockPath?: string;
211
275
  info?: AudioBackendInfo;
@@ -227,6 +291,12 @@ export type AudioEngineOptions = Readonly<{
227
291
  respawnMs?: number;
228
292
  /** Player lifecycle notices worth a status line. */
229
293
  onStatus?: (status: AudioStatus) => void;
294
+ /** Play-mode lead (default 60 ms; 15 ms on the native sink). */
295
+ playLeadMs?: number;
296
+ /** Native sink output device name (default: system default). */
297
+ device?: string;
298
+ /** Native sink device buffer frames (default 128; 0 = device default). */
299
+ bufferFrames?: number;
230
300
  }>;
231
301
 
232
302
  export type AudioStatus = Readonly<{
@@ -239,7 +309,13 @@ export type AudioStatus = Readonly<{
239
309
  const MAX_RESPAWNS = 3;
240
310
  /** A player that ran this long before dying resets the respawn budget. */
241
311
  const RESPAWN_RESET_MS = 10_000;
242
- /** Lead grows to this multiple of the last render time. */
312
+ /**
313
+ * Default queue lead: the edit-to-audible floor while playing. 100 ms leaves
314
+ * 80 ms of slack over the 20 ms pump tick for main-thread stalls (a frame
315
+ * costs about 1 ms; play mode already runs at 60 ms).
316
+ */
317
+ const DEFAULT_LEAD_MS = 100;
318
+ /** Inline renders grow the lead to this multiple of the last render time. */
243
319
  const LEAD_RENDER_FACTOR = 1.5;
244
320
 
245
321
  type Loop = Readonly<{
@@ -308,8 +384,16 @@ type LiveVoice = {
308
384
  /** Fade-out frames left once released early; undefined while sounding. */
309
385
  fadeLeft: number | undefined;
310
386
  fadeFrames: number;
387
+ /** Rings to its end on note-off (0.6.1 modal ring-out). */
388
+ ringOut?: boolean;
311
389
  };
312
390
 
391
+ /**
392
+ * At most this many ring-out voices sound at once; the oldest beyond it fades
393
+ * over `RING_OUT_FADE_SECONDS` (spec 7.3: 32 resonator voices).
394
+ */
395
+ export const MAX_RING_OUT_VOICES = 32;
396
+
313
397
  type PlayRequest = {
314
398
  score: TrackScore;
315
399
  beat: number | undefined;
@@ -325,8 +409,8 @@ type PlayRequest = {
325
409
  *
326
410
  * Timing: the engine is paced by the same monotonic clock as the transport.
327
411
  * Frame `n` of the stream belongs to wall time `start + n / rate`, and the
328
- * engine keeps a lead of audio queued ahead of now: at least `leadMs`, and
329
- * 1.5x the last render time when renders are slower than that. The device
412
+ * engine keeps a lead of audio queued ahead of now: `leadMs`, or 1.5x the
413
+ * last render time when renders run inline (no worker) and are slower. The device
330
414
  * plays the queue at its own rate, so what you hear trails the transport by
331
415
  * the player's fixed output latency and never accumulates drift from
332
416
  * re-renders. Renders run in a worker; an edit re-renders off-thread and
@@ -342,6 +426,8 @@ export class AudioEngine {
342
426
  private leadFrames: number;
343
427
  private readonly defaultLeadFrames: number;
344
428
  private readonly tickMs: number;
429
+ /** The lead play mode and auditions pin (see `setLeadMs`). */
430
+ public readonly playLeadMs: number;
345
431
  private readonly now: () => number;
346
432
  private readonly useTimer: boolean;
347
433
  private readonly spawnPlayer: (command: readonly string[]) => PlayerProcess;
@@ -392,17 +478,37 @@ export class AudioEngine {
392
478
  options.info ?? detectAudioBackend({ sampleRate: this.sampleRate });
393
479
  this.lock = new PlaybackLock(options.lockPath);
394
480
  this.leadFrames = Math.round(
395
- ((options.leadMs ?? 200) * this.sampleRate) / 1000,
481
+ ((options.leadMs ?? DEFAULT_LEAD_MS) * this.sampleRate) / 1000,
396
482
  );
397
483
  this.defaultLeadFrames = this.leadFrames;
398
484
  this.fadeFrames = Math.max(
399
485
  1,
400
486
  Math.round(SWAP_FADE_SECONDS * this.sampleRate),
401
487
  );
402
- this.tickMs = options.tickMs ?? 20;
488
+ const native =
489
+ this.info.backend === "native" ? this.info.native : undefined;
490
+ this.tickMs = options.tickMs ?? (native ? NATIVE_TICK_MS : 20);
491
+ this.playLeadMs =
492
+ options.playLeadMs ??
493
+ envNumber(process.env.DAWG_PLAY_LEAD_MS) ??
494
+ (native ? NATIVE_PLAY_LEAD_MS : 60);
403
495
  this.now = options.now ?? (() => performance.now());
404
496
  this.useTimer = options.timer ?? true;
405
- this.spawnPlayer = options.spawn ?? spawnStdinPlayer;
497
+ const device = options.device ?? process.env.DAWG_AUDIO_DEVICE?.trim();
498
+ const bufferFrames =
499
+ options.bufferFrames ?? envNumber(process.env.DAWG_AUDIO_BUFFER) ?? 128;
500
+ this.spawnPlayer =
501
+ options.spawn ??
502
+ (native
503
+ ? () =>
504
+ openNativePlayer(native, {
505
+ rate: this.sampleRate,
506
+ channels: RENDER_CHANNELS,
507
+ ...(device ? { device } : {}),
508
+ bufferFrames,
509
+ now: this.now,
510
+ })
511
+ : spawnStdinPlayer);
406
512
  this.respawnMs = options.respawnMs ?? 250;
407
513
  this.onStatus = options.onStatus;
408
514
  this.renderer = new LoopRenderer({
@@ -443,6 +549,17 @@ export class AudioEngine {
443
549
  : Math.max(1, Math.round((ms * this.sampleRate) / 1000));
444
550
  }
445
551
 
552
+ /**
553
+ * A few words on how play mode sounds, for the play strip: the native
554
+ * sink, or the stdin player and why the sink is not in use.
555
+ */
556
+ public get audioNote(): string {
557
+ if (this.info.backend === "native") return "native sink";
558
+ return this.info.nativeUnavailable
559
+ ? `${this.info.backend} · no native sink`
560
+ : this.info.backend;
561
+ }
562
+
446
563
  /** Lead in milliseconds: how far ahead of now a new live voice sounds. */
447
564
  public get leadMs(): number {
448
565
  return (this.lead * 1000) / this.sampleRate;
@@ -531,14 +648,18 @@ export class AudioEngine {
531
648
  existing.frames = note.frames;
532
649
  existing.fadeLeft = undefined;
533
650
  existing.fadeFrames = this.releaseFrames(note);
534
- } else
651
+ existing.ringOut = note.ringOut === true;
652
+ } else {
535
653
  this.voices.set(id, {
536
654
  pcm: note.pcm,
537
655
  frames: note.frames,
538
656
  position: 0,
539
657
  fadeLeft: undefined,
540
658
  fadeFrames: this.releaseFrames(note),
659
+ ...(note.ringOut ? { ringOut: true } : {}),
541
660
  });
661
+ if (note.ringOut) this.capRingOut();
662
+ }
542
663
  // Write what is due now so the voice joins the very next chunk.
543
664
  const first = this.written;
544
665
  this.pump();
@@ -553,11 +674,39 @@ export class AudioEngine {
553
674
  );
554
675
  }
555
676
 
556
- /** Fade a live voice out over ~10 ms (sustain lifted, mode left). */
557
- public noteOff(id: number): void {
677
+ /**
678
+ * Past `MAX_RING_OUT_VOICES` ringing voices, fade the oldest still at full
679
+ * level over `RING_OUT_FADE_SECONDS` (Map order is start order).
680
+ */
681
+ private capRingOut(): void {
682
+ let ringing = 0;
683
+ for (const voice of this.voices.values())
684
+ if (voice.ringOut && voice.fadeLeft === undefined) ringing += 1;
685
+ for (const voice of this.voices.values()) {
686
+ if (ringing <= MAX_RING_OUT_VOICES) break;
687
+ if (!voice.ringOut || voice.fadeLeft !== undefined) continue;
688
+ this.fadeRingOut(voice);
689
+ ringing -= 1;
690
+ }
691
+ }
692
+
693
+ private fadeRingOut(voice: LiveVoice): void {
694
+ voice.fadeFrames = Math.max(
695
+ 1,
696
+ Math.round(this.sampleRate * RING_OUT_FADE_SECONDS),
697
+ );
698
+ voice.fadeLeft = voice.fadeFrames;
699
+ }
700
+
701
+ /**
702
+ * Fade a live voice out over ~10 ms (sustain lifted, mode left). A
703
+ * ring-out voice keeps ringing; `noteOff(id, true)` silences it too.
704
+ */
705
+ public noteOff(id: number, force = false): void {
558
706
  const voice = this.voices.get(id);
559
- if (voice && voice.fadeLeft === undefined)
560
- voice.fadeLeft = voice.fadeFrames;
707
+ if (!voice || voice.fadeLeft !== undefined) return;
708
+ if (!voice.ringOut) voice.fadeLeft = voice.fadeFrames;
709
+ else if (force) this.fadeRingOut(voice);
561
710
  }
562
711
 
563
712
  /** Frames of live voice `id` already mixed, or undefined once finished. */
@@ -578,14 +727,14 @@ export class AudioEngine {
578
727
 
579
728
  /** Frames kept queued ahead of the clock right now. */
580
729
  public get lead(): number {
581
- // Play mode and auditions keep their short lead: renders run
582
- // off-thread, so a slow render delays the swap, not the stream.
583
- const adaptive =
584
- (this.monitoring || this.leadPinned) && this.renderer.offThread
585
- ? 0
586
- : Math.round(
587
- (LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
588
- );
730
+ // Off-thread renders never stall the pump: a slow render delays the
731
+ // swap, not the stream, so only inline renders grow the lead (every
732
+ // frame of lead is a frame of edit latency).
733
+ const adaptive = this.renderer.offThread
734
+ ? 0
735
+ : Math.round(
736
+ (LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
737
+ );
589
738
  // The pump never queues more than a second, so the lead stays under it.
590
739
  return Math.min(
591
740
  Math.max(this.leadFrames, adaptive),
@@ -678,7 +827,18 @@ export class AudioEngine {
678
827
  );
679
828
  }
680
829
 
830
+ /**
831
+ * Stream timing on the native sink: the host time of the latest device
832
+ * callback, when a frame is heard, and the device latency. Undefined on
833
+ * the stdin players (their output latency is unknown).
834
+ */
835
+ public get timing(): AudioTiming | undefined {
836
+ return this.child?.clock?.timing();
837
+ }
838
+
681
839
  private queuedFrames(): number {
840
+ const clock = this.child?.clock;
841
+ if (clock) return clock.queuedFrames();
682
842
  const elapsed = Math.floor(
683
843
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
684
844
  );
@@ -740,10 +900,21 @@ export class AudioEngine {
740
900
  const child = this.child;
741
901
  const loop = this.loop;
742
902
  if (!child || (!loop && !this.monitoring)) return;
743
- const due =
744
- Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
745
- this.lead;
746
- let remaining = due - this.written;
903
+ let remaining: number;
904
+ if (child.clock) {
905
+ // Device-clocked: top the ring up to the lead. Frames the device
906
+ // played as silence (an underrun) are skipped so the loop stays on
907
+ // the transport's beat.
908
+ const slip = child.clock.slipFrames();
909
+ if (slip > 0 && loop) this.cursor = (this.cursor + slip) % loop.frames;
910
+ if (slip > 0) this.endFades();
911
+ remaining = this.lead - child.clock.queuedFrames();
912
+ } else {
913
+ const due =
914
+ Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
915
+ this.lead;
916
+ remaining = due - this.written;
917
+ }
747
918
  if (remaining <= 0) return;
748
919
  // Never queue more than one second at once, e.g. after a stalled loop.
749
920
  if (remaining > this.sampleRate) {
@@ -823,6 +994,8 @@ export class AudioEngine {
823
994
 
824
995
  /** Wall time (monotonic ms) stream frame `frame` sounds at. */
825
996
  private frameMs(frame: number): number {
997
+ const clock = this.child?.clock;
998
+ if (clock) return clock.frameMs(frame);
826
999
  return this.startMs + (frame * 1000) / this.sampleRate;
827
1000
  }
828
1001
 
package/src/audio/fit.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  sampleAt,
32
32
  } from "./dsp/stft.ts";
33
33
  import { hermite4 } from "./dsp/interp.ts";
34
+ import { pitchShiftJob } from "./dsp/shift.ts";
34
35
  import type { FitMap } from "./warp.ts";
35
36
 
36
37
  export type FitAlgorithm = "repitch" | "beats" | "tones";
@@ -52,7 +53,12 @@ const onsetCache = new Map<string, readonly number[]>();
52
53
  /** Background jobs by cache key, with the window they fit (one per window). */
53
54
  const pending = new Map<
54
55
  string,
55
- { 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
+ }
56
62
  >();
57
63
  let live = false;
58
64
  /** True when the current live render left a voice silent while fitting. */
@@ -146,6 +152,76 @@ export function fittedBuffer(
146
152
  return out;
147
153
  }
148
154
 
155
+ /**
156
+ * Live renders pitch-shift buffers up to this long synchronously; longer
157
+ * ones run between blocks while the voice plays a repitch. A phase-vocoder
158
+ * shift with formant keeping costs about 30 ms per second of 22.05 kHz
159
+ * audio, so only very short sounds fit the 10 ms note-on budget.
160
+ */
161
+ export const LIVE_SYNC_SHIFT_SECONDS = 0.1;
162
+
163
+ /**
164
+ * `buffer` pitch-shifted by `semitones` (formant semitones, 0 keeps),
165
+ * cached under `key` with the fits (0.6.1 `shift`, `formant`). Returns
166
+ * undefined only in a live render while a long shift is still running.
167
+ */
168
+ export function shiftedBuffer(
169
+ key: string,
170
+ buffer: Float32Array,
171
+ sampleRate: number,
172
+ semitones: number,
173
+ formant: number | undefined,
174
+ ): Float32Array | undefined {
175
+ const hit = cache.get(key);
176
+ if (hit) {
177
+ cache.delete(key);
178
+ cache.set(key, hit);
179
+ return hit;
180
+ }
181
+ const work = () =>
182
+ pitchShiftJob(buffer, sampleRate, semitones, {
183
+ ...(formant === undefined ? {} : { formant }),
184
+ });
185
+ if (live && buffer.length > LIVE_SYNC_SHIFT_SECONDS * sampleRate) {
186
+ missed = true;
187
+ if (!pending.has(key)) {
188
+ pending.set(key, { job: work(), window: key });
189
+ schedule();
190
+ }
191
+ return undefined;
192
+ }
193
+ pending.delete(key);
194
+ const out = drain(work());
195
+ remember(key, out);
196
+ return out;
197
+ }
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
+
149
225
  function drain(job: Generator<void, Float32Array>): Float32Array {
150
226
  for (;;) {
151
227
  const step = job.next();
@@ -159,13 +235,14 @@ function schedule(): void {
159
235
  timer = undefined;
160
236
  const next = pending.entries().next();
161
237
  if (next.done) return;
162
- const [key, { job }] = next.value;
238
+ const [key, { job, done }] = next.value;
163
239
  const until = performance.now() + 8;
164
240
  for (;;) {
165
241
  const step = job.next();
166
242
  if (step.done) {
167
243
  pending.delete(key);
168
- remember(key, step.value);
244
+ if (done) done(step.value);
245
+ else remember(key, step.value);
169
246
  for (const listener of listeners) listener();
170
247
  break;
171
248
  }