@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.
- package/CHANGELOG.md +70 -0
- package/DAWG.md +293 -31
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +90 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +74 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +301 -26
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +709 -33
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/audio/clock.ts
CHANGED
|
@@ -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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/audio/effects/bus.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
package/src/audio/engine.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
:
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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) /
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
993
|
+
const beat = loop ? beatOfFrame(loop, frame) : 0;
|
|
924
994
|
this.starting = this.start(loop, beat).finally(() => {
|
|
925
995
|
this.starting = undefined;
|
|
926
996
|
});
|