@hraness/dawg 0.4.1 → 0.6.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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * One oversampler per nonlinear rig section (stomp, head): 4x at 22.05 kHz
3
+ * and below, 2x at 44.1/48 kHz, from the shared half-band primitives in
4
+ * src/audio/dsp/oversample.ts. The section's linear-phase delay is removed
5
+ * so a rigged track stays on the grid.
6
+ */
7
+ import { Adaa1, logCosh } from "../../dsp/shape.ts";
8
+ import { Oversample2x, Oversample4x } from "../../dsp/oversample.ts";
9
+
10
+ /** Oversampling factor of a nonlinear section at `sampleRate`. */
11
+ export function sectionFactor(sampleRate: number): 2 | 4 {
12
+ return sampleRate <= 32_000 ? 4 : 2;
13
+ }
14
+
15
+ type Oversampler = { process(x: number, fn: (x: number) => number): number };
16
+
17
+ function oversampler(factor: 2 | 4): Oversampler {
18
+ return factor === 4 ? new Oversample4x() : new Oversample2x();
19
+ }
20
+
21
+ const latencies = new Map<number, number>();
22
+
23
+ /** Base-rate delay of the up/down pair (impulse peak), measured once. */
24
+ export function sectionLatency(factor: 2 | 4): number {
25
+ const known = latencies.get(factor);
26
+ if (known !== undefined) return known;
27
+ const os = oversampler(factor);
28
+ let peak = 0;
29
+ let at = 0;
30
+ for (let i = 0; i < 256; i += 1) {
31
+ const y = Math.abs(os.process(i === 0 ? 1 : 0, (x) => x));
32
+ if (y > peak) {
33
+ peak = y;
34
+ at = i;
35
+ }
36
+ }
37
+ latencies.set(factor, at);
38
+ return at;
39
+ }
40
+
41
+ /**
42
+ * Runs `fn` over `buffer` at the oversampled rate, in place and latency
43
+ * compensated. `control(i)` runs at the base rate before sample i.
44
+ */
45
+ export function runSection(
46
+ buffer: Float64Array,
47
+ factor: 2 | 4,
48
+ fn: (x: number) => number,
49
+ control?: (index: number) => void,
50
+ ): void {
51
+ const os = oversampler(factor);
52
+ const latency = sectionLatency(factor);
53
+ const n = buffer.length;
54
+ for (let i = 0; i < n + latency; i += 1) {
55
+ if (control && i < n) control(i);
56
+ const y = os.process(i < n ? buffer[i]! : 0, fn);
57
+ if (i >= latency) buffer[i - latency] = y;
58
+ }
59
+ }
60
+
61
+ // Static curves with closed-form antiderivatives for first-order ADAA.
62
+
63
+ /** Triode-like asymmetry: the positive half clips harder (grid current). */
64
+ const ASYM_B = 0.35;
65
+ const ASYM_T = Math.tanh(ASYM_B);
66
+ const asymF = (x: number) => Math.tanh(x + ASYM_B) - ASYM_T;
67
+ const asymAF = (x: number) => logCosh(x + ASYM_B) - ASYM_T * x;
68
+
69
+ export const adaaTanh = () => new Adaa1(Math.tanh, logCosh);
70
+ export const adaaTriode = () => new Adaa1(asymF, asymAF);
71
+ /** Full-wave rectifier (octave up): |x|, antiderivative x|x|/2. */
72
+ export const adaaRectify = () =>
73
+ new Adaa1(
74
+ (x) => Math.abs(x),
75
+ (x) => 0.5 * x * Math.abs(x),
76
+ );
77
+ /** RAT diode pair: tanh of a cubic-boosted input (no closed-form F). */
78
+ export const ratClip = (x: number) => Math.tanh(x + 0.18 * x * x * x);
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Stomp boxes (`fx.stomp`): fuzz (Big Muff family), face (Fuzz Face), od
3
+ * (Tube Screamer), rat (RAT) and octave (Octavia). Circuit-inspired
4
+ * per-sample models ported from the 0.6 guitar prototype, run inside one
5
+ * oversampler with ADAA shapers.
6
+ */
7
+ import type { Adaa1 } from "../../dsp/shape.ts";
8
+ import { Dc, Hp1, Lp1, clamp, dbToGain } from "./filters.ts";
9
+ import {
10
+ adaaRectify,
11
+ adaaTanh,
12
+ adaaTriode,
13
+ ratClip,
14
+ runSection,
15
+ } from "./section.ts";
16
+
17
+ export const STOMP_TYPES = ["fuzz", "face", "od", "rat", "octave"] as const;
18
+ export type StompType = (typeof STOMP_TYPES)[number];
19
+
20
+ export type StompSettings = {
21
+ gain: number;
22
+ tone: number;
23
+ level: number;
24
+ octave: number;
25
+ };
26
+
27
+ /** A stomp circuit at the oversampled rate `sr`. */
28
+ export type StompCircuit = {
29
+ process(x: number): number;
30
+ /** Re-reads gain, tone and level (control rate, automated only). */
31
+ set(settings: StompSettings): void;
32
+ };
33
+
34
+ export function stompCircuit(
35
+ type: string,
36
+ initial: StompSettings,
37
+ sr: number,
38
+ ): StompCircuit {
39
+ switch (type) {
40
+ case "fuzz":
41
+ return fuzz(initial, sr);
42
+ case "face":
43
+ return face(initial, sr);
44
+ case "rat":
45
+ return rat(initial, sr);
46
+ case "octave":
47
+ return octave(initial, sr);
48
+ default:
49
+ return od(initial, sr);
50
+ }
51
+ }
52
+
53
+ function fuzz(initial: StompSettings, sr: number): StompCircuit {
54
+ // Input HPF, two cascaded clipping stages with interstage lowpass, then
55
+ // the passive LP/HP tone blend (the Muff's mid scoop).
56
+ const hp = new Hp1(90, sr);
57
+ const s1 = adaaTanh();
58
+ const s2 = adaaTanh();
59
+ const lpA = new Lp1(3200, sr);
60
+ const hpB = new Hp1(60, sr);
61
+ const lpB = new Lp1(2600, sr);
62
+ const toneLp = new Lp1(800, sr);
63
+ const toneHp = new Hp1(1600, sr);
64
+ let pre = 1;
65
+ let tone = 0.5;
66
+ let out = 1;
67
+ const set = (p: StompSettings) => {
68
+ pre = 2 ** (0.5 + 0.55 * p.gain);
69
+ tone = clamp(p.tone, 0, 1);
70
+ out = dbToGain(p.level) * 0.7;
71
+ };
72
+ set(initial);
73
+ return {
74
+ set,
75
+ process(input) {
76
+ let x = hp.process(input) * pre;
77
+ x = lpA.process(s1.process(x)) * 6;
78
+ x = lpB.process(s2.process(hpB.process(x)));
79
+ const y = (1 - tone) * toneLp.process(x) + tone * toneHp.process(x) * 1.6;
80
+ return y * out;
81
+ },
82
+ };
83
+ }
84
+
85
+ function face(initial: StompSettings, sr: number): StompCircuit {
86
+ // Asymmetric and round; cleans up as the input drops.
87
+ const hp = new Hp1(40, sr);
88
+ const s = adaaTriode();
89
+ const dc = new Dc(sr, 12);
90
+ const lp = new Lp1(2500, sr);
91
+ let pre = 1;
92
+ let out = 1;
93
+ const set = (p: StompSettings) => {
94
+ pre = 2 ** (0.3 + 0.5 * p.gain);
95
+ lp.set(2500 + 4000 * clamp(p.tone, 0, 1), sr);
96
+ out = dbToGain(p.level) * 0.8;
97
+ };
98
+ set(initial);
99
+ return {
100
+ set,
101
+ process(input) {
102
+ return lp.process(dc.process(s.process(hp.process(input) * pre))) * out;
103
+ },
104
+ };
105
+ }
106
+
107
+ function od(initial: StompSettings, sr: number): StompCircuit {
108
+ // Clean path plus a soft-clipped 720 Hz high-passed gain path (diodes in
109
+ // the op-amp feedback), then the tone lowpass.
110
+ const hp = new Hp1(720, sr);
111
+ const lpGain = new Lp1(5600, sr);
112
+ const s = adaaTanh();
113
+ const tone = new Lp1(700, sr);
114
+ let pre = 1;
115
+ let out = 1;
116
+ const set = (p: StompSettings) => {
117
+ pre = 1 + 10 ** (p.gain / 5);
118
+ tone.set(700 * 2 ** (3 * clamp(p.tone, 0, 1)), sr);
119
+ out = dbToGain(p.level) * 0.6;
120
+ };
121
+ set(initial);
122
+ return {
123
+ set,
124
+ process(x) {
125
+ const y = x + s.process(lpGain.process(hp.process(x) * pre)) * 0.5;
126
+ return tone.process(y) * out;
127
+ },
128
+ };
129
+ }
130
+
131
+ function rat(initial: StompSettings, sr: number): StompCircuit {
132
+ // Op-amp gain whose bandwidth shrinks with gain (LM308 GBW), hard diode
133
+ // clip to ground, then the passive filter.
134
+ const hp = new Hp1(60, sr);
135
+ const lpOp = new Lp1(20_000, sr);
136
+ const filter = new Lp1(475, sr);
137
+ let gain = 1;
138
+ let out = 1;
139
+ const set = (p: StompSettings) => {
140
+ gain = Math.min(1 + 2 ** (0.67 * p.gain + 1.5), 2000);
141
+ lpOp.set(clamp(1.0e6 / 3 / gain, 900, 20_000), sr);
142
+ filter.set(475 * 2 ** (6 * clamp(p.tone, 0, 1)), sr);
143
+ out = dbToGain(p.level) * 0.55;
144
+ };
145
+ set(initial);
146
+ return {
147
+ set,
148
+ process(input) {
149
+ const x = lpOp.process(hp.process(input) * gain * 0.02);
150
+ return filter.process(ratClip(x)) * out;
151
+ },
152
+ };
153
+ }
154
+
155
+ function octave(initial: StompSettings, sr: number): StompCircuit {
156
+ // Rectified (octave-up) path blended into a fuzz.
157
+ const hp = new Hp1(120, sr);
158
+ const rect: Adaa1 = adaaRectify();
159
+ const dc = new Dc(sr, 20);
160
+ const s = adaaTanh();
161
+ const lp = new Lp1(3000, sr);
162
+ let pre = 1;
163
+ let mix = 0.7;
164
+ let out = 1;
165
+ const set = (p: StompSettings) => {
166
+ pre = 2 ** (0.5 + 0.5 * p.gain);
167
+ lp.set(3000 + 4000 * clamp(p.tone, 0, 1), sr);
168
+ mix = clamp(p.octave, 0, 1);
169
+ out = dbToGain(p.level) * 0.7;
170
+ };
171
+ set(initial);
172
+ return {
173
+ set,
174
+ process(input) {
175
+ const x = hp.process(input);
176
+ const o = dc.process(rect.process(x * 2)) * 2;
177
+ return lp.process(s.process(((1 - mix) * x + mix * o) * pre)) * out;
178
+ },
179
+ };
180
+ }
181
+
182
+ /** Stomp calibration input: a 196 Hz (G3) sine at guitar level. */
183
+ export const STOMP_CALIBRATION_DBFS = -18;
184
+
185
+ const stompMakeups = new Map<string, number>();
186
+
187
+ /**
188
+ * Linear gain that brings a pedal at `settings` (level taken as 0 dB) back
189
+ * to the calibration sine's level, so switching or kicking on a stomp does
190
+ * not jump the track by 15-25 dB; `level` stays a trim on top. Measured on
191
+ * the circuit itself, deterministic, cached per settings and rate.
192
+ */
193
+ export function stompMakeup(
194
+ type: string,
195
+ settings: StompSettings,
196
+ sampleRate: number,
197
+ factor: 2 | 4,
198
+ ): number {
199
+ const key = `${sampleRate}|${factor}|${type}|${settings.gain}|${settings.tone}|${settings.octave}`;
200
+ const known = stompMakeups.get(key);
201
+ if (known !== undefined) return known;
202
+ const amplitude = 10 ** (STOMP_CALIBRATION_DBFS / 20) * Math.SQRT2;
203
+ const settle = Math.round(sampleRate * 0.1);
204
+ const length = settle + Math.round(sampleRate * 0.1);
205
+ const buffer = new Float64Array(length);
206
+ const w = (2 * Math.PI * 196) / sampleRate;
207
+ for (let i = 0; i < length; i += 1) buffer[i] = amplitude * Math.sin(w * i);
208
+ const circuit = stompCircuit(
209
+ type,
210
+ { ...settings, level: 0 },
211
+ sampleRate * factor,
212
+ );
213
+ runSection(buffer, factor, (x) => circuit.process(x));
214
+ let sum = 0;
215
+ for (let i = settle; i < length; i += 1) sum += buffer[i]! * buffer[i]!;
216
+ const rms = Math.sqrt(sum / (length - settle));
217
+ const target = 10 ** (STOMP_CALIBRATION_DBFS / 20);
218
+ const makeup = rms > 1e-12 ? target / rms : 1;
219
+ stompMakeups.set(key, makeup);
220
+ if (stompMakeups.size > 4096)
221
+ stompMakeups.delete(stompMakeups.keys().next().value!);
222
+ return makeup;
223
+ }
224
+
225
+ /** Makeup for an automated gain: linear between whole steps. */
226
+ export function stompMakeupAt(
227
+ type: string,
228
+ settings: StompSettings,
229
+ sampleRate: number,
230
+ factor: 2 | 4,
231
+ ): number {
232
+ const g = clamp(settings.gain, 0, 10);
233
+ const g0 = Math.min(9, Math.floor(g));
234
+ const at = (gain: number) =>
235
+ stompMakeup(type, { ...settings, gain }, sampleRate, factor);
236
+ const a = at(g0);
237
+ return a + (at(g0 + 1) - a) * (g - g0);
238
+ }
@@ -16,6 +16,7 @@ import {
16
16
  OnePole,
17
17
  Param,
18
18
  interpolateAutomation,
19
+ tickAtSample,
19
20
  type EffectContext,
20
21
  } from "./common.ts";
21
22
 
@@ -49,7 +50,12 @@ export function applyDelay(
49
50
  const feedbackLane = track.delayFeedbackAutomation ?? [];
50
51
  const mixLane = track.delayMixAutomation ?? [];
51
52
  if (delay.mix <= 0 && mixLane.length === 0) return;
52
- const { sampleRate, samplesPerTick } = context;
53
+ const { sampleRate } = context;
54
+ const synced = !(delay.time !== undefined && delay.time > 0);
55
+ if (context.warp && synced) {
56
+ applyWarpedDelay(left, right, track, context, wetOnly);
57
+ return;
58
+ }
53
59
  const length = Math.max(
54
60
  1,
55
61
  Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
@@ -69,7 +75,7 @@ export function applyDelay(
69
75
  ];
70
76
  for (let index = 0; index < left.length; index += 1) {
71
77
  if (automated && index % CONTROL_SAMPLES === 0) {
72
- const tick = index / samplesPerTick;
78
+ const tick = tickAtSample(context, index);
73
79
  feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
74
80
  mix = interpolateAutomation(mixLane, tick, delay.mix);
75
81
  }
@@ -98,6 +104,89 @@ export function applyDelay(
98
104
  }
99
105
  }
100
106
 
107
+ /**
108
+ * The beat-synced delay under a tempo map: the read head trails the write
109
+ * head by `beats` of score time at every control block, read with linear
110
+ * interpolation, so each echo lands `beats` later on the grid through tempo
111
+ * steps, ramps and fermatas. Same routing and lanes as `applyDelay`.
112
+ */
113
+ function applyWarpedDelay(
114
+ left: Float64Array,
115
+ right: Float64Array,
116
+ track: Track,
117
+ context: EffectContext,
118
+ wetOnly: boolean,
119
+ ): void {
120
+ const delay = track.delay!;
121
+ const warp = context.warp!;
122
+ const { sampleRate } = context;
123
+ const feedbackLane = track.delayFeedbackAutomation ?? [];
124
+ const mixLane = track.delayMixAutomation ?? [];
125
+ // Ticks per beat, recovered from the context's start-tempo sample rate.
126
+ const ticksPerBeat = Math.round(
127
+ (sampleRate * 60) / (context.tempoBpm * context.samplesPerTick),
128
+ );
129
+ const lag = delay.beats * ticksPerBeat;
130
+ const lagAt = (index: number) =>
131
+ Math.max(2, index - warp.sample(warp.tick(index) - lag));
132
+ // Longest lag over the buffer sizes the lines.
133
+ let longest = 1;
134
+ for (let index = 0; index <= left.length; index += CONTROL_SAMPLES)
135
+ longest = Math.max(longest, lagAt(index));
136
+ const size = Math.ceil(longest) + 2;
137
+ const lineL = new Float64Array(size);
138
+ const lineR = new Float64Array(size);
139
+ const pingpong = delay.pingpong === true;
140
+ const highcut =
141
+ delay.highcut === undefined
142
+ ? undefined
143
+ : [
144
+ new OnePole(delay.highcut, sampleRate),
145
+ new OnePole(delay.highcut, sampleRate),
146
+ ];
147
+ let feedback = delay.feedback;
148
+ let mix = delay.mix;
149
+ let lagNow = lagAt(0);
150
+ let lagStep = 0;
151
+ for (let index = 0; index < left.length; index += 1) {
152
+ if (index % CONTROL_SAMPLES === 0) {
153
+ const tick = tickAtSample(context, index);
154
+ feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
155
+ mix = interpolateAutomation(mixLane, tick, delay.mix);
156
+ lagNow = lagAt(index);
157
+ lagStep = (lagAt(index + CONTROL_SAMPLES) - lagNow) / CONTROL_SAMPLES;
158
+ } else lagNow += lagStep;
159
+ const read = index - Math.min(size - 2, lagNow);
160
+ const base = Math.floor(read);
161
+ const fraction = read - base;
162
+ const at = (line: Float64Array, position: number) =>
163
+ position < 0 ? 0 : line[position % size]!;
164
+ const wetL =
165
+ at(lineL, base) * (1 - fraction) + at(lineL, base + 1) * fraction;
166
+ const wetR =
167
+ at(lineR, base) * (1 - fraction) + at(lineR, base + 1) * fraction;
168
+ const dryL = left[index]!;
169
+ const dryR = right[index]!;
170
+ let writeL: number;
171
+ let writeR: number;
172
+ if (pingpong) {
173
+ writeL = (dryL + dryR) * 0.5 + wetR * feedback;
174
+ writeR = wetL * feedback;
175
+ } else {
176
+ writeL = dryL + wetR * feedback;
177
+ writeR = dryR + wetL * feedback;
178
+ }
179
+ if (highcut) {
180
+ writeL = highcut[0]!.process(writeL);
181
+ writeR = highcut[1]!.process(writeR);
182
+ }
183
+ lineL[index % size] = writeL;
184
+ lineR[index % size] = writeR;
185
+ left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
186
+ right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
187
+ }
188
+ }
189
+
101
190
  /** Seconds until the track's delay decays below -60 dB, bounded. */
102
191
  export function delayTailFor(track: Track, tempoBpm: number): number {
103
192
  if (!track.delay) return 0;
@@ -211,6 +300,7 @@ function mixParamOf(track: Track, context: EffectContext): Param {
211
300
  track.reverb?.mix ?? 0,
212
301
  track.fxAutomation?.["reverb-mix"],
213
302
  context.samplesPerTick,
303
+ context.warp,
214
304
  );
215
305
  }
216
306
 
@@ -2,10 +2,19 @@ import type { TrackScore } from "../../core/score.ts";
2
2
  import { PlaybackLock } from "./lock.ts";
3
3
  import { LoopPlayer } from "./player.ts";
4
4
  import { LoopRenderer, type LoopRender } from "./renderer.ts";
5
- import { clickSounds, clicksIn, type ClickLevel } from "./click.ts";
5
+ import {
6
+ clickSounds,
7
+ clicksIn,
8
+ type ClickGrid,
9
+ type ClickLevel,
10
+ } from "./click.ts";
6
11
  import type { LiveNotePcm } from "./live.ts";
12
+ import { measureLoudness, pcmChannels } from "./loudness.ts";
13
+ import type { MasterReport } from "./master.ts";
7
14
  import { levelOf, type SoundLevel } from "./preview.ts";
8
15
  import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
16
+ import { playbackTime } from "./arrange.ts";
17
+ import { transportMapFor, type TransportMap } from "./clock.ts";
9
18
 
10
19
  /**
11
20
  * How dawgd (or a file-mode window) makes sound.
@@ -237,8 +246,35 @@ type Loop = Readonly<{
237
246
  pcm: Int16Array;
238
247
  frames: number;
239
248
  framesPerBeat: number;
249
+ /** The song's tempo map; absent, a beat is `framesPerBeat` frames. */
250
+ map?: TransportMap;
251
+ sampleRate: number;
240
252
  }>;
241
253
 
254
+ /** Loop frame (unwrapped without a map) where transport `beat` sounds. */
255
+ function frameOfBeat(loop: Loop, beat: number): number {
256
+ const map = loop.map;
257
+ if (!map) return beat * loop.framesPerBeat;
258
+ const inner = ((beat % map.loopBeats) + map.loopBeats) % map.loopBeats;
259
+ return map.seconds(inner) * loop.sampleRate;
260
+ }
261
+
262
+ /** Transport beat at loop `frame` (inside one pass with a map). */
263
+ function beatOfFrame(loop: Loop, frame: number): number {
264
+ const map = loop.map;
265
+ if (!map) return frame / loop.framesPerBeat;
266
+ const seconds = frame / loop.sampleRate;
267
+ const inner =
268
+ ((seconds % map.loopSeconds) + map.loopSeconds) % map.loopSeconds;
269
+ return map.beat(inner);
270
+ }
271
+
272
+ /** The beat `frames` stream frames after `beat`. */
273
+ function beatAfter(loop: Loop, beat: number, frames: number): number {
274
+ if (!loop.map) return beat + frames / loop.framesPerBeat;
275
+ return beatOfFrame(loop, frameOfBeat(loop, beat) + frames);
276
+ }
277
+
242
278
  /** A loop fading out after a swap, read on from where it was. */
243
279
  type FadeOut = {
244
280
  loop: Loop;
@@ -259,6 +295,8 @@ export type ClickBus = Readonly<{
259
295
  volume: number;
260
296
  beatsPerBar: number;
261
297
  subdivision: number;
298
+ /** Meter-aware clicks (core/tempo.ts) replacing the fixed grid. */
299
+ grid?: ClickGrid;
262
300
  beatAt: (monotonicMs: number) => number | undefined;
263
301
  }>;
264
302
 
@@ -334,6 +372,9 @@ export class AudioEngine {
334
372
  /** The loop last swapped in, for `level` (measured on demand, once). */
335
373
  private lastLoopPcm: Int16Array | undefined;
336
374
  private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
375
+ private loudnessOf: { pcm: Int16Array; report: MasterReport } | undefined;
376
+ /** Loudness the song master reached on the loop now playing. */
377
+ private lastMaster: MasterReport | undefined;
337
378
  private respawns = 0;
338
379
  private respawnTimer: ReturnType<typeof setTimeout> | undefined;
339
380
  /** Play mode: keep a player running without a loop for live voices. */
@@ -421,6 +462,33 @@ export class AudioEngine {
421
462
  return this.measured.level;
422
463
  }
423
464
 
465
+ /**
466
+ * Integrated loudness and true peak of the loop now playing, after the
467
+ * song master when there is one (the header meter). Without a master the
468
+ * loop's PCM is measured once per render, on first read. Undefined before
469
+ * any render.
470
+ */
471
+ public get loudness(): MasterReport | undefined {
472
+ const pcm = this.lastLoopPcm;
473
+ if (!pcm) return undefined;
474
+ if (this.lastMaster) return this.lastMaster;
475
+ if (this.loudnessOf?.pcm !== pcm) {
476
+ const [left, right] = pcmChannels(pcm);
477
+ const measured = measureLoudness(left, right, this.sampleRate, {
478
+ loop: true,
479
+ });
480
+ this.loudnessOf = {
481
+ pcm,
482
+ report: {
483
+ gainDb: 0,
484
+ integrated: measured.integrated,
485
+ truePeak: measured.truePeak,
486
+ },
487
+ };
488
+ }
489
+ return this.loudnessOf.report;
490
+ }
491
+
424
492
  public get canMonitor(): boolean {
425
493
  return this.info.backend !== "none" && !this.fallback;
426
494
  }
@@ -462,13 +530,14 @@ export class AudioEngine {
462
530
  existing.pcm = note.pcm;
463
531
  existing.frames = note.frames;
464
532
  existing.fadeLeft = undefined;
533
+ existing.fadeFrames = this.releaseFrames(note);
465
534
  } else
466
535
  this.voices.set(id, {
467
536
  pcm: note.pcm,
468
537
  frames: note.frames,
469
538
  position: 0,
470
539
  fadeLeft: undefined,
471
- fadeFrames: Math.max(1, Math.round(this.sampleRate * 0.01)),
540
+ fadeFrames: this.releaseFrames(note),
472
541
  });
473
542
  // Write what is due now so the voice joins the very next chunk.
474
543
  const first = this.written;
@@ -476,6 +545,14 @@ export class AudioEngine {
476
545
  return this.frameMs(first);
477
546
  }
478
547
 
548
+ /** A live voice's note-off fade: 10 ms, or the instrument's release. */
549
+ private releaseFrames(note: LiveNotePcm): number {
550
+ return Math.max(
551
+ 1,
552
+ Math.round(this.sampleRate * (note.releaseSeconds ?? 0.01)),
553
+ );
554
+ }
555
+
479
556
  /** Fade a live voice out over ~10 ms (sustain lifted, mode left). */
480
557
  public noteOff(id: number): void {
481
558
  const voice = this.voices.get(id);
@@ -552,6 +629,7 @@ export class AudioEngine {
552
629
  if (request.generation === this.generation) {
553
630
  await this.apply(this.toLoop(render, request.score), request);
554
631
  this.lastLoopPcm = render.pcm;
632
+ this.lastMaster = render.master;
555
633
  }
556
634
  for (const waiter of request.waiters) waiter.resolve();
557
635
  } catch (error) {
@@ -571,10 +649,11 @@ export class AudioEngine {
571
649
  const beat =
572
650
  request.beat === undefined
573
651
  ? undefined
574
- : request.beat +
575
- ((this.now() - request.atMs) * this.sampleRate) /
576
- 1000 /
577
- loop.framesPerBeat;
652
+ : beatAfter(
653
+ loop,
654
+ request.beat,
655
+ ((this.now() - request.atMs) * this.sampleRate) / 1000,
656
+ );
578
657
  if (this.child) {
579
658
  this.swap(loop, beat);
580
659
  return;
@@ -595,19 +674,10 @@ export class AudioEngine {
595
674
  if (!this.loop || !this.child) return;
596
675
  this.cursor = this.frameForBeat(
597
676
  this.loop,
598
- beat + this.queuedBeats(this.loop),
677
+ beatAfter(this.loop, beat, this.queuedFrames()),
599
678
  );
600
679
  }
601
680
 
602
- /**
603
- * Beats already queued ahead of the wall clock. The next frame written
604
- * sounds this far after now, so a transport beat maps to the write head
605
- * by adding it.
606
- */
607
- private queuedBeats(loop: Loop): number {
608
- return this.queuedFrames() / loop.framesPerBeat;
609
- }
610
-
611
681
  private queuedFrames(): number {
612
682
  const elapsed = Math.floor(
613
683
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
@@ -622,6 +692,7 @@ export class AudioEngine {
622
692
  public async stopAsync(): Promise<void> {
623
693
  this.generation += 1;
624
694
  this.lastLoopPcm = undefined;
695
+ this.lastMaster = undefined;
625
696
  this.respawns = 0;
626
697
  if (this.respawnTimer) clearTimeout(this.respawnTimer);
627
698
  this.respawnTimer = undefined;
@@ -813,15 +884,19 @@ export class AudioEngine {
813
884
  }
814
885
 
815
886
  private toLoop(render: LoopRender, score: TrackScore): Loop {
887
+ const timeline = playbackTime(score);
888
+ const map = transportMapFor(timeline);
816
889
  return {
817
890
  pcm: render.pcm,
818
891
  frames: render.frames,
819
- framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
892
+ framesPerBeat: (60 * this.sampleRate) / timeline.tempoBpm,
893
+ sampleRate: this.sampleRate,
894
+ ...(map ? { map } : {}),
820
895
  };
821
896
  }
822
897
 
823
898
  private frameForBeat(loop: Loop, beat: number): number {
824
- const frame = Math.round(Math.max(0, beat) * loop.framesPerBeat);
899
+ const frame = Math.round(frameOfBeat(loop, Math.max(0, beat)));
825
900
  return ((frame % loop.frames) + loop.frames) % loop.frames;
826
901
  }
827
902
 
@@ -829,12 +904,16 @@ export class AudioEngine {
829
904
  const previous = this.loop;
830
905
  // Keep the musical position: the same beat inside the new loop.
831
906
  let cursor = previous
832
- ? this.frameForBeat(next, this.cursor / previous.framesPerBeat)
907
+ ? this.frameForBeat(next, beatOfFrame(previous, this.cursor))
833
908
  : 0;
834
909
  if (beat !== undefined) {
835
910
  // Re-anchor to the transport only when it disagrees audibly (a seek
836
911
  // or a tempo change); sub-frame rounding must not click on edits.
837
- const anchored = this.frameForBeat(next, beat + this.queuedBeats(next));
912
+ // The next frame written sounds `queuedFrames` after now.
913
+ const anchored = this.frameForBeat(
914
+ next,
915
+ beatAfter(next, beat, this.queuedFrames()),
916
+ );
838
917
  const distance = Math.abs(anchored - cursor);
839
918
  const wrapped = Math.min(distance, next.frames - distance);
840
919
  if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
@@ -920,7 +999,7 @@ export class AudioEngine {
920
999
  if (generation !== this.generation || this.child || this.starting)
921
1000
  return;
922
1001
  const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
923
- const beat = loop ? frame / loop.framesPerBeat : 0;
1002
+ const beat = loop ? beatOfFrame(loop, frame) : 0;
924
1003
  this.starting = this.start(loop, beat).finally(() => {
925
1004
  this.starting = undefined;
926
1005
  });