@hraness/dawg 0.4.0 → 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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  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/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. 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
  }
@@ -21,7 +21,15 @@ import {
21
21
  import { applyChorus, applyLeslie, applyPhaser } from "./modulation.ts";
22
22
  import { applyDelay, applyReverb } from "./space.ts";
23
23
 
24
- export { delayTailFor, reverbImpulse, reverbTailFor } from "./space.ts";
24
+ export {
25
+ addReverbWet,
26
+ delayTailFor,
27
+ reverbActive,
28
+ reverbImpulse,
29
+ reverbTailFor,
30
+ reverbWet,
31
+ type ReverbWet,
32
+ } from "./space.ts";
25
33
  export { interpolateAutomation, type EffectContext } from "./common.ts";
26
34
 
27
35
  type MonoStage = (
@@ -77,6 +85,7 @@ export function applyMonoChain(
77
85
  /**
78
86
  * Stereo stages, in chain order, after pan. A track on a shared orbit bus
79
87
  * (`sends: false`) skips delay and reverb: the bus applies them to the sum.
88
+ * `reverb: false` stops before the reverb (the stem cache runs it apart).
80
89
  */
81
90
  export function applyStereoChain(
82
91
  left: Float64Array,
@@ -84,12 +93,13 @@ export function applyStereoChain(
84
93
  track: Track,
85
94
  context: EffectContext,
86
95
  sends = true,
96
+ reverb = sends,
87
97
  ): void {
88
98
  for (const stage of FX_CHAIN.slice(PAN_INDEX + 1)) {
89
99
  if (stage === "delay") {
90
100
  if (sends) applyDelay(left, right, track, context);
91
101
  } else if (stage === "reverb") {
92
- if (sends) applyReverb(left, right, track, context);
102
+ if (reverb) applyReverb(left, right, track, context);
93
103
  } else {
94
104
  const values = track.fx?.[stage as FxName];
95
105
  const apply = STEREO[stage as FxName];
@@ -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
  };
@@ -119,9 +119,48 @@ export function convolveStereo(
119
119
  gain: number,
120
120
  gainAt?: (index: number) => number,
121
121
  ): void {
122
+ const rawL = new Float64Array(input.length);
123
+ const rawR = new Float64Array(input.length);
124
+ const scale = convolveRaw(input, impulse, rawL, rawR);
125
+ if (scale === 0) return;
126
+ addConvolved(rawL, rawR, scale, outL, outR, gain, gainAt);
127
+ }
128
+
129
+ /**
130
+ * Adds the unscaled convolution from `convolveRaw` at `gain` (or `gainAt`
131
+ * per sample): `out += raw · (gain · scale)`, the order `convolveStereo`
132
+ * always used, so a cached raw signal mixes to the same samples.
133
+ */
134
+ export function addConvolved(
135
+ rawL: Float64Array,
136
+ rawR: Float64Array,
137
+ scale: number,
138
+ outL: Float64Array,
139
+ outR: Float64Array,
140
+ gain: number,
141
+ gainAt?: (index: number) => number,
142
+ ): void {
143
+ for (let index = 0; index < rawL.length; index += 1) {
144
+ const g = (gainAt ? gainAt(index) : gain) * scale;
145
+ outL[index]! += rawL[index]! * g;
146
+ outR[index]! += rawR[index]! * g;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The convolution input ∗ impulse into `rawL`/`rawR` before its inverse-FFT
152
+ * scale, which it returns. Samples past the input are left as they were
153
+ * (zero for fresh arrays), as is everything when the impulse is empty.
154
+ */
155
+ export function convolveRaw(
156
+ input: Float64Array,
157
+ impulse: Impulse,
158
+ rawL: Float64Array,
159
+ rawR: Float64Array,
160
+ ): number {
122
161
  const length = input.length;
123
162
  const irLength = impulse.left.length;
124
- if (length === 0 || irLength === 0) return;
163
+ if (length === 0 || irLength === 0) return 0;
125
164
  const block = partitionSize(irLength);
126
165
  const n = block * 2;
127
166
  const partitions = Math.ceil(irLength / block);
@@ -179,12 +218,11 @@ export function convolveStereo(
179
218
  const outStart = k * block;
180
219
  const count = Math.min(block, length - outStart);
181
220
  for (let i = 0; i < count; i += 1) {
182
- const index = outStart + i;
183
- const g = (gainAt ? gainAt(index) : gain) * scale;
184
- outL[index]! += accRe[block + i]! * g;
185
- outR[index]! += accIm[block + i]! * g;
221
+ rawL[outStart + i] = accRe[block + i]!;
222
+ rawR[outStart + i] = accIm[block + i]!;
186
223
  }
187
224
  }
225
+ return scale;
188
226
  }
189
227
 
190
228
  /** Built-in impulse names (`builtin:<name>`). */
@@ -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),