@hraness/dawg 0.4.1 → 0.5.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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. package/tui/keys.ts +72 -0
@@ -1,7 +1,40 @@
1
+ import { loopTicksOf, timeMapFor, type TimeScore } from "../../core/tempo.ts";
2
+
3
+ /**
4
+ * Beats and seconds through a song's tempo map, one loop at a time: the
5
+ * transport runs in score beats, and the map says how long each loop pass
6
+ * takes. Absent, the clock keeps its constant-tempo arithmetic.
7
+ */
8
+ export type TransportMap = Readonly<{
9
+ loopBeats: number;
10
+ loopSeconds: number;
11
+ /** Seconds into a loop pass at `beat` (0..loopBeats). */
12
+ seconds(beat: number): number;
13
+ /** Beat inside a loop pass at `seconds` (0..loopSeconds). */
14
+ beat(seconds: number): number;
15
+ }>;
16
+
17
+ /** The transport map for a score, or undefined at a constant tempo. */
18
+ export function transportMapFor(score: TimeScore): TransportMap | undefined {
19
+ const map = timeMapFor(score);
20
+ if (!map) return undefined;
21
+ const tpb = score.ticksPerBeat;
22
+ const loopBeats = loopTicksOf(score) / tpb;
23
+ const loopSeconds = map.seconds(loopTicksOf(score));
24
+ if (!(loopBeats > 0) || !(loopSeconds > 0)) return undefined;
25
+ return Object.freeze({
26
+ loopBeats,
27
+ loopSeconds,
28
+ seconds: (beat: number) => map.seconds(beat * tpb),
29
+ beat: (seconds: number) => map.tick(seconds) / tpb,
30
+ });
31
+ }
32
+
1
33
  export class TransportClock {
2
34
  private startedAtMs: number | undefined;
3
35
  private pausedBeat = 0;
4
36
  private bpm: number;
37
+ private map: TransportMap | undefined;
5
38
 
6
39
  constructor(bpm = 120) {
7
40
  this.bpm = bpm;
@@ -24,6 +57,7 @@ export class TransportClock {
24
57
 
25
58
  beatAt(nowMs = performance.now()): number {
26
59
  if (this.startedAtMs === undefined) return this.pausedBeat;
60
+ if (this.map) return Math.max(0, this.msToBeat(nowMs - this.startedAtMs));
27
61
  return Math.max(0, (nowMs - this.startedAtMs) / this.beatToMs(1));
28
62
  }
29
63
 
@@ -40,6 +74,25 @@ export class TransportClock {
40
74
  else this.pausedBeat = beat;
41
75
  }
42
76
 
77
+ /**
78
+ * Follow a song's tempo map (`transportMapFor`), keeping the current
79
+ * beat; undefined returns to the constant `bpm`.
80
+ */
81
+ setTimeMap(map: TransportMap | undefined, nowMs = performance.now()): void {
82
+ if (map === this.map) return;
83
+ const beat = this.beatAt(nowMs);
84
+ this.map = map;
85
+ if (this.startedAtMs !== undefined)
86
+ this.startedAtMs = nowMs - this.beatToMs(beat);
87
+ else this.pausedBeat = beat;
88
+ }
89
+
90
+ /** Follow a score's start tempo and tempo map. */
91
+ follow(score: TimeScore, nowMs = performance.now()): void {
92
+ this.setTempo(score.tempoBpm, nowMs);
93
+ this.setTimeMap(transportMapFor(score), nowMs);
94
+ }
95
+
43
96
  /** Converge to a timestamped transport event from another TUI process. */
44
97
  sync(
45
98
  beat: number,
@@ -50,10 +103,10 @@ export class TransportClock {
50
103
  ): void {
51
104
  if (!Number.isFinite(beat) || beat < 0) return;
52
105
  if (!Number.isFinite(eventAtMs) || !Number.isFinite(nowWallMs)) return;
53
- const elapsed = playing
54
- ? Math.max(0, nowWallMs - eventAtMs) / this.beatToMs(1)
55
- : 0;
56
- const currentBeat = beat + elapsed;
106
+ const elapsedMs = playing ? Math.max(0, nowWallMs - eventAtMs) : 0;
107
+ const currentBeat = this.map
108
+ ? this.msToBeat(this.beatToMs(beat) + elapsedMs)
109
+ : beat + elapsedMs / this.beatToMs(1);
57
110
  if (playing) {
58
111
  this.startedAtMs = nowMonotonicMs - this.beatToMs(currentBeat);
59
112
  } else {
@@ -63,6 +116,19 @@ export class TransportClock {
63
116
  }
64
117
 
65
118
  private beatToMs(beats: number): number {
66
- return (beats * 60_000) / this.bpm;
119
+ const map = this.map;
120
+ if (!map) return (beats * 60_000) / this.bpm;
121
+ const loops = Math.floor(beats / map.loopBeats);
122
+ const inner = beats - loops * map.loopBeats;
123
+ return (loops * map.loopSeconds + map.seconds(inner)) * 1000;
124
+ }
125
+
126
+ private msToBeat(ms: number): number {
127
+ const map = this.map;
128
+ if (!map) return ms / this.beatToMs(1);
129
+ const seconds = ms / 1000;
130
+ const loops = Math.floor(seconds / map.loopSeconds);
131
+ const inner = seconds - loops * map.loopSeconds;
132
+ return loops * map.loopBeats + map.beat(inner);
67
133
  }
68
134
  }
@@ -17,6 +17,7 @@ import type { Track } from "../../../core/score.ts";
17
17
  import {
18
18
  CONTROL_SAMPLES,
19
19
  interpolateAutomation,
20
+ tickAtSample,
20
21
  type EffectContext,
21
22
  } from "./common.ts";
22
23
  import { applyDelay, applyReverb } from "./space.ts";
@@ -58,13 +59,13 @@ function send(
58
59
  stemR: Float64Array,
59
60
  base: number,
60
61
  lane: readonly Readonly<{ tick: number; value: number }>[],
61
- samplesPerTick: number,
62
+ context: Pick<EffectContext, "samplesPerTick" | "warp">,
62
63
  ): void {
63
64
  if (base <= 0 && lane.length === 0) return;
64
65
  let level = base;
65
66
  for (let index = 0; index < outL.length; index += 1) {
66
67
  if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
67
- level = interpolateAutomation(lane, index / samplesPerTick, base);
68
+ level = interpolateAutomation(lane, tickAtSample(context, index), base);
68
69
  outL[index]! += stemL[index]! * level;
69
70
  outR[index]! += stemR[index]! * level;
70
71
  }
@@ -89,7 +90,7 @@ export function sendToBus(
89
90
  stemR,
90
91
  track.delay.mix,
91
92
  track.delayMixAutomation ?? [],
92
- context.samplesPerTick,
93
+ context,
93
94
  );
94
95
  }
95
96
  if (track.reverb) {
@@ -101,7 +102,7 @@ export function sendToBus(
101
102
  stemR,
102
103
  track.reverb.mix,
103
104
  track.fxAutomation?.["reverb-mix"] ?? [],
104
- context.samplesPerTick,
105
+ context,
105
106
  );
106
107
  }
107
108
  }
@@ -12,6 +12,7 @@
12
12
  import type { AutomationPoint, Track } from "../../../core/score.ts";
13
13
  import type { FxLane, FxValues } from "../../../core/fx.ts";
14
14
  import type { DecodedSample } from "../samples.ts";
15
+ import type { SampleWarp } from "../warp.ts";
15
16
 
16
17
  /** Effect parameters are refreshed at this sample interval. */
17
18
  export const CONTROL_SAMPLES = 32;
@@ -22,8 +23,34 @@ export type EffectContext = Readonly<{
22
23
  tempoBpm: number;
23
24
  /** Decoded `reverb.ir` samples by track id (built-in impulses need none). */
24
25
  irs?: ReadonlyMap<string, DecodedSample>;
26
+ /** The song's tempo map in samples; absent: constant tempo. */
27
+ warp?: SampleWarp;
25
28
  }>;
26
29
 
30
+ /** Score tick at sample `index`, through the tempo map when there is one. */
31
+ export function tickAtSample(
32
+ context: Pick<EffectContext, "samplesPerTick" | "warp">,
33
+ index: number,
34
+ ): number {
35
+ return context.warp
36
+ ? context.warp.tick(index)
37
+ : index / context.samplesPerTick;
38
+ }
39
+
40
+ /**
41
+ * Tempo at sample `index` for tempo-synced LFOs and delays: through the
42
+ * tempo map when there is one (ramps, steps and fermata holds included),
43
+ * else the song's constant tempo.
44
+ */
45
+ export function tempoAtSample(
46
+ context: Pick<EffectContext, "tempoBpm" | "warp">,
47
+ index: number,
48
+ ): number {
49
+ return context.warp
50
+ ? context.warp.bpm(context.warp.tick(index))
51
+ : context.tempoBpm;
52
+ }
53
+
27
54
  /** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
28
55
  export function interpolateAutomation(
29
56
  points: readonly AutomationPoint[],
@@ -58,6 +85,7 @@ export class Param {
58
85
  readonly fallback: number,
59
86
  points: readonly AutomationPoint[] | undefined,
60
87
  private readonly samplesPerTick: number,
88
+ private readonly warp?: SampleWarp,
61
89
  ) {
62
90
  this.points = points ?? [];
63
91
  this.automated = this.points.length > 0;
@@ -67,7 +95,7 @@ export class Param {
67
95
  return this.automated
68
96
  ? interpolateAutomation(
69
97
  this.points,
70
- index / this.samplesPerTick,
98
+ this.warp ? this.warp.tick(index) : index / this.samplesPerTick,
71
99
  this.fallback,
72
100
  )
73
101
  : this.fallback;
@@ -93,6 +121,7 @@ export function fxReader(
93
121
  values[name] as number,
94
122
  track.fxAutomation?.[`${effect}-${name}` as FxLane],
95
123
  context.samplesPerTick,
124
+ context.warp,
96
125
  );
97
126
  },
98
127
  };
@@ -10,6 +10,7 @@ import {
10
10
  fxReader,
11
11
  lfo,
12
12
  lfoHz,
13
+ tempoAtSample,
13
14
  type EffectContext,
14
15
  } from "./common.ts";
15
16
 
@@ -42,7 +43,7 @@ export function applyTremolo(
42
43
  for (let index = 0; index < buffer.length; index += 1) {
43
44
  if (index % CONTROL_SAMPLES === 0) {
44
45
  phasor.setHz(
45
- lfoHz(sync, rate.at(index), context.tempoBpm),
46
+ lfoHz(sync, rate.at(index), tempoAtSample(context, index)),
46
47
  context.sampleRate,
47
48
  );
48
49
  if (depth.automated) amount = depth.at(index);
@@ -15,7 +15,9 @@ import {
15
15
  interpolateAutomation,
16
16
  lfo,
17
17
  lfoHz,
18
+ tempoAtSample,
18
19
  resonanceQ,
20
+ tickAtSample,
19
21
  type EffectContext,
20
22
  type FilterType,
21
23
  } from "./common.ts";
@@ -99,7 +101,7 @@ export function applyTrackFilter(
99
101
  const resonanceLane = track.resonanceAutomation ?? [];
100
102
  if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
101
103
  return;
102
- const { sampleRate, samplesPerTick } = context;
104
+ const { sampleRate } = context;
103
105
  const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
104
106
  const staticResonance = track.filter?.resonance ?? 0;
105
107
  const automated = automation.length > 0 || resonanceLane.length > 0;
@@ -111,7 +113,7 @@ export function applyTrackFilter(
111
113
  filter.update(staticCutoff, staticResonance);
112
114
  for (let index = 0; index < buffer.length; index += 1) {
113
115
  if (automated && index % CONTROL_SAMPLES === 0) {
114
- const tick = index / samplesPerTick;
116
+ const tick = tickAtSample(context, index);
115
117
  filter.update(
116
118
  interpolateAutomation(automation, tick, staticCutoff),
117
119
  interpolateAutomation(resonanceLane, tick, staticResonance),
@@ -205,7 +207,7 @@ export function applyAutoFilter(
205
207
  : release * envelope + (1 - release) * level;
206
208
  if (index % CONTROL_SAMPLES === 0) {
207
209
  phasor.setHz(
208
- lfoHz(sync, rate.at(index), context.tempoBpm),
210
+ lfoHz(sync, rate.at(index), tempoAtSample(context, index)),
209
211
  context.sampleRate,
210
212
  );
211
213
  followOctaves = follow.at(index);
@@ -9,6 +9,7 @@ import {
9
9
  clamp,
10
10
  fxReader,
11
11
  lfoHz,
12
+ tempoAtSample,
12
13
  readFractional,
13
14
  type EffectContext,
14
15
  } from "./common.ts";
@@ -57,7 +58,10 @@ export function applyPhaser(
57
58
  };
58
59
  for (let index = 0; index < left.length; index += 1) {
59
60
  if (index % CONTROL_SAMPLES === 0) {
60
- phasor.setHz(lfoHz(sync, rate.at(index), context.tempoBpm), sampleRate);
61
+ phasor.setHz(
62
+ lfoHz(sync, rate.at(index), tempoAtSample(context, index)),
63
+ sampleRate,
64
+ );
61
65
  if (depth.automated) amount = depth.at(index);
62
66
  const frequency = clamp(
63
67
  center + (sweep / 2) * Math.sin(2 * Math.PI * phasor.phase),
@@ -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
  }
@@ -552,6 +620,7 @@ export class AudioEngine {
552
620
  if (request.generation === this.generation) {
553
621
  await this.apply(this.toLoop(render, request.score), request);
554
622
  this.lastLoopPcm = render.pcm;
623
+ this.lastMaster = render.master;
555
624
  }
556
625
  for (const waiter of request.waiters) waiter.resolve();
557
626
  } catch (error) {
@@ -571,10 +640,11 @@ export class AudioEngine {
571
640
  const beat =
572
641
  request.beat === undefined
573
642
  ? undefined
574
- : request.beat +
575
- ((this.now() - request.atMs) * this.sampleRate) /
576
- 1000 /
577
- loop.framesPerBeat;
643
+ : beatAfter(
644
+ loop,
645
+ request.beat,
646
+ ((this.now() - request.atMs) * this.sampleRate) / 1000,
647
+ );
578
648
  if (this.child) {
579
649
  this.swap(loop, beat);
580
650
  return;
@@ -595,19 +665,10 @@ export class AudioEngine {
595
665
  if (!this.loop || !this.child) return;
596
666
  this.cursor = this.frameForBeat(
597
667
  this.loop,
598
- beat + this.queuedBeats(this.loop),
668
+ beatAfter(this.loop, beat, this.queuedFrames()),
599
669
  );
600
670
  }
601
671
 
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
672
  private queuedFrames(): number {
612
673
  const elapsed = Math.floor(
613
674
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
@@ -622,6 +683,7 @@ export class AudioEngine {
622
683
  public async stopAsync(): Promise<void> {
623
684
  this.generation += 1;
624
685
  this.lastLoopPcm = undefined;
686
+ this.lastMaster = undefined;
625
687
  this.respawns = 0;
626
688
  if (this.respawnTimer) clearTimeout(this.respawnTimer);
627
689
  this.respawnTimer = undefined;
@@ -813,15 +875,19 @@ export class AudioEngine {
813
875
  }
814
876
 
815
877
  private toLoop(render: LoopRender, score: TrackScore): Loop {
878
+ const timeline = playbackTime(score);
879
+ const map = transportMapFor(timeline);
816
880
  return {
817
881
  pcm: render.pcm,
818
882
  frames: render.frames,
819
- framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
883
+ framesPerBeat: (60 * this.sampleRate) / timeline.tempoBpm,
884
+ sampleRate: this.sampleRate,
885
+ ...(map ? { map } : {}),
820
886
  };
821
887
  }
822
888
 
823
889
  private frameForBeat(loop: Loop, beat: number): number {
824
- const frame = Math.round(Math.max(0, beat) * loop.framesPerBeat);
890
+ const frame = Math.round(frameOfBeat(loop, Math.max(0, beat)));
825
891
  return ((frame % loop.frames) + loop.frames) % loop.frames;
826
892
  }
827
893
 
@@ -829,12 +895,16 @@ export class AudioEngine {
829
895
  const previous = this.loop;
830
896
  // Keep the musical position: the same beat inside the new loop.
831
897
  let cursor = previous
832
- ? this.frameForBeat(next, this.cursor / previous.framesPerBeat)
898
+ ? this.frameForBeat(next, beatOfFrame(previous, this.cursor))
833
899
  : 0;
834
900
  if (beat !== undefined) {
835
901
  // Re-anchor to the transport only when it disagrees audibly (a seek
836
902
  // or a tempo change); sub-frame rounding must not click on edits.
837
- const anchored = this.frameForBeat(next, beat + this.queuedBeats(next));
903
+ // The next frame written sounds `queuedFrames` after now.
904
+ const anchored = this.frameForBeat(
905
+ next,
906
+ beatAfter(next, beat, this.queuedFrames()),
907
+ );
838
908
  const distance = Math.abs(anchored - cursor);
839
909
  const wrapped = Math.min(distance, next.frames - distance);
840
910
  if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
@@ -920,7 +990,7 @@ export class AudioEngine {
920
990
  if (generation !== this.generation || this.child || this.starting)
921
991
  return;
922
992
  const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
923
- const beat = loop ? frame / loop.framesPerBeat : 0;
993
+ const beat = loop ? beatOfFrame(loop, frame) : 0;
924
994
  this.starting = this.start(loop, beat).finally(() => {
925
995
  this.starting = undefined;
926
996
  });