@hraness/dawg 0.6.1 → 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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  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 +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Formant shift at constant pitch (0.7 `fx.formant`): the throat or gender
3
+ * knob. Per STFT frame the cepstral spectral envelope E is taken and each
4
+ * bin is scaled by E(k / a) / E(k), a = 2^(shift / 12), with its phase left
5
+ * untouched, then the frames overlap-add. The pitch never moves, so no
6
+ * phase vocoder is needed (Röbel and Rodet, "Efficient spectral envelope
7
+ * estimation and its application to pitch shifting and envelope
8
+ * preservation", DAFx 2005; Little AlterBoy and VocalSynth's formant knob).
9
+ *
10
+ * The lifter adapts to the voice: 0.75 periods of a 5-frame median
11
+ * autocorrelation f0, clamped to 1-2 ms, 1 ms when unvoiced. A fixed 2 ms
12
+ * lifter resolves single harmonics above about 500 Hz and makes high
13
+ * voices worse than no processing (design formant.md section 2).
14
+ *
15
+ * Frames sit at multiples of the hop in absolute track samples (an
16
+ * arranged window passes its origin as `seedSeconds`), and a window padded
17
+ * back by `FORMANT_PAD` samples and forward by one frame equals the same
18
+ * span of a full render bit for bit.
19
+ */
20
+ import type { FxValues } from "../../../core/fx.ts";
21
+ import type { Track } from "../../../core/score.ts";
22
+ import { cepstralEnvelope, envelopeScratch } from "../dsp/envelope.ts";
23
+ import { fftInPlace } from "../dsp/fft.ts";
24
+ import { hann, HANN_SQUARED_OLA_QUARTER } from "../dsp/stft.ts";
25
+ import { fxReader, type EffectContext } from "./common.ts";
26
+
27
+ /** Per-bin gain clamp (24 dB), as shift.ts holds formants. */
28
+ const MAX_GAIN = 10 ** (24 / 20);
29
+ /** Lifter periods and clamps (design formant.md section 2). */
30
+ const LIFTER_PERIODS = 0.75;
31
+ const LIFTER_MIN_SECONDS = 0.001;
32
+ const LIFTER_MAX_SECONDS = 0.002;
33
+ /** Frames in the f0 median (this one and the four before it). */
34
+ const F0_MEDIAN = 5;
35
+ const F0_MIN_HZ = 60;
36
+ const F0_MAX_HZ = 1500;
37
+ /** Below this normalized autocorrelation a frame counts as unvoiced. */
38
+ const VOICED = 0.45;
39
+ /** The first peak within this fraction of the best is the period. */
40
+ const FIRST_PEAK = 0.85;
41
+
42
+ /** Frame length: 1024 at or below 24 kHz, 2048 above. */
43
+ export function formantFrame(sampleRate: number): number {
44
+ return sampleRate > 24_000 ? 2048 : 1024;
45
+ }
46
+
47
+ /**
48
+ * Samples a window render must start before its first output sample (4
49
+ * hops for the f0 median plus one frame), and it renders one frame past
50
+ * its last.
51
+ */
52
+ export function formantPad(sampleRate: number): number {
53
+ const n = formantFrame(sampleRate);
54
+ return n + 4 * (n / 4);
55
+ }
56
+
57
+ export type FormantShiftOptions = Readonly<{
58
+ /** Wet amount 0..1 (1: fully shifted). */
59
+ mix?: number | ((index: number) => number);
60
+ /** Absolute track sample of `x[0]`; frames anchor at hop multiples of it. */
61
+ origin?: number;
62
+ }>;
63
+
64
+ type Analysis = {
65
+ n: number;
66
+ hop: number;
67
+ win: Float64Array;
68
+ wac: Float64Array;
69
+ minLag: number;
70
+ maxLag: number;
71
+ };
72
+
73
+ const analyses = new Map<number, Analysis>();
74
+
75
+ function analysisFor(sampleRate: number): Analysis {
76
+ let a = analyses.get(sampleRate);
77
+ if (a) return a;
78
+ const n = formantFrame(sampleRate);
79
+ const win = hann(n);
80
+ const minLag = Math.floor(sampleRate / F0_MAX_HZ);
81
+ const maxLag = Math.min(n / 2 - 1, Math.ceil(sampleRate / F0_MIN_HZ));
82
+ // The window's own autocorrelation, divided out so long lags are not
83
+ // biased down (Boersma 1993).
84
+ const wac = new Float64Array(maxLag + 1);
85
+ for (let t = 0; t <= maxLag; t += 1) {
86
+ let sum = 0;
87
+ for (let i = 0; i + t < n; i += 1) sum += win[i]! * win[i + t]!;
88
+ wac[t] = sum;
89
+ }
90
+ a = { n, hop: n / 4, win, wac, minLag, maxLag };
91
+ analyses.set(sampleRate, a);
92
+ return a;
93
+ }
94
+
95
+ /** One frame's f0 from its power spectrum (0: unvoiced). */
96
+ function frameF0(
97
+ mag: Float64Array,
98
+ a: Analysis,
99
+ sampleRate: number,
100
+ re: Float64Array,
101
+ im: Float64Array,
102
+ ): number {
103
+ const { n, wac, minLag, maxLag } = a;
104
+ const half = n / 2;
105
+ for (let k = 0; k <= half; k += 1) {
106
+ re[k] = mag[k]! * mag[k]!;
107
+ im[k] = 0;
108
+ if (k > 0 && k < half) {
109
+ re[n - k] = re[k]!;
110
+ im[n - k] = 0;
111
+ }
112
+ }
113
+ fftInPlace(re, im, true);
114
+ const r0 = re[0]!;
115
+ if (r0 <= 1e-12) return 0;
116
+ const w0 = wac[0]! / r0;
117
+ for (let t = minLag - 1; t <= maxLag; t += 1) re[t] = (re[t]! * w0) / wac[t]!;
118
+ let best = 0;
119
+ for (let t = minLag; t <= maxLag; t += 1) best = Math.max(best, re[t]!);
120
+ if (best < VOICED) return 0;
121
+ for (let t = minLag + 1; t < maxLag; t += 1) {
122
+ const v = re[t]!;
123
+ if (v >= FIRST_PEAK * best && v >= re[t - 1]! && v >= re[t + 1]!) {
124
+ const l = re[t - 1]!;
125
+ const r = re[t + 1]!;
126
+ const d = l - 2 * v + r;
127
+ const offset = d < 0 ? (0.5 * (l - r)) / d : 0;
128
+ return sampleRate / (t + offset);
129
+ }
130
+ }
131
+ return 0;
132
+ }
133
+
134
+ /**
135
+ * Shift the formants of `x` by `shift` semitones (a number, or a function
136
+ * of the buffer index read once per hop) at constant pitch. Returns a new
137
+ * buffer; `shift` 0 everywhere returns a copy of `x`.
138
+ */
139
+ export function formantShift(
140
+ x: Float64Array,
141
+ sampleRate: number,
142
+ shift: number | ((index: number) => number),
143
+ options: FormantShiftOptions = {},
144
+ ): Float64Array {
145
+ if (shift === 0) return Float64Array.from(x);
146
+ const a = analysisFor(sampleRate);
147
+ const { n, hop, win } = a;
148
+ const half = n / 2;
149
+ const origin = Math.round(options.origin ?? 0);
150
+ const out = new Float64Array(x.length);
151
+ const re = new Float64Array(n);
152
+ const im = new Float64Array(n);
153
+ const acRe = new Float64Array(n);
154
+ const acIm = new Float64Array(n);
155
+ const mag = new Float64Array(half + 1);
156
+ const env = new Float64Array(half + 1);
157
+ const scratch = envelopeScratch(n);
158
+ const history = new Float64Array(F0_MEDIAN);
159
+ const sorted = new Float64Array(F0_MEDIAN);
160
+ let frames = 0;
161
+ const ola = 1 / HANN_SQUARED_OLA_QUARTER;
162
+ const auto = typeof shift === "function";
163
+ // First frame: the latest hop multiple (absolute) whose frame still
164
+ // reaches x[0].
165
+ const firstAbs = Math.floor((origin - n + 1 + (hop - 1)) / hop) * hop;
166
+ for (let start = firstAbs - origin; start < x.length; start += hop) {
167
+ const st = auto ? shift(Math.max(0, start + half)) : shift;
168
+ for (let i = 0; i < n; i += 1) {
169
+ const j = start + i;
170
+ re[i] = j >= 0 && j < x.length ? x[j]! * win[i]! : 0;
171
+ im[i] = 0;
172
+ }
173
+ fftInPlace(re, im);
174
+ for (let k = 0; k <= half; k += 1) mag[k] = Math.hypot(re[k]!, im[k]!);
175
+ // Every frame joins the f0 history, so a window and the full render
176
+ // agree whatever the shift was.
177
+ history[frames % F0_MEDIAN] = frameF0(mag, a, sampleRate, acRe, acIm);
178
+ frames += 1;
179
+ if (st === 0) {
180
+ fftInPlace(re, im, true);
181
+ } else {
182
+ const m = Math.min(frames, F0_MEDIAN);
183
+ for (let i = 0; i < m; i += 1) sorted[i] = history[i]!;
184
+ for (let i = 1; i < m; i += 1)
185
+ for (let j = i; j > 0 && sorted[j - 1]! > sorted[j]!; j -= 1) {
186
+ const tmp = sorted[j]!;
187
+ sorted[j] = sorted[j - 1]!;
188
+ sorted[j - 1] = tmp;
189
+ }
190
+ const f0 = sorted[m >> 1]!;
191
+ const seconds =
192
+ f0 > 0
193
+ ? Math.min(
194
+ LIFTER_MAX_SECONDS,
195
+ Math.max(LIFTER_MIN_SECONDS, LIFTER_PERIODS / f0),
196
+ )
197
+ : LIFTER_MIN_SECONDS;
198
+ const lifter = Math.max(4, Math.round(seconds * sampleRate));
199
+ cepstralEnvelope(mag, n, lifter, env, scratch);
200
+ const ratio = 2 ** (-st / 12);
201
+ for (let k = 0; k <= half; k += 1) {
202
+ const source = k * ratio;
203
+ const i0 = Math.floor(source);
204
+ const f = source - i0;
205
+ const e =
206
+ i0 >= half
207
+ ? env[half]!
208
+ : env[i0]! + (env[Math.min(half, i0 + 1)]! - env[i0]!) * f;
209
+ const g = Math.min(
210
+ MAX_GAIN,
211
+ Math.max(1 / MAX_GAIN, e / Math.max(env[k]!, 1e-12)),
212
+ );
213
+ re[k] = re[k]! * g;
214
+ im[k] = im[k]! * g;
215
+ if (k > 0 && k < half) {
216
+ re[n - k] = re[k]!;
217
+ im[n - k] = -im[k]!;
218
+ }
219
+ }
220
+ fftInPlace(re, im, true);
221
+ }
222
+ for (let i = 0; i < n; i += 1) {
223
+ const j = start + i;
224
+ if (j >= 0 && j < x.length)
225
+ out[j] = out[j]! + (re[i]! / n) * win[i]! * ola;
226
+ }
227
+ }
228
+ const mix = options.mix ?? 1;
229
+ if (typeof mix === "function")
230
+ for (let i = 0; i < x.length; i += 1)
231
+ out[i] = x[i]! + (out[i]! - x[i]!) * mix(i);
232
+ else if (mix < 1)
233
+ for (let i = 0; i < x.length; i += 1)
234
+ out[i] = x[i]! + (out[i]! - x[i]!) * mix;
235
+ return out;
236
+ }
237
+
238
+ /** `fx.formant`: formant shift with mix, both automatable. */
239
+ export function applyFormant(
240
+ buffer: Float64Array,
241
+ track: Track,
242
+ values: FxValues,
243
+ context: EffectContext,
244
+ ): void {
245
+ const read = fxReader(track, "formant", values, context);
246
+ const shift = read.param("shift");
247
+ const mix = read.param("mix");
248
+ if (!shift.automated && shift.fallback === 0) return;
249
+ if (!mix.automated && mix.fallback <= 0) return;
250
+ const origin = context.seedSeconds
251
+ ? Math.round(context.seedSeconds * context.sampleRate)
252
+ : 0;
253
+ const out = formantShift(
254
+ buffer,
255
+ context.sampleRate,
256
+ shift.automated ? (index) => shift.at(index) : shift.fallback,
257
+ {
258
+ origin,
259
+ mix: mix.automated ? (index) => mix.at(index) : mix.fallback,
260
+ },
261
+ );
262
+ buffer.set(out);
263
+ }
@@ -16,6 +16,14 @@ import { levelOf, type SoundLevel } from "./preview.ts";
16
16
  import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
17
17
  import { playbackTime } from "./arrange.ts";
18
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";
19
27
 
20
28
  /**
21
29
  * How dawgd (or a file-mode window) makes sound.
@@ -30,7 +38,8 @@ import { transportMapFor, type TransportMap } from "./clock.ts";
30
38
  * s16le from stdin; tests use it to record the exact byte stream.
31
39
  * - `none` makes no sound; the transport clock still runs.
32
40
  */
33
- export type AudioBackend = "ffplay" | "sox" | "afplay" | "command" | "none";
41
+ export type AudioBackend =
42
+ "native" | "ffplay" | "sox" | "afplay" | "command" | "none";
34
43
 
35
44
  export type AudioBackendInfo = Readonly<{
36
45
  backend: AudioBackend;
@@ -40,6 +49,10 @@ export type AudioBackendInfo = Readonly<{
40
49
  command?: readonly string[];
41
50
  /** Human-readable reason the backend was chosen. */
42
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;
43
56
  }>;
44
57
 
45
58
  type Which = (binary: string) => string | null;
@@ -49,8 +62,17 @@ export type DetectOptions = Readonly<{
49
62
  which?: Which;
50
63
  platform?: NodeJS.Platform;
51
64
  sampleRate?: number;
65
+ /** Native sink probe; `false` skips it (tests). Default: the real one. */
66
+ native?: (() => NativeProbe) | false;
52
67
  }>;
53
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
+
54
76
  /** Streaming argv for the known stdin players. */
55
77
  export function streamingCommand(
56
78
  backend: "ffplay" | "sox",
@@ -99,8 +121,9 @@ export function streamingCommand(
99
121
 
100
122
  /**
101
123
  * 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.
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.
104
127
  */
105
128
  export function detectAudioBackend(
106
129
  options: DetectOptions = {},
@@ -128,13 +151,40 @@ export function detectAudioBackend(
128
151
  }
129
152
  const forced = env.DAWG_AUDIO_BACKEND?.trim().toLowerCase();
130
153
  const candidates: AudioBackend[] =
154
+ forced === "native" ||
131
155
  forced === "ffplay" ||
132
156
  forced === "sox" ||
133
157
  forced === "afplay" ||
134
158
  forced === "none"
135
159
  ? [forced]
136
- : ["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;
137
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
+ }
138
188
  if (candidate === "none")
139
189
  return {
140
190
  backend: "none",
@@ -144,26 +194,26 @@ export function detectAudioBackend(
144
194
  if (candidate === "ffplay" || candidate === "sox") {
145
195
  const binary = which(candidate === "ffplay" ? "ffplay" : "play");
146
196
  if (!binary) continue;
147
- return {
197
+ return why({
148
198
  backend: candidate,
149
199
  streaming: true,
150
200
  command: streamingCommand(candidate, binary, sampleRate),
151
201
  detail: `${binary} (gapless stream)`,
152
- };
202
+ });
153
203
  }
154
204
  const binary = platform === "darwin" ? which("afplay") : null;
155
205
  if (binary)
156
- return {
206
+ return why({
157
207
  backend: "afplay",
158
208
  streaming: false,
159
209
  detail: `${binary} (re-render on edit; install ffmpeg or sox for gapless playback)`,
160
- };
210
+ });
161
211
  }
162
- return {
212
+ return why({
163
213
  backend: "none",
164
214
  streaming: false,
165
215
  detail: "no player found (install ffmpeg or sox)",
166
- };
216
+ });
167
217
  }
168
218
 
169
219
  /** One status line for `dawg auth status` and the header. */
@@ -205,8 +255,21 @@ type PlayerProcess = {
205
255
  readonly stdin: Sink;
206
256
  readonly exited: Promise<number>;
207
257
  kill(): void;
258
+ /** Device-clocked players (the native sink) pace the pump themselves. */
259
+ readonly clock?: PlayerClock;
208
260
  };
209
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
+
210
273
  export type AudioEngineOptions = Readonly<{
211
274
  lockPath?: string;
212
275
  info?: AudioBackendInfo;
@@ -228,6 +291,12 @@ export type AudioEngineOptions = Readonly<{
228
291
  respawnMs?: number;
229
292
  /** Player lifecycle notices worth a status line. */
230
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;
231
300
  }>;
232
301
 
233
302
  export type AudioStatus = Readonly<{
@@ -240,7 +309,13 @@ export type AudioStatus = Readonly<{
240
309
  const MAX_RESPAWNS = 3;
241
310
  /** A player that ran this long before dying resets the respawn budget. */
242
311
  const RESPAWN_RESET_MS = 10_000;
243
- /** 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. */
244
319
  const LEAD_RENDER_FACTOR = 1.5;
245
320
 
246
321
  type Loop = Readonly<{
@@ -334,8 +409,8 @@ type PlayRequest = {
334
409
  *
335
410
  * Timing: the engine is paced by the same monotonic clock as the transport.
336
411
  * 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
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
339
414
  * plays the queue at its own rate, so what you hear trails the transport by
340
415
  * the player's fixed output latency and never accumulates drift from
341
416
  * re-renders. Renders run in a worker; an edit re-renders off-thread and
@@ -351,6 +426,8 @@ export class AudioEngine {
351
426
  private leadFrames: number;
352
427
  private readonly defaultLeadFrames: number;
353
428
  private readonly tickMs: number;
429
+ /** The lead play mode and auditions pin (see `setLeadMs`). */
430
+ public readonly playLeadMs: number;
354
431
  private readonly now: () => number;
355
432
  private readonly useTimer: boolean;
356
433
  private readonly spawnPlayer: (command: readonly string[]) => PlayerProcess;
@@ -401,17 +478,37 @@ export class AudioEngine {
401
478
  options.info ?? detectAudioBackend({ sampleRate: this.sampleRate });
402
479
  this.lock = new PlaybackLock(options.lockPath);
403
480
  this.leadFrames = Math.round(
404
- ((options.leadMs ?? 200) * this.sampleRate) / 1000,
481
+ ((options.leadMs ?? DEFAULT_LEAD_MS) * this.sampleRate) / 1000,
405
482
  );
406
483
  this.defaultLeadFrames = this.leadFrames;
407
484
  this.fadeFrames = Math.max(
408
485
  1,
409
486
  Math.round(SWAP_FADE_SECONDS * this.sampleRate),
410
487
  );
411
- 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);
412
495
  this.now = options.now ?? (() => performance.now());
413
496
  this.useTimer = options.timer ?? true;
414
- 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);
415
512
  this.respawnMs = options.respawnMs ?? 250;
416
513
  this.onStatus = options.onStatus;
417
514
  this.renderer = new LoopRenderer({
@@ -452,6 +549,17 @@ export class AudioEngine {
452
549
  : Math.max(1, Math.round((ms * this.sampleRate) / 1000));
453
550
  }
454
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
+
455
563
  /** Lead in milliseconds: how far ahead of now a new live voice sounds. */
456
564
  public get leadMs(): number {
457
565
  return (this.lead * 1000) / this.sampleRate;
@@ -619,14 +727,14 @@ export class AudioEngine {
619
727
 
620
728
  /** Frames kept queued ahead of the clock right now. */
621
729
  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
- );
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
+ );
630
738
  // The pump never queues more than a second, so the lead stays under it.
631
739
  return Math.min(
632
740
  Math.max(this.leadFrames, adaptive),
@@ -719,7 +827,18 @@ export class AudioEngine {
719
827
  );
720
828
  }
721
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
+
722
839
  private queuedFrames(): number {
840
+ const clock = this.child?.clock;
841
+ if (clock) return clock.queuedFrames();
723
842
  const elapsed = Math.floor(
724
843
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
725
844
  );
@@ -781,10 +900,21 @@ export class AudioEngine {
781
900
  const child = this.child;
782
901
  const loop = this.loop;
783
902
  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;
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
+ }
788
918
  if (remaining <= 0) return;
789
919
  // Never queue more than one second at once, e.g. after a stalled loop.
790
920
  if (remaining > this.sampleRate) {
@@ -864,6 +994,8 @@ export class AudioEngine {
864
994
 
865
995
  /** Wall time (monotonic ms) stream frame `frame` sounds at. */
866
996
  private frameMs(frame: number): number {
997
+ const clock = this.child?.clock;
998
+ if (clock) return clock.frameMs(frame);
867
999
  return this.startMs + (frame * 1000) / this.sampleRate;
868
1000
  }
869
1001
 
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
  }