@hraness/dawg 0.5.0 → 0.6.1
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 +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resample (0.6.1): render one track, one orbit or the whole mix, over the
|
|
3
|
+
* song, a section or a bar range, to a 16-bit WAV a new sampler or granular
|
|
4
|
+
* track plays. The render is the same offline path `dawg render` writes
|
|
5
|
+
* (`renderArrangedPcm`), with the source soloed and, unless `master` is
|
|
6
|
+
* asked for, the song master left out (a pre-master stem), so the same score
|
|
7
|
+
* always gives the same bytes and the same sha256.
|
|
8
|
+
*
|
|
9
|
+
* Level: the sampler plays a resampled stereo file (a ref with `from`) as
|
|
10
|
+
* stereo, each channel scaled by SAMPLE_LEVEL and the centre pan's
|
|
11
|
+
* equal-power gain, so the file is written RESAMPLE_FILE_SCALE louder than
|
|
12
|
+
* the stem (about +0.09 dB) for the new track, at voice gain RESAMPLE_GAIN
|
|
13
|
+
* and pan 0, to match the source in both channels (panned sources and
|
|
14
|
+
* stereo effects keep their image). A granular track reads the mono
|
|
15
|
+
* mixdown. Peaks past full scale clamp.
|
|
16
|
+
*
|
|
17
|
+
* A section or bar range renders from PREROLL_BARS before it (or from the
|
|
18
|
+
* earliest source note still sounding at its start) and drops those
|
|
19
|
+
* frames, so held notes and effect tails cross into the range as in the
|
|
20
|
+
* song.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import {
|
|
24
|
+
SCORE_LIMITS,
|
|
25
|
+
TrackScore,
|
|
26
|
+
ScoreValidationError,
|
|
27
|
+
type SampleProvenance,
|
|
28
|
+
type Track,
|
|
29
|
+
} from "../../core/score.ts";
|
|
30
|
+
import { barTicks, findSection, sectionScore } from "../../core/sections.ts";
|
|
31
|
+
import { secondsAtTick } from "../../core/tempo.ts";
|
|
32
|
+
import { renderArrangedPcm } from "./arrange.ts";
|
|
33
|
+
import { orbitOf } from "./effects/duck.ts";
|
|
34
|
+
import { exportSampleRate } from "./measure.ts";
|
|
35
|
+
import { SAMPLE_LEVEL } from "./sampler.ts";
|
|
36
|
+
import type { SampleBank } from "./samples.ts";
|
|
37
|
+
import { RENDER_CHANNELS, encodeWav } from "./wav.ts";
|
|
38
|
+
|
|
39
|
+
/** Bars rendered before a section or bar range for effect tails. */
|
|
40
|
+
export const PREROLL_BARS = 2;
|
|
41
|
+
|
|
42
|
+
/** Voice gain the new sampler track plays the file at. */
|
|
43
|
+
export const RESAMPLE_GAIN = 2;
|
|
44
|
+
/** File level over the rendered stem so the voice reproduces it. */
|
|
45
|
+
export const RESAMPLE_FILE_SCALE =
|
|
46
|
+
Math.SQRT1_2 / (SAMPLE_LEVEL * RESAMPLE_GAIN * 0.5);
|
|
47
|
+
|
|
48
|
+
export type ResampleSource =
|
|
49
|
+
| Readonly<{ kind: "track"; trackId: string }>
|
|
50
|
+
| Readonly<{ kind: "orbit"; orbit: number }>
|
|
51
|
+
| Readonly<{ kind: "master" }>;
|
|
52
|
+
|
|
53
|
+
export type ResampleRange =
|
|
54
|
+
| Readonly<{ kind: "song" }>
|
|
55
|
+
| Readonly<{ kind: "section"; name: string }>
|
|
56
|
+
| Readonly<{ kind: "bars"; from: number; to: number }>;
|
|
57
|
+
|
|
58
|
+
export type ResampleRequest = Readonly<{
|
|
59
|
+
source: ResampleSource;
|
|
60
|
+
range: ResampleRange;
|
|
61
|
+
/** Keep the song master on the render (always on for `master`). */
|
|
62
|
+
post?: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
|
|
65
|
+
export type ResampleRender = Readonly<{
|
|
66
|
+
wav: Uint8Array;
|
|
67
|
+
sha256: string;
|
|
68
|
+
sampleRate: number;
|
|
69
|
+
seconds: number;
|
|
70
|
+
/** Score tick the rendered range starts at (where the new note goes). */
|
|
71
|
+
startTick: number;
|
|
72
|
+
/** Ticks the range covers (the new note's length). */
|
|
73
|
+
durationTicks: number;
|
|
74
|
+
from: SampleProvenance;
|
|
75
|
+
}>;
|
|
76
|
+
|
|
77
|
+
/** `track:<id>`, `orbit:<n>` or `master`: the provenance source name. */
|
|
78
|
+
export function resampleSourceName(source: ResampleSource): string {
|
|
79
|
+
if (source.kind === "track") return `track:${source.trackId}`;
|
|
80
|
+
if (source.kind === "orbit") return `orbit:${source.orbit}`;
|
|
81
|
+
return "master";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** sha256 of the score's canonical JSON (the provenance pin). */
|
|
85
|
+
export function scoreSha256(score: TrackScore): string {
|
|
86
|
+
return createHash("sha256")
|
|
87
|
+
.update(JSON.stringify(score.toJSON()))
|
|
88
|
+
.digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The tracks a source plays (throws for an unknown track or empty orbit). */
|
|
92
|
+
export function resampleTracks(
|
|
93
|
+
score: TrackScore,
|
|
94
|
+
source: ResampleSource,
|
|
95
|
+
): readonly Track[] {
|
|
96
|
+
if (source.kind === "master") return score.tracks;
|
|
97
|
+
const tracks =
|
|
98
|
+
source.kind === "track"
|
|
99
|
+
? score.tracks.filter((track) => track.id === source.trackId)
|
|
100
|
+
: score.tracks.filter((track) => orbitOf(track) === source.orbit);
|
|
101
|
+
if (tracks.length === 0)
|
|
102
|
+
throw new ScoreValidationError(
|
|
103
|
+
source.kind === "track"
|
|
104
|
+
? `no track ${source.trackId}`
|
|
105
|
+
: `no track plays on orbit ${source.orbit}`,
|
|
106
|
+
"invalid-track",
|
|
107
|
+
);
|
|
108
|
+
return tracks;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The score the render plays: the source soloed (every other track muted),
|
|
113
|
+
* the master removed unless `post`, cut to the range. Returns where the
|
|
114
|
+
* range sits in the song.
|
|
115
|
+
*/
|
|
116
|
+
export function resampleScore(
|
|
117
|
+
score: TrackScore,
|
|
118
|
+
request: ResampleRequest,
|
|
119
|
+
): Readonly<{
|
|
120
|
+
score: TrackScore;
|
|
121
|
+
startTick: number;
|
|
122
|
+
durationTicks: number;
|
|
123
|
+
/** Ticks of lead-in the render plays before the range (sliced off). */
|
|
124
|
+
leadTicks: number;
|
|
125
|
+
}> {
|
|
126
|
+
const sources = new Set(
|
|
127
|
+
resampleTracks(score, request.source).map((track) => track.id),
|
|
128
|
+
);
|
|
129
|
+
const post = request.post === true || request.source.kind === "master";
|
|
130
|
+
const json = { ...score.toJSON() } as Record<string, unknown>;
|
|
131
|
+
json.tracks = score.tracks.map((track) => {
|
|
132
|
+
const copy = { ...track } as Record<string, unknown>;
|
|
133
|
+
delete copy.solo;
|
|
134
|
+
// Solo, not mute, the others: a granular `quant chord` source still
|
|
135
|
+
// hears the song's harmony (muted tracks leave the chord).
|
|
136
|
+
if (request.source.kind !== "master" && sources.has(track.id)) {
|
|
137
|
+
delete copy.muted;
|
|
138
|
+
copy.solo = true;
|
|
139
|
+
}
|
|
140
|
+
return copy;
|
|
141
|
+
});
|
|
142
|
+
if (!post) delete json.master;
|
|
143
|
+
let played = new TrackScore(json as never);
|
|
144
|
+
const ticks = barTicks(played);
|
|
145
|
+
const range = request.range;
|
|
146
|
+
if (range.kind === "song")
|
|
147
|
+
return {
|
|
148
|
+
score: played,
|
|
149
|
+
startTick: 0,
|
|
150
|
+
durationTicks: played.bars * ticks,
|
|
151
|
+
leadTicks: 0,
|
|
152
|
+
};
|
|
153
|
+
let section;
|
|
154
|
+
if (range.kind === "section") {
|
|
155
|
+
section = findSection(played, range.name);
|
|
156
|
+
if (!section)
|
|
157
|
+
throw new ScoreValidationError(
|
|
158
|
+
`no section named ${range.name}${played.sections.length > 0 ? ` (sections: ${played.sections.map((s) => s.name).join(", ")})` : ""}`,
|
|
159
|
+
"invalid-track",
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
if (
|
|
163
|
+
!Number.isInteger(range.from) ||
|
|
164
|
+
!Number.isInteger(range.to) ||
|
|
165
|
+
range.from < 1 ||
|
|
166
|
+
range.to < range.from ||
|
|
167
|
+
range.to > played.bars
|
|
168
|
+
)
|
|
169
|
+
throw new ScoreValidationError(
|
|
170
|
+
`bars must be a-b inside 1-${played.bars}`,
|
|
171
|
+
"invalid-track",
|
|
172
|
+
);
|
|
173
|
+
section = {
|
|
174
|
+
name: "resample",
|
|
175
|
+
startBar: range.from - 1,
|
|
176
|
+
bars: range.to - range.from + 1,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Lead-in: notes still sounding at the range start, and the effect tails
|
|
180
|
+
// of the bars before it, play from where they began (PREROLL_BARS back at
|
|
181
|
+
// least); the render then drops the lead-in frames.
|
|
182
|
+
const from = section.startBar * ticks;
|
|
183
|
+
let leadBar = Math.max(0, section.startBar - PREROLL_BARS);
|
|
184
|
+
for (const note of played.notes)
|
|
185
|
+
if (
|
|
186
|
+
sources.has(note.trackId) &&
|
|
187
|
+
note.startTick < from &&
|
|
188
|
+
note.startTick + note.durationTicks > from
|
|
189
|
+
)
|
|
190
|
+
leadBar = Math.min(leadBar, Math.floor(note.startTick / ticks));
|
|
191
|
+
const lead = section.startBar - leadBar;
|
|
192
|
+
played = sectionScore(played, {
|
|
193
|
+
...section,
|
|
194
|
+
startBar: leadBar,
|
|
195
|
+
bars: section.bars + lead,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
score: played,
|
|
199
|
+
startTick: from,
|
|
200
|
+
durationTicks: section.bars * ticks,
|
|
201
|
+
leadTicks: lead * ticks,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Render a resample to WAV bytes. `samples` is the source score's loaded
|
|
207
|
+
* bank (needed when the source plays samples).
|
|
208
|
+
*/
|
|
209
|
+
export function renderResample(
|
|
210
|
+
score: TrackScore,
|
|
211
|
+
request: ResampleRequest,
|
|
212
|
+
samples?: SampleBank,
|
|
213
|
+
): ResampleRender {
|
|
214
|
+
const {
|
|
215
|
+
score: played,
|
|
216
|
+
startTick,
|
|
217
|
+
durationTicks,
|
|
218
|
+
leadTicks,
|
|
219
|
+
} = resampleScore(score, request);
|
|
220
|
+
// The song's export rate (48 kHz once it has a master), so the new
|
|
221
|
+
// track nulls against the source when both play at that rate.
|
|
222
|
+
const sampleRate = exportSampleRate(score);
|
|
223
|
+
const rendered = renderArrangedPcm(played, {
|
|
224
|
+
sampleRate,
|
|
225
|
+
loop: false,
|
|
226
|
+
...(samples ? { samples } : {}),
|
|
227
|
+
});
|
|
228
|
+
const skip = Math.min(
|
|
229
|
+
rendered.frames,
|
|
230
|
+
Math.round(secondsAtTick(played, leadTicks) * rendered.sampleRate),
|
|
231
|
+
);
|
|
232
|
+
const audio =
|
|
233
|
+
skip === 0
|
|
234
|
+
? rendered
|
|
235
|
+
: {
|
|
236
|
+
...rendered,
|
|
237
|
+
frames: rendered.frames - skip,
|
|
238
|
+
pcm: rendered.pcm.subarray(skip * RENDER_CHANNELS),
|
|
239
|
+
};
|
|
240
|
+
const full = new Int16Array(audio.pcm.length);
|
|
241
|
+
for (let index = 0; index < full.length; index += 1)
|
|
242
|
+
full[index] = Math.max(
|
|
243
|
+
-32_768,
|
|
244
|
+
Math.min(32_767, Math.round(audio.pcm[index]! * RESAMPLE_FILE_SCALE)),
|
|
245
|
+
);
|
|
246
|
+
// Trim the tail that rounds to digital silence (within 1 LSB): the new
|
|
247
|
+
// track's note length comes from the range, not from the file.
|
|
248
|
+
let end = full.length;
|
|
249
|
+
while (end > 0 && Math.abs(full[end - 1]!) <= 1) end -= 1;
|
|
250
|
+
if (end === 0)
|
|
251
|
+
throw new ScoreValidationError(
|
|
252
|
+
`${resampleSourceName(request.source)} is silent here · nothing to resample (add notes, unmute it or pick other bars)`,
|
|
253
|
+
);
|
|
254
|
+
// Keep 20 ms of silence so the sampler's end fade lands on nothing.
|
|
255
|
+
const frames = Math.min(
|
|
256
|
+
audio.frames,
|
|
257
|
+
Math.ceil(end / RENDER_CHANNELS) + Math.round(0.02 * audio.sampleRate),
|
|
258
|
+
);
|
|
259
|
+
const pcm = full.subarray(0, frames * RENDER_CHANNELS);
|
|
260
|
+
const seconds = frames / audio.sampleRate;
|
|
261
|
+
if (seconds > SCORE_LIMITS.maxResampleSeconds)
|
|
262
|
+
throw new ScoreValidationError(
|
|
263
|
+
`the resample is ${Math.round(seconds)} s; the limit is ${SCORE_LIMITS.maxResampleSeconds} s · pick a section or bars`,
|
|
264
|
+
"score-limit",
|
|
265
|
+
);
|
|
266
|
+
const wav = encodeWav(pcm, audio.sampleRate, RENDER_CHANNELS);
|
|
267
|
+
const range = request.range;
|
|
268
|
+
const from: SampleProvenance = {
|
|
269
|
+
source: resampleSourceName(request.source),
|
|
270
|
+
...(range.kind === "section"
|
|
271
|
+
? { section: findSection(score, range.name)?.name ?? range.name }
|
|
272
|
+
: {}),
|
|
273
|
+
...(range.kind === "bars" ? { bars: [range.from, range.to] as const } : {}),
|
|
274
|
+
score: scoreSha256(score),
|
|
275
|
+
};
|
|
276
|
+
return {
|
|
277
|
+
wav,
|
|
278
|
+
sha256: createHash("sha256").update(wav).digest("hex"),
|
|
279
|
+
sampleRate: audio.sampleRate,
|
|
280
|
+
seconds,
|
|
281
|
+
startTick,
|
|
282
|
+
durationTicks,
|
|
283
|
+
from,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The modal percussion engine (0.6, lane f06-modal): renders a track whose
|
|
3
|
+
* instrument is `"modal"` and that carries a `modal` field. Registered in
|
|
4
|
+
* `src/audio/instruments.ts`; without the field the legacy tone voice plays.
|
|
5
|
+
*
|
|
6
|
+
* Per track: notes in onset order, at most `MAX_RESONATOR_VOICES` sounding
|
|
7
|
+
* (the oldest onset is stolen with a 5 ms fade), each a `ModalBank` struck
|
|
8
|
+
* once and rung for up to `MAX_RESONATOR_TAIL` seconds after note-off with
|
|
9
|
+
* early stop on silence. Stealing depends only on earlier onsets, so a
|
|
10
|
+
* render is prefix-stable: adding a later note never changes earlier audio
|
|
11
|
+
* before that note starts.
|
|
12
|
+
*
|
|
13
|
+
* 0.5 hooks: tuning and note cents (`noteHz`), bends and glides
|
|
14
|
+
* (`performance.cents`, faded per tick above 0.45 sr), articulation as
|
|
15
|
+
* timbre only (accent and marcato harden the mallet; staccato damps sooner
|
|
16
|
+
* through the shorter length), sustain pedal (longer lengths) and half
|
|
17
|
+
* pedal (`performance.damp`), velocity curve and humanize (already in the
|
|
18
|
+
* performed notes), and the tempo map (`context.warp`).
|
|
19
|
+
*/
|
|
20
|
+
import type { PerformedNote } from "../../core/expression.ts";
|
|
21
|
+
import {
|
|
22
|
+
MAX_RESONATOR_TAIL,
|
|
23
|
+
MAX_RESONATOR_VOICES,
|
|
24
|
+
MODAL_LANE_PARAMS,
|
|
25
|
+
modalSettings,
|
|
26
|
+
modalTailSeconds,
|
|
27
|
+
RESONATOR_TABLE_VERSION,
|
|
28
|
+
type ModalSettings,
|
|
29
|
+
} from "../../core/resonators.ts";
|
|
30
|
+
import type { AutomationPoint, Track } from "../../core/score.ts";
|
|
31
|
+
import { noteHz } from "../../core/tuning.ts";
|
|
32
|
+
import { ModalBank, MODAL_BLOCK } from "./dsp/modal.ts";
|
|
33
|
+
import { logCosh } from "./dsp/shape.ts";
|
|
34
|
+
import { interpolateAutomation } from "./effects/common.ts";
|
|
35
|
+
import type { EngineContext, InstrumentEngine } from "./instruments.ts";
|
|
36
|
+
import { warpedSpan } from "./warp.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Raised-cosine fade applied to a stolen voice, seconds. At 80 ms the
|
|
40
|
+
* largest per-sample gain step is pi/(2*fade samples): -61 dB at 22.05 kHz,
|
|
41
|
+
* -68 dB at 48 kHz, so stealing a long gong or kempul does not click.
|
|
42
|
+
*/
|
|
43
|
+
const STEAL_FADE = 0.08;
|
|
44
|
+
/** Extra mallet hardness for accent and marcato. */
|
|
45
|
+
const ACCENT_HARDNESS = 0.2;
|
|
46
|
+
/**
|
|
47
|
+
* Track-level safety knee: exactly linear up to KNEE, then a tanh shoulder
|
|
48
|
+
* that never passes CEILING. Single notes (about 0.8 at velocity 0.8) and
|
|
49
|
+
* dyads pass bit-exact; only dense velocity-1 chords reach the shoulder. A
|
|
50
|
+
* six-note chord at velocity 1 sums to about 4-5 and lands under the
|
|
51
|
+
* ceiling, which the centre pan and track gain keep below 0 dBFS (spec
|
|
52
|
+
* test 13).
|
|
53
|
+
*/
|
|
54
|
+
export const MODAL_KNEE = 1;
|
|
55
|
+
const KNEE = MODAL_KNEE;
|
|
56
|
+
const CEILING = 1.38;
|
|
57
|
+
const SPAN = CEILING - KNEE;
|
|
58
|
+
|
|
59
|
+
function knee(x: number): number {
|
|
60
|
+
const a = Math.abs(x);
|
|
61
|
+
if (a <= KNEE) return x;
|
|
62
|
+
return Math.sign(x) * (KNEE + SPAN * Math.tanh((a - KNEE) / SPAN));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Antiderivative of `knee` (even), for first-order ADAA. */
|
|
66
|
+
function kneeF(x: number): number {
|
|
67
|
+
const a = Math.abs(x);
|
|
68
|
+
if (a <= KNEE) return 0.5 * x * x;
|
|
69
|
+
return (
|
|
70
|
+
0.5 * KNEE * KNEE +
|
|
71
|
+
KNEE * (a - KNEE) +
|
|
72
|
+
SPAN * SPAN * logCosh((a - KNEE) / SPAN)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The knee with first-order ADAA only inside the shoulder: while this and
|
|
78
|
+
* the previous sample are both under KNEE the output is the input itself, so
|
|
79
|
+
* the limiter adds no two-tap low-pass or half-sample delay to normal play.
|
|
80
|
+
*/
|
|
81
|
+
export function applyModalKnee(dry: Float64Array, samples: number): void {
|
|
82
|
+
let x1 = 0;
|
|
83
|
+
let F1 = 0;
|
|
84
|
+
for (let index = 0; index < samples; index += 1) {
|
|
85
|
+
const x = dry[index]!;
|
|
86
|
+
const Fx = kneeF(x);
|
|
87
|
+
if (Math.abs(x) > KNEE || Math.abs(x1) > KNEE) {
|
|
88
|
+
const dx = x - x1;
|
|
89
|
+
dry[index] = Math.abs(dx) < 1e-5 ? knee(0.5 * (x + x1)) : (Fx - F1) / dx;
|
|
90
|
+
}
|
|
91
|
+
x1 = x;
|
|
92
|
+
F1 = Fx;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type Planned = Readonly<{
|
|
97
|
+
note: PerformedNote;
|
|
98
|
+
start: number;
|
|
99
|
+
length: number;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
function span(
|
|
103
|
+
note: PerformedNote,
|
|
104
|
+
context: EngineContext,
|
|
105
|
+
): { start: number; length: number } {
|
|
106
|
+
if (context.warp)
|
|
107
|
+
return warpedSpan(context.warp, note.startTick, note.durationTicks);
|
|
108
|
+
const perTick =
|
|
109
|
+
(60 * context.sampleRate) / (context.tempoBpm * context.ticksPerBeat);
|
|
110
|
+
return {
|
|
111
|
+
start: Math.max(0, Math.floor(note.startTick * perTick)),
|
|
112
|
+
length: Math.max(1, Math.floor(note.durationTicks * perTick)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function laneOf(
|
|
117
|
+
track: Track,
|
|
118
|
+
param: string,
|
|
119
|
+
): readonly AutomationPoint[] | undefined {
|
|
120
|
+
const lanes = track.fxAutomation as
|
|
121
|
+
Readonly<Record<string, readonly AutomationPoint[]>> | undefined;
|
|
122
|
+
const points = lanes?.[`modal-${param}`];
|
|
123
|
+
return points && points.length > 0 ? points : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Renders a modal track's performed notes into `dry` (mono). */
|
|
127
|
+
export function renderResonatorTrack(
|
|
128
|
+
dry: Float64Array,
|
|
129
|
+
notes: readonly PerformedNote[],
|
|
130
|
+
track: Track,
|
|
131
|
+
context: EngineContext,
|
|
132
|
+
): void {
|
|
133
|
+
const { sampleRate, samples } = context;
|
|
134
|
+
const samplesPerTick = context.samplesPerTick;
|
|
135
|
+
const tickAt = (index: number): number =>
|
|
136
|
+
context.warp ? context.warp.tick(index) : index / samplesPerTick;
|
|
137
|
+
const planned: Planned[] = notes
|
|
138
|
+
.map((note) => ({ note, ...span(note, context) }))
|
|
139
|
+
.filter((entry) => entry.start < samples)
|
|
140
|
+
.sort(
|
|
141
|
+
(a, b) =>
|
|
142
|
+
a.start - b.start ||
|
|
143
|
+
a.note.pitch - b.note.pitch ||
|
|
144
|
+
(a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
|
|
145
|
+
);
|
|
146
|
+
const lanes = new Map<string, readonly AutomationPoint[]>();
|
|
147
|
+
for (const { param } of MODAL_LANE_PARAMS) {
|
|
148
|
+
const points = laneOf(track, param);
|
|
149
|
+
if (points) lanes.set(param, points);
|
|
150
|
+
}
|
|
151
|
+
const motorLane = lanes.get("motordepth");
|
|
152
|
+
const volumeLane = track.volumeAutomation ?? [];
|
|
153
|
+
const volume = Math.max(0, Math.min(1, track.volume ?? 1));
|
|
154
|
+
const gainAt = (index: number): number =>
|
|
155
|
+
volume *
|
|
156
|
+
(volumeLane.length > 0
|
|
157
|
+
? interpolateAutomation(volumeLane, tickAt(index), 1)
|
|
158
|
+
: 1);
|
|
159
|
+
const seedTick = context.seedTick ?? 0;
|
|
160
|
+
const base = modalSettings(track.modal);
|
|
161
|
+
// An ombak pair (f061-gamelan-winds): the pengisep names its partner in
|
|
162
|
+
// `pair` and sounds `ombak` Hz above the same note, single voice; the
|
|
163
|
+
// partner (pengumbang) carries ombak 0. Each track renders from its own
|
|
164
|
+
// field, so stems key per track.
|
|
165
|
+
const paired = track.modal?.pair !== undefined;
|
|
166
|
+
const banks: ModalBank[] = [];
|
|
167
|
+
// Conservative end of each planned voice (its ring cap, or a steal).
|
|
168
|
+
const ends: number[] = [];
|
|
169
|
+
const steals: number[] = [];
|
|
170
|
+
const sounding: number[] = [];
|
|
171
|
+
for (let i = 0; i < planned.length; i += 1) {
|
|
172
|
+
const { note, start, length } = planned[i]!;
|
|
173
|
+
const onsetTick = note.startTick;
|
|
174
|
+
const settings =
|
|
175
|
+
lanes.size === 0
|
|
176
|
+
? base
|
|
177
|
+
: clampLanes(
|
|
178
|
+
modalSettings(track.modal, (param) => {
|
|
179
|
+
const points = lanes.get(param);
|
|
180
|
+
return points
|
|
181
|
+
? interpolateAutomation(
|
|
182
|
+
points,
|
|
183
|
+
onsetTick,
|
|
184
|
+
base[param as keyof typeof base] as number,
|
|
185
|
+
)
|
|
186
|
+
: undefined;
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const performance = note.performance;
|
|
190
|
+
const accent =
|
|
191
|
+
performance?.accent || note.articulation === "marcato"
|
|
192
|
+
? ACCENT_HARDNESS
|
|
193
|
+
: 0;
|
|
194
|
+
const hz = noteHz(note.pitch, note.cents, context.tuning);
|
|
195
|
+
const bank = new ModalBank(
|
|
196
|
+
paired ? { ...settings, ombak: 0 } : settings,
|
|
197
|
+
{
|
|
198
|
+
hz: paired ? hz + settings.ombak : hz,
|
|
199
|
+
velocity: note.velocity,
|
|
200
|
+
start: start / sampleRate,
|
|
201
|
+
duration: length / sampleRate,
|
|
202
|
+
seed: `${note.id}:${note.startTick + seedTick}:modal`,
|
|
203
|
+
...(accent ? { accent } : {}),
|
|
204
|
+
...(performance?.cents ? { cents: performance.cents } : {}),
|
|
205
|
+
...(motorLane
|
|
206
|
+
? {
|
|
207
|
+
motordepth: (t: number) =>
|
|
208
|
+
interpolateAutomation(
|
|
209
|
+
motorLane,
|
|
210
|
+
tickAt(start + t * sampleRate),
|
|
211
|
+
settings.motordepth,
|
|
212
|
+
),
|
|
213
|
+
}
|
|
214
|
+
: {}),
|
|
215
|
+
},
|
|
216
|
+
sampleRate,
|
|
217
|
+
MAX_RESONATOR_TAIL,
|
|
218
|
+
);
|
|
219
|
+
banks.push(bank);
|
|
220
|
+
ends.push(start + bank.endAt);
|
|
221
|
+
steals.push(Infinity);
|
|
222
|
+
// Voice stealing in onset order: drop finished voices, then steal the
|
|
223
|
+
// oldest onset while the pool is full.
|
|
224
|
+
for (let k = sounding.length - 1; k >= 0; k -= 1)
|
|
225
|
+
if (ends[sounding[k]!]! <= start) sounding.splice(k, 1);
|
|
226
|
+
while (sounding.length >= MAX_RESONATOR_VOICES) {
|
|
227
|
+
const oldest = sounding.shift()!;
|
|
228
|
+
steals[oldest] = start;
|
|
229
|
+
ends[oldest] = Math.min(
|
|
230
|
+
ends[oldest]!,
|
|
231
|
+
start + Math.round(STEAL_FADE * sampleRate),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
sounding.push(i);
|
|
235
|
+
}
|
|
236
|
+
const scratch = new Float64Array(MODAL_BLOCK);
|
|
237
|
+
const fade = Math.max(1, Math.round(STEAL_FADE * sampleRate));
|
|
238
|
+
for (let i = 0; i < planned.length; i += 1) {
|
|
239
|
+
const { note, start } = planned[i]!;
|
|
240
|
+
const bank = banks[i]!;
|
|
241
|
+
const stop = Math.min(samples, ends[i]!);
|
|
242
|
+
const steal = steals[i]!;
|
|
243
|
+
const damp = note.performance?.damp;
|
|
244
|
+
const dampFrom = damp ? start + Math.round(damp.from * sampleRate) : 0;
|
|
245
|
+
let at = start;
|
|
246
|
+
while (at < stop) {
|
|
247
|
+
const count = Math.min(MODAL_BLOCK, stop - at);
|
|
248
|
+
scratch.fill(0, 0, count);
|
|
249
|
+
const alive = bank.process(scratch, 0, count);
|
|
250
|
+
const gain = gainAt(at);
|
|
251
|
+
for (let j = 0; j < count; j += 1) {
|
|
252
|
+
const index = at + j;
|
|
253
|
+
let level = gain;
|
|
254
|
+
if (index >= steal)
|
|
255
|
+
level *=
|
|
256
|
+
index - steal >= fade
|
|
257
|
+
? 0
|
|
258
|
+
: 0.5 + 0.5 * Math.cos((Math.PI * (index - steal)) / fade);
|
|
259
|
+
if (damp && index > dampFrom)
|
|
260
|
+
level *= Math.exp(-(index - dampFrom) / (damp.tau * sampleRate));
|
|
261
|
+
dry[index]! += scratch[j]! * level;
|
|
262
|
+
}
|
|
263
|
+
if (!alive) break;
|
|
264
|
+
at += count;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Causal and stateless across renders, so the output stays prefix-stable.
|
|
268
|
+
applyModalKnee(dry, samples);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Lane values land inside each parameter's range. */
|
|
272
|
+
function clampLanes(settings: ModalSettings): ModalSettings {
|
|
273
|
+
const out: Record<string, number | string> = { ...settings };
|
|
274
|
+
for (const { param, spec } of MODAL_LANE_PARAMS) {
|
|
275
|
+
const value = out[param];
|
|
276
|
+
if (typeof value === "number")
|
|
277
|
+
out[param] = Math.max(spec.min, Math.min(spec.max, value));
|
|
278
|
+
}
|
|
279
|
+
return Object.freeze(out) as ModalSettings;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The modal engine as registered in `src/audio/instruments.ts`. */
|
|
283
|
+
export const MODAL_ENGINE: InstrumentEngine = Object.freeze({
|
|
284
|
+
id: "modal",
|
|
285
|
+
field: "modal",
|
|
286
|
+
render(dry, _dryR, notes, track, context) {
|
|
287
|
+
renderResonatorTrack(dry, notes, track, context);
|
|
288
|
+
},
|
|
289
|
+
tailSeconds: (track: Track, lowestPitch?: number) =>
|
|
290
|
+
modalTailSeconds(track.modal, lowestPitch),
|
|
291
|
+
// Undamped bars and gongs ring on after the key lifts, as struck metal does.
|
|
292
|
+
ringOut: (track: Track) => modalSettings(track.modal).damp === 0,
|
|
293
|
+
stereo: () => false,
|
|
294
|
+
assetDigests: () => [`resonators:${RESONATOR_TABLE_VERSION}`],
|
|
295
|
+
});
|