@hraness/dawg 0.6.0 → 0.7.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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -12
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +171 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +244 -0
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +36 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +50 -7
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +144 -0
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/src/audio/wav.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type Track,
|
|
10
10
|
type TrackScore,
|
|
11
11
|
} from "../../core/score.ts";
|
|
12
|
+
import { autotuneStemDigests } from "./autotune.ts";
|
|
12
13
|
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
13
14
|
import {
|
|
14
15
|
performanceTimingFor,
|
|
@@ -21,9 +22,19 @@ import {
|
|
|
21
22
|
renderSamplerVoices,
|
|
22
23
|
samplerTailSeconds,
|
|
23
24
|
} from "./sampler.ts";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
EFFECT_NAMES,
|
|
27
|
+
FX_CHAIN,
|
|
28
|
+
FX_PRESETS,
|
|
29
|
+
effectSpec,
|
|
30
|
+
} from "../../core/fx.ts";
|
|
25
31
|
import { seededRandom } from "./random.ts";
|
|
26
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
engineFor,
|
|
34
|
+
engineTailSeconds,
|
|
35
|
+
RING_OUT_FADE_SECONDS,
|
|
36
|
+
type InstrumentEngine,
|
|
37
|
+
} from "./instruments.ts";
|
|
27
38
|
import {
|
|
28
39
|
isStereoVoice,
|
|
29
40
|
renderSynthNote,
|
|
@@ -31,7 +42,7 @@ import {
|
|
|
31
42
|
usesSynthVoice,
|
|
32
43
|
type VoiceContext,
|
|
33
44
|
} from "./synth/voice.ts";
|
|
34
|
-
import {
|
|
45
|
+
import { legacyWaveFor } from "./synth/oscillators.ts";
|
|
35
46
|
import {
|
|
36
47
|
EMPTY_SAMPLE_BANK,
|
|
37
48
|
sampleKey,
|
|
@@ -39,13 +50,23 @@ import {
|
|
|
39
50
|
type SampleBank,
|
|
40
51
|
} from "./samples.ts";
|
|
41
52
|
import { synthKit } from "../../core/kits.ts";
|
|
42
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
kitDrumSample,
|
|
55
|
+
kitTailSeconds,
|
|
56
|
+
METAL_SECONDS,
|
|
57
|
+
metalKindForPitch,
|
|
58
|
+
metalSample,
|
|
59
|
+
newKitVoiceState,
|
|
60
|
+
tomRatio,
|
|
61
|
+
} from "./kits.ts";
|
|
43
62
|
import {
|
|
44
63
|
addReverbWet,
|
|
45
64
|
applyMonoChain,
|
|
46
65
|
applyStereoChain,
|
|
47
66
|
delayTailFor,
|
|
48
67
|
interpolateAutomation,
|
|
68
|
+
needsEffectNotes,
|
|
69
|
+
type EffectNote,
|
|
49
70
|
reverbActive,
|
|
50
71
|
reverbImpulse,
|
|
51
72
|
reverbTailFor,
|
|
@@ -73,8 +94,13 @@ import {
|
|
|
73
94
|
slowestBpmOf,
|
|
74
95
|
} from "../../core/tempo.ts";
|
|
75
96
|
import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
|
|
97
|
+
import { clipsDigest, hasClips, renderClips } from "./clips.ts";
|
|
98
|
+
import { isGuideInstrument } from "../../core/clips.ts";
|
|
76
99
|
import { applyMaster, type MasterReport } from "./master.ts";
|
|
77
100
|
import type { SongMaster } from "../../core/master.ts";
|
|
101
|
+
import { resolveTrackRef } from "../../core/routing.ts";
|
|
102
|
+
import { resolveVocoder, VOCODER_INSTRUMENT } from "../../core/vocoder.ts";
|
|
103
|
+
import { applyVocoder, autoGateDb } from "./vocoder/index.ts";
|
|
78
104
|
|
|
79
105
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
80
106
|
|
|
@@ -86,6 +112,12 @@ export type RenderOptions = WavOptions &
|
|
|
86
112
|
* what the streaming engine plays; exports keep the default one-shot.
|
|
87
113
|
*/
|
|
88
114
|
loop?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* 0.7 clips: a `vocal` track's guide notes sound as a soft sine. The
|
|
117
|
+
* live loop (audition, play mode) sets it; exports never do, so a
|
|
118
|
+
* rendered file holds only the clips.
|
|
119
|
+
*/
|
|
120
|
+
guide?: boolean;
|
|
89
121
|
/**
|
|
90
122
|
* Decoded sampler voices (`SampleLibrary.load`). Without it sampler
|
|
91
123
|
* tracks render silent; scores without samplers are unaffected.
|
|
@@ -97,6 +129,19 @@ export type RenderOptions = WavOptions &
|
|
|
97
129
|
* a single pass of the whole song. Zero (the default) changes nothing.
|
|
98
130
|
*/
|
|
99
131
|
seedTick?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Song seconds at an arranged window's first frame (with a tempo map,
|
|
134
|
+
* more than `seedTick` at the base tempo); engines whose state runs on
|
|
135
|
+
* the song clock (organ wheels and rotors) start there.
|
|
136
|
+
*/
|
|
137
|
+
seedSeconds?: number;
|
|
138
|
+
/** Per-track engine state at the window origin (`windowSeed`). */
|
|
139
|
+
seedState?: Readonly<Record<string, string>>;
|
|
140
|
+
/**
|
|
141
|
+
* The song whose chords a granular `quant chord` reads, at `tick` for
|
|
142
|
+
* this render's tick 0 (a live note renders alone). Absent: the score.
|
|
143
|
+
*/
|
|
144
|
+
quantChord?: Readonly<{ score: TrackScore; tick: number }>;
|
|
100
145
|
}>;
|
|
101
146
|
|
|
102
147
|
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
@@ -107,8 +152,36 @@ export type RenderedAudio = Readonly<{
|
|
|
107
152
|
pcm: Int16Array;
|
|
108
153
|
/** Loudness the song master reached; absent without a master. */
|
|
109
154
|
master?: MasterReport;
|
|
155
|
+
/**
|
|
156
|
+
* Non-finite samples (NaN, Infinity) a track's source or effects made,
|
|
157
|
+
* zeroed before they could poison a feedback stage or the mix (q08);
|
|
158
|
+
* absent when every sample was finite.
|
|
159
|
+
*/
|
|
160
|
+
nonFinite?: NonFiniteReport;
|
|
161
|
+
}>;
|
|
162
|
+
|
|
163
|
+
/** Where a render zeroed non-finite samples. */
|
|
164
|
+
export type NonFiniteReport = Readonly<{
|
|
165
|
+
samples: number;
|
|
166
|
+
/** Track ids in render order, then `bus` for the bus returns. */
|
|
167
|
+
tracks: readonly string[];
|
|
110
168
|
}>;
|
|
111
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Zeroes NaN and Infinity in `buffer` and returns how many it found. A
|
|
172
|
+
* NaN written into an Int16Array stores 0, and one fed to a filter, comb
|
|
173
|
+
* or reverb feedback silences everything after it.
|
|
174
|
+
*/
|
|
175
|
+
export function scrubNonFinite(buffer: Float64Array): number {
|
|
176
|
+
let count = 0;
|
|
177
|
+
for (let i = 0; i < buffer.length; i += 1)
|
|
178
|
+
if (!Number.isFinite(buffer[i]!)) {
|
|
179
|
+
buffer[i] = 0;
|
|
180
|
+
count += 1;
|
|
181
|
+
}
|
|
182
|
+
return count;
|
|
183
|
+
}
|
|
184
|
+
|
|
112
185
|
/** Output channel count for every render and export. */
|
|
113
186
|
export const RENDER_CHANNELS = 2 as const;
|
|
114
187
|
export const DEFAULT_SAMPLE_RATE = 22_050;
|
|
@@ -143,6 +216,8 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
|
143
216
|
// 0.6 instruments (src/audio/instruments.ts), one line per lane.
|
|
144
217
|
"granular",
|
|
145
218
|
"modal",
|
|
219
|
+
// 0.7 clips: a track of audio clips whose notes are guides.
|
|
220
|
+
"vocal",
|
|
146
221
|
] as const);
|
|
147
222
|
|
|
148
223
|
/** Per-track effects understood by the renderer and the agent, in chain
|
|
@@ -191,12 +266,13 @@ const MAX_ENGINE_TAIL_SECONDS = 30;
|
|
|
191
266
|
* The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
|
|
192
267
|
* renders (exports, previews) let engine tails reach their own cap; the
|
|
193
268
|
* 8 s cap applies only to loop folding. Zero without an engine, so legacy
|
|
194
|
-
* renders are unchanged.
|
|
269
|
+
* renders are unchanged. `ringing` counts only ring-out tracks (0.6.1).
|
|
195
270
|
*/
|
|
196
|
-
function oneShotEngineTail(score: TrackScore): number {
|
|
271
|
+
function oneShotEngineTail(score: TrackScore, ringing = false): number {
|
|
197
272
|
let tail = 0;
|
|
198
273
|
for (const track of score.tracks) {
|
|
199
|
-
|
|
274
|
+
const engine = engineFor(track);
|
|
275
|
+
if (!engine || (ringing && engine.ringOut?.(track) !== true)) continue;
|
|
200
276
|
let lowest: number | undefined;
|
|
201
277
|
for (const note of score.notes)
|
|
202
278
|
if (
|
|
@@ -219,6 +295,9 @@ export type RenderContext = Readonly<{
|
|
|
219
295
|
/** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
|
|
220
296
|
warp?: SampleWarp;
|
|
221
297
|
seedTick?: number;
|
|
298
|
+
seedSeconds?: number;
|
|
299
|
+
seedState?: Readonly<Record<string, string>>;
|
|
300
|
+
quantChord?: Readonly<{ score: TrackScore; tick: number }>;
|
|
222
301
|
}>;
|
|
223
302
|
|
|
224
303
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
@@ -265,6 +344,8 @@ type Stem = {
|
|
|
265
344
|
/** Stem, pre-reverb pair and tail (counted in the cache budget). */
|
|
266
345
|
bytes: number;
|
|
267
346
|
used: number;
|
|
347
|
+
/** Non-finite samples zeroed while rendering it (0 or absent: none). */
|
|
348
|
+
nonFinite?: number;
|
|
268
349
|
/** For a track with a reverb: its stem before the reverb, and the tail. */
|
|
269
350
|
room?: Readonly<{
|
|
270
351
|
/** `stemKey` of the track with its reverb mix and mix lane left out. */
|
|
@@ -283,6 +364,9 @@ type Stem = {
|
|
|
283
364
|
* byte-identical to a cold render; mute and solo only pick which stems are
|
|
284
365
|
* summed. Scratch buffers are reused across renders.
|
|
285
366
|
*/
|
|
367
|
+
/** A guide note's level against a plain sine note (about -12 dB). */
|
|
368
|
+
const GUIDE_LEVEL = 0.25;
|
|
369
|
+
|
|
286
370
|
export class StemRenderer {
|
|
287
371
|
private readonly maxCacheBytes: number;
|
|
288
372
|
private readonly stems = new Map<string, Stem>();
|
|
@@ -293,6 +377,11 @@ export class StemRenderer {
|
|
|
293
377
|
>();
|
|
294
378
|
private cacheBytes = 0;
|
|
295
379
|
private renders = 0;
|
|
380
|
+
/** Vocoder modulator taps by source key (0.7), kept one render. */
|
|
381
|
+
private readonly modSources = new Map<
|
|
382
|
+
string,
|
|
383
|
+
{ tap: Float64Array; used: number }
|
|
384
|
+
>();
|
|
296
385
|
private scratch: {
|
|
297
386
|
dry: Float64Array;
|
|
298
387
|
dryR: Float64Array;
|
|
@@ -421,9 +510,16 @@ export class StemRenderer {
|
|
|
421
510
|
...(bank.irs ? { irs: bank.irs } : {}),
|
|
422
511
|
...(warp ? { warp } : {}),
|
|
423
512
|
...(options.seedTick ? { seedTick: options.seedTick } : {}),
|
|
513
|
+
...(options.seedSeconds ? { seedSeconds: options.seedSeconds } : {}),
|
|
514
|
+
...(options.seedState ? { seedState: options.seedState } : {}),
|
|
515
|
+
...(options.quantChord ? { quantChord: options.quantChord } : {}),
|
|
424
516
|
};
|
|
425
517
|
this.renders += 1;
|
|
426
518
|
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
519
|
+
const nonFinite: { samples: number; tracks: string[] } = {
|
|
520
|
+
samples: 0,
|
|
521
|
+
tracks: [],
|
|
522
|
+
};
|
|
427
523
|
mixL.fill(0);
|
|
428
524
|
mixR.fill(0);
|
|
429
525
|
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
@@ -434,6 +530,14 @@ export class StemRenderer {
|
|
|
434
530
|
if (group) group.push(note);
|
|
435
531
|
else groups.set(note.trackId, [note]);
|
|
436
532
|
}
|
|
533
|
+
// Tracks with audio clips (0.7) render even without notes, after the
|
|
534
|
+
// tracks with notes, so songs without clips keep their sum order.
|
|
535
|
+
for (const track of score.tracks)
|
|
536
|
+
if (hasClips(track) && !groups.has(track.id)) groups.set(track.id, []);
|
|
537
|
+
// A built-in vocoder carrier following chords or a drone needs no notes.
|
|
538
|
+
for (const track of score.tracks)
|
|
539
|
+
if (!groups.has(track.id) && vocoderPlaysWithoutNotes(track))
|
|
540
|
+
groups.set(track.id, []);
|
|
437
541
|
// Note expression and track performance (core/expression.ts): the
|
|
438
542
|
// notes as played. A track with neither gets its notes back unchanged.
|
|
439
543
|
const timing = performanceTimingFor(score);
|
|
@@ -482,13 +586,31 @@ export class StemRenderer {
|
|
|
482
586
|
const engine = engineFor(track);
|
|
483
587
|
// The song key always joins an engine's stem key: engines may tune to
|
|
484
588
|
// it (sympathetic strings) without a song tuning.
|
|
485
|
-
const
|
|
589
|
+
const clipDigest = track
|
|
590
|
+
? clipsDigest(track, bank, options.guide === true)
|
|
591
|
+
: undefined;
|
|
592
|
+
const baseDigests =
|
|
486
593
|
engine && track
|
|
487
594
|
? [
|
|
488
|
-
...(engine.assetDigests?.(track, bank) ?? []),
|
|
595
|
+
...(engine.assetDigests?.(track, bank, score) ?? []),
|
|
489
596
|
`key:${score.key ?? ""}`,
|
|
597
|
+
...(clipDigest === undefined ? [] : [`clips:${clipDigest}`]),
|
|
490
598
|
]
|
|
491
|
-
: undefined
|
|
599
|
+
: clipDigest === undefined
|
|
600
|
+
? undefined
|
|
601
|
+
: [`clips:${clipDigest}`];
|
|
602
|
+
// A vocoder carrier (0.7) also keys on its modulator's tap.
|
|
603
|
+
const vocoderSource = track?.vocoder
|
|
604
|
+
? vocoderSourceOf(track, groups, performed, context, bank)
|
|
605
|
+
: undefined;
|
|
606
|
+
const withVocoder = vocoderSource
|
|
607
|
+
? [...(baseDigests ?? []), vocoderSource.key]
|
|
608
|
+
: baseDigests;
|
|
609
|
+
// 0.7 autotune: its targets (guide notes, chords, key, tuning) and the
|
|
610
|
+
// pitch engine version join the stem key; absent adds nothing.
|
|
611
|
+
const engineDigests = track?.autotune
|
|
612
|
+
? [...(withVocoder ?? []), ...autotuneStemDigests(track, score)]
|
|
613
|
+
: withVocoder;
|
|
492
614
|
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
493
615
|
// table id joins the stem key), undefined for every other instrument.
|
|
494
616
|
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
@@ -544,67 +666,39 @@ export class StemRenderer {
|
|
|
544
666
|
}
|
|
545
667
|
: { left, right };
|
|
546
668
|
dry.fill(0);
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
669
|
+
const stereo = renderVoiceInto(
|
|
670
|
+
dry,
|
|
671
|
+
dryR,
|
|
672
|
+
played,
|
|
673
|
+
track,
|
|
674
|
+
context,
|
|
675
|
+
score,
|
|
676
|
+
bank,
|
|
677
|
+
engine,
|
|
678
|
+
sampler,
|
|
679
|
+
wavetable,
|
|
680
|
+
tuning,
|
|
681
|
+
options.guide === true,
|
|
682
|
+
);
|
|
683
|
+
// The vocoder stage (0.7): after the voice, before the chain.
|
|
684
|
+
if (track && vocoderSource)
|
|
685
|
+
applyVocoder(
|
|
554
686
|
dry,
|
|
555
687
|
stereo ? dryR : undefined,
|
|
556
|
-
|
|
688
|
+
this.vocoderTap(vocoderSource, context, bank),
|
|
557
689
|
track,
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
ticksPerBeat: score.ticksPerBeat,
|
|
561
|
-
...(tuning ? { tuning } : {}),
|
|
562
|
-
},
|
|
563
|
-
bank,
|
|
690
|
+
context,
|
|
691
|
+
vocoderSource.gateDb,
|
|
564
692
|
);
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
...context,
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
};
|
|
575
|
-
const warp = context.warp;
|
|
576
|
-
for (const note of played) {
|
|
577
|
-
const { start, length } = noteSpan(note, context);
|
|
578
|
-
// The voice asks for `startTick + elapsed / samplesPerTick`;
|
|
579
|
-
// through a tempo map that sample's tick comes from the map.
|
|
580
|
-
const noteGainAt = warp
|
|
581
|
-
? (tick: number) =>
|
|
582
|
-
gainAt(
|
|
583
|
-
warp.tick(
|
|
584
|
-
start + (tick - note.startTick) * context.samplesPerTick,
|
|
585
|
-
),
|
|
586
|
-
)
|
|
587
|
-
: gainAt;
|
|
588
|
-
renderSynthNote(
|
|
589
|
-
dry,
|
|
590
|
-
stereo ? dryR : undefined,
|
|
591
|
-
note,
|
|
592
|
-
track,
|
|
593
|
-
start,
|
|
594
|
-
length,
|
|
595
|
-
voice,
|
|
596
|
-
noteGainAt,
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
} else {
|
|
600
|
-
const drums = isDrumInstrument(track?.instrument);
|
|
601
|
-
for (const note of played) {
|
|
602
|
-
if (drums) renderDrumNote(dry, note, track, context);
|
|
603
|
-
else renderToneNote(dry, note, track, context, tuning);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (track) applyMonoChain(dry, track, context);
|
|
607
|
-
if (stereo && track) applyMonoChain(dryR, track, context);
|
|
693
|
+
// A non-finite source sample would poison every feedback stage.
|
|
694
|
+
let bad = scrubNonFinite(dry) + (stereo ? scrubNonFinite(dryR) : 0);
|
|
695
|
+
// Note-aware stages (bloom, swell) see the notes; others never do.
|
|
696
|
+
const chain =
|
|
697
|
+
track && needsEffectNotes(track)
|
|
698
|
+
? { ...context, notes: effectNotes(played, context, tuning) }
|
|
699
|
+
: context;
|
|
700
|
+
if (track) applyMonoChain(dry, track, chain);
|
|
701
|
+
if (stereo && track) applyMonoChain(dryR, track, chain);
|
|
608
702
|
applyPan(
|
|
609
703
|
dry,
|
|
610
704
|
target.left,
|
|
@@ -620,10 +714,12 @@ export class StemRenderer {
|
|
|
620
714
|
target.left,
|
|
621
715
|
target.right,
|
|
622
716
|
track,
|
|
623
|
-
|
|
717
|
+
chain,
|
|
624
718
|
true,
|
|
625
719
|
false,
|
|
626
720
|
);
|
|
721
|
+
// The room's input: scrubbed so the saved pair is clean too.
|
|
722
|
+
bad += scrubNonFinite(target.left) + scrubNonFinite(target.right);
|
|
627
723
|
const pre = {
|
|
628
724
|
left: Float64Array.from(target.left),
|
|
629
725
|
right: Float64Array.from(target.right),
|
|
@@ -638,9 +734,10 @@ export class StemRenderer {
|
|
|
638
734
|
target.left,
|
|
639
735
|
target.right,
|
|
640
736
|
track,
|
|
641
|
-
|
|
737
|
+
chain,
|
|
642
738
|
busOrbit === undefined,
|
|
643
739
|
);
|
|
740
|
+
bad += scrubNonFinite(target.left) + scrubNonFinite(target.right);
|
|
644
741
|
stem = {
|
|
645
742
|
key,
|
|
646
743
|
left: target.left,
|
|
@@ -648,9 +745,14 @@ export class StemRenderer {
|
|
|
648
745
|
bytes: target.left.byteLength * (saved ? 6 : 2),
|
|
649
746
|
used: this.renders,
|
|
650
747
|
...(saved ? { room: saved } : {}),
|
|
748
|
+
...(bad > 0 ? { nonFinite: bad } : {}),
|
|
651
749
|
};
|
|
652
750
|
if (caching) this.store(trackId, stem);
|
|
653
751
|
}
|
|
752
|
+
if (stem.nonFinite) {
|
|
753
|
+
nonFinite.samples += stem.nonFinite;
|
|
754
|
+
nonFinite.tracks.push(trackId);
|
|
755
|
+
}
|
|
654
756
|
if (track && busOrbit !== undefined) {
|
|
655
757
|
let bus = buses.get(busOrbit);
|
|
656
758
|
if (!bus)
|
|
@@ -694,7 +796,27 @@ export class StemRenderer {
|
|
|
694
796
|
if (!groups.has(trackId)) this.evict(trackId);
|
|
695
797
|
// Linear effects superpose, so folding the tail onto the start yields
|
|
696
798
|
// the steady state of the loop playing forever.
|
|
799
|
+
// A ring-out longer than the fold (a gong past 8 s) would be cut there:
|
|
800
|
+
// fade its last RING_OUT_FADE_SECONDS so the fold never clicks. Only
|
|
801
|
+
// ringing 0.6 engine tracks qualify, so legacy loops are untouched.
|
|
802
|
+
if (
|
|
803
|
+
options.loop &&
|
|
804
|
+
samples > frames &&
|
|
805
|
+
oneShotEngineTail(score, true) > (samples - frames) / sampleRate
|
|
806
|
+
)
|
|
807
|
+
fadeEnd(
|
|
808
|
+
mixL,
|
|
809
|
+
mixR,
|
|
810
|
+
samples,
|
|
811
|
+
Math.round(RING_OUT_FADE_SECONDS * sampleRate),
|
|
812
|
+
);
|
|
697
813
|
if (samples > frames) foldTail(mixL, mixR, frames, samples);
|
|
814
|
+
// Bus returns: a last scrub before the master's own feedback stages.
|
|
815
|
+
const busBad = scrubNonFinite(mixL) + scrubNonFinite(mixR);
|
|
816
|
+
if (busBad > 0) {
|
|
817
|
+
nonFinite.samples += busBad;
|
|
818
|
+
nonFinite.tracks.push("bus");
|
|
819
|
+
}
|
|
698
820
|
// The song master (src/audio/master.ts); none leaves the mix untouched.
|
|
699
821
|
const mastered = applyMaster(
|
|
700
822
|
mixL,
|
|
@@ -715,9 +837,40 @@ export class StemRenderer {
|
|
|
715
837
|
frames,
|
|
716
838
|
pcm,
|
|
717
839
|
...(mastered ? { master: mastered.report } : {}),
|
|
840
|
+
...(nonFinite.samples > 0
|
|
841
|
+
? {
|
|
842
|
+
nonFinite: Object.freeze({
|
|
843
|
+
samples: nonFinite.samples,
|
|
844
|
+
tracks: Object.freeze([...nonFinite.tracks]),
|
|
845
|
+
}),
|
|
846
|
+
}
|
|
847
|
+
: {}),
|
|
718
848
|
});
|
|
719
849
|
}
|
|
720
850
|
|
|
851
|
+
/**
|
|
852
|
+
* A vocoder modulator's mono tap over this render: the source's voice,
|
|
853
|
+
* through its mono chain for `tap: chain`, stereo summed (L+R)/2. Kept
|
|
854
|
+
* across renders under the stem cache budget, keyed by the source key.
|
|
855
|
+
*/
|
|
856
|
+
private vocoderTap(
|
|
857
|
+
source: VocoderSource,
|
|
858
|
+
context: RenderContext,
|
|
859
|
+
bank: SampleBank,
|
|
860
|
+
): Float64Array {
|
|
861
|
+
const known = this.modSources.get(source.key);
|
|
862
|
+
if (known && known.tap.length === context.samples) {
|
|
863
|
+
known.used = this.renders;
|
|
864
|
+
return known.tap;
|
|
865
|
+
}
|
|
866
|
+
const tap = renderVocoderTap(source, context, bank);
|
|
867
|
+
for (const [key, entry] of this.modSources)
|
|
868
|
+
if (entry.used < this.renders - 1) this.modSources.delete(key);
|
|
869
|
+
if (this.maxCacheBytes > 0)
|
|
870
|
+
this.modSources.set(source.key, { tap, used: this.renders });
|
|
871
|
+
return tap;
|
|
872
|
+
}
|
|
873
|
+
|
|
721
874
|
private scratchFor(samples: number) {
|
|
722
875
|
if (this.scratch.dry.length < samples) {
|
|
723
876
|
this.scratch = {
|
|
@@ -800,6 +953,21 @@ function addStem(
|
|
|
800
953
|
}
|
|
801
954
|
|
|
802
955
|
/** Fold everything past `frames` back onto the loop start. */
|
|
956
|
+
/** Fade the last `fade` of `samples` mix frames linearly to zero. */
|
|
957
|
+
function fadeEnd(
|
|
958
|
+
mixL: Float64Array,
|
|
959
|
+
mixR: Float64Array,
|
|
960
|
+
samples: number,
|
|
961
|
+
fade: number,
|
|
962
|
+
): void {
|
|
963
|
+
const n = Math.min(fade, samples);
|
|
964
|
+
for (let i = 0; i < n; i += 1) {
|
|
965
|
+
const gain = (n - 1 - i) / n;
|
|
966
|
+
mixL[samples - n + i]! *= gain;
|
|
967
|
+
mixR[samples - n + i]! *= gain;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
803
971
|
function foldTail(
|
|
804
972
|
mixL: Float64Array,
|
|
805
973
|
mixR: Float64Array,
|
|
@@ -925,8 +1093,14 @@ function stemKey(
|
|
|
925
1093
|
...(context.score.tuning || track?.tuning
|
|
926
1094
|
? [context.score.tuning ?? null, context.score.key]
|
|
927
1095
|
: []),
|
|
1096
|
+
// Calibrated engines (0.7) render differently; absent keeps old keys.
|
|
1097
|
+
...(context.score.calibration ? [`cal:${context.score.calibration}`] : []),
|
|
928
1098
|
// Seeded noise follows the window origin of an arranged render.
|
|
929
1099
|
...(context.seedTick ? [`seed:${context.seedTick}`] : []),
|
|
1100
|
+
...(context.seedSeconds ? [`at:${context.seedSeconds}`] : []),
|
|
1101
|
+
...(track && context.seedState?.[track.id]
|
|
1102
|
+
? [`state:${context.seedState[track.id]}`]
|
|
1103
|
+
: []),
|
|
930
1104
|
]);
|
|
931
1105
|
}
|
|
932
1106
|
|
|
@@ -941,25 +1115,307 @@ function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
|
|
|
941
1115
|
});
|
|
942
1116
|
}
|
|
943
1117
|
|
|
944
|
-
/**
|
|
1118
|
+
/** A vocoder carrier's modulator: its track, notes, key and gate. */
|
|
1119
|
+
/** FX_CHAIN index of pan: a `tap: chain` modulator runs the stages before. */
|
|
1120
|
+
const TAP_END = FX_CHAIN.indexOf("pan");
|
|
1121
|
+
|
|
1122
|
+
type VocoderSource = Readonly<{
|
|
1123
|
+
track: Track;
|
|
1124
|
+
played: readonly PerformedNote[];
|
|
1125
|
+
tap: "chain" | "dry";
|
|
1126
|
+
key: string;
|
|
1127
|
+
gateDb: number;
|
|
1128
|
+
}>;
|
|
1129
|
+
|
|
1130
|
+
/** True for a built-in carrier that sounds without notes (chords, drone). */
|
|
1131
|
+
function vocoderPlaysWithoutNotes(track: Track): boolean {
|
|
1132
|
+
if (!track.vocoder || track.instrument !== VOCODER_INSTRUMENT) return false;
|
|
1133
|
+
const follow = resolveVocoder(track.vocoder).follow;
|
|
1134
|
+
return follow === "chords" || follow === "drone";
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const assetGates = new WeakMap<DecodedSample, number>();
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* The auto gate from a source's assets (its sampler files): the quietest
|
|
1141
|
+
* file's 10th-percentile 10 ms frame level + 6 dB, never the render span.
|
|
1142
|
+
* A source without assets (a synth) has no noise floor: the gate is off.
|
|
1143
|
+
*/
|
|
1144
|
+
function sourceGateDb(source: Track, bank: SampleBank): number {
|
|
1145
|
+
let gate: number | undefined;
|
|
1146
|
+
for (const voice of Object.keys(source.sampler?.voices ?? {}).sort()) {
|
|
1147
|
+
const sample = bank.voices.get(sampleKey(source.id, voice));
|
|
1148
|
+
if (!sample) continue;
|
|
1149
|
+
let level = assetGates.get(sample);
|
|
1150
|
+
if (level === undefined) {
|
|
1151
|
+
level = autoGateDb(Float64Array.from(sample.mono), sample.sampleRate);
|
|
1152
|
+
assetGates.set(sample, level);
|
|
1153
|
+
}
|
|
1154
|
+
if (level > -120) gate = gate === undefined ? level : Math.min(gate, level);
|
|
1155
|
+
}
|
|
1156
|
+
return gate ?? -120;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Resolves a carrier's modulator, or undefined (the stage is bypassed). */
|
|
1160
|
+
function vocoderSourceOf(
|
|
1161
|
+
track: Track,
|
|
1162
|
+
groups: ReadonlyMap<string, readonly Note[]>,
|
|
1163
|
+
performed: ReadonlyMap<string, readonly PerformedNote[]>,
|
|
1164
|
+
context: RenderContext,
|
|
1165
|
+
bank: SampleBank,
|
|
1166
|
+
): VocoderSource | undefined {
|
|
1167
|
+
const ref = track.vocoder?.src;
|
|
1168
|
+
if (ref === undefined) return undefined;
|
|
1169
|
+
const source = resolveTrackRef(context.score, ref);
|
|
1170
|
+
if (!source || source.id === track.id) return undefined;
|
|
1171
|
+
const settings = resolveVocoder(track.vocoder);
|
|
1172
|
+
const notes = groups.get(source.id) ?? [];
|
|
1173
|
+
const engine = engineFor(source);
|
|
1174
|
+
// Post-tap fields never change the tap (mute, solo and volume included).
|
|
1175
|
+
const {
|
|
1176
|
+
vocoder: _vocoder,
|
|
1177
|
+
pan: _pan,
|
|
1178
|
+
volume: _volume,
|
|
1179
|
+
volumeAutomation: _volumeAutomation,
|
|
1180
|
+
panAutomation: _panAutomation,
|
|
1181
|
+
delay: _delay,
|
|
1182
|
+
delayFeedbackAutomation: _delayFeedback,
|
|
1183
|
+
delayMixAutomation: _delayMix,
|
|
1184
|
+
reverb: _reverb,
|
|
1185
|
+
...kept
|
|
1186
|
+
} = source;
|
|
1187
|
+
// Only the stages the tap runs (before pan, and none for `tap: dry`):
|
|
1188
|
+
// a source's reverb, delay, double or other post-pan fx never re-render
|
|
1189
|
+
// its carriers. fxAutomation stays (rarely post-pan; a cheap re-render).
|
|
1190
|
+
const tapped = settings.tap === "chain" ? FX_CHAIN.slice(0, TAP_END) : [];
|
|
1191
|
+
const fx = kept.fx
|
|
1192
|
+
? Object.fromEntries(
|
|
1193
|
+
Object.entries(kept.fx).filter(
|
|
1194
|
+
([stage]) =>
|
|
1195
|
+
tapped.includes(stage as never) ||
|
|
1196
|
+
!FX_CHAIN.includes(stage as never),
|
|
1197
|
+
),
|
|
1198
|
+
)
|
|
1199
|
+
: undefined;
|
|
1200
|
+
const stripped = {
|
|
1201
|
+
...kept,
|
|
1202
|
+
fx: fx && Object.keys(fx).length > 0 ? fx : undefined,
|
|
1203
|
+
} as unknown as Track;
|
|
1204
|
+
const gateDb =
|
|
1205
|
+
settings.gate === "auto" ? sourceGateDb(source, bank) : settings.gate;
|
|
1206
|
+
// The modulator's clips (0.7) are heard; guide tones never are.
|
|
1207
|
+
const clipDigest = clipsDigest(source, bank, false);
|
|
1208
|
+
// A tuned modulator (0.7 autotune) keys on its targets too: its guide
|
|
1209
|
+
// track's notes, the chord timeline and the key change the tap.
|
|
1210
|
+
const clipKeys = [
|
|
1211
|
+
...(clipDigest === undefined ? [] : [`clips:${clipDigest}`]),
|
|
1212
|
+
...(source.autotune ? autotuneStemDigests(source, context.score) : []),
|
|
1213
|
+
];
|
|
1214
|
+
const key = stemKey(
|
|
1215
|
+
stripped,
|
|
1216
|
+
notes,
|
|
1217
|
+
context,
|
|
1218
|
+
isSamplerInstrument(source.instrument) ? bank : undefined,
|
|
1219
|
+
wavetableHook(source, bank, context)?.id,
|
|
1220
|
+
undefined,
|
|
1221
|
+
engine
|
|
1222
|
+
? [
|
|
1223
|
+
...(engine.assetDigests?.(source, bank, context.score) ?? []),
|
|
1224
|
+
`key:${context.score.key ?? ""}`,
|
|
1225
|
+
...clipKeys,
|
|
1226
|
+
]
|
|
1227
|
+
: clipKeys.length > 0
|
|
1228
|
+
? clipKeys
|
|
1229
|
+
: undefined,
|
|
1230
|
+
);
|
|
1231
|
+
return {
|
|
1232
|
+
track: source,
|
|
1233
|
+
played: performed.get(source.id) ?? [],
|
|
1234
|
+
tap: settings.tap,
|
|
1235
|
+
key: `vocsrc:${settings.tap}:${gateDb}:${key}`,
|
|
1236
|
+
gateDb,
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Renders a modulator's mono tap (see `StemRenderer.vocoderTap`). */
|
|
1241
|
+
function renderVocoderTap(
|
|
1242
|
+
source: VocoderSource,
|
|
1243
|
+
context: RenderContext,
|
|
1244
|
+
bank: SampleBank,
|
|
1245
|
+
): Float64Array {
|
|
1246
|
+
const { played } = source;
|
|
1247
|
+
// The tap is rendered at unity gain: the voice bakes track volume in
|
|
1248
|
+
// (trackGainAt), and volume is a post-tap field the cache key leaves out.
|
|
1249
|
+
const track: Track = {
|
|
1250
|
+
...source.track,
|
|
1251
|
+
volume: 1,
|
|
1252
|
+
volumeAutomation: [],
|
|
1253
|
+
};
|
|
1254
|
+
const { score } = context;
|
|
1255
|
+
const dry = new Float64Array(context.samples);
|
|
1256
|
+
const dryR = new Float64Array(context.samples);
|
|
1257
|
+
const tuning = resolveTuning(score.tuning, track.tuning, score.key);
|
|
1258
|
+
const stereo = renderVoiceInto(
|
|
1259
|
+
dry,
|
|
1260
|
+
dryR,
|
|
1261
|
+
played,
|
|
1262
|
+
track,
|
|
1263
|
+
context,
|
|
1264
|
+
score,
|
|
1265
|
+
bank,
|
|
1266
|
+
engineFor(track),
|
|
1267
|
+
isSamplerInstrument(track.instrument),
|
|
1268
|
+
wavetableHook(track, bank, context),
|
|
1269
|
+
tuning,
|
|
1270
|
+
false,
|
|
1271
|
+
);
|
|
1272
|
+
if (source.tap === "chain") {
|
|
1273
|
+
const chain = needsEffectNotes(track)
|
|
1274
|
+
? { ...context, notes: effectNotes(played, context, tuning) }
|
|
1275
|
+
: context;
|
|
1276
|
+
applyMonoChain(dry, track, chain);
|
|
1277
|
+
if (stereo) applyMonoChain(dryR, track, chain);
|
|
1278
|
+
}
|
|
1279
|
+
if (stereo)
|
|
1280
|
+
for (let i = 0; i < dry.length; i += 1) dry[i] = (dry[i]! + dryR[i]!) / 2;
|
|
1281
|
+
return dry;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Renders a track's voice (engine, sampler, synth or tone/drum notes) into
|
|
1286
|
+
* `dry` (and `dryR` when stereo) before any effect; returns whether it
|
|
1287
|
+
* wrote a right channel. Shared by the stem pass and the vocoder tap.
|
|
1288
|
+
*/
|
|
1289
|
+
function renderVoiceInto(
|
|
1290
|
+
dry: Float64Array,
|
|
1291
|
+
dryR: Float64Array,
|
|
1292
|
+
played: readonly PerformedNote[],
|
|
1293
|
+
track: Track | undefined,
|
|
1294
|
+
context: RenderContext,
|
|
1295
|
+
score: TrackScore,
|
|
1296
|
+
bank: SampleBank,
|
|
1297
|
+
engine: InstrumentEngine | undefined,
|
|
1298
|
+
sampler: boolean,
|
|
1299
|
+
wavetable: ReturnType<typeof wavetableHook> | undefined,
|
|
1300
|
+
tuning: TuningTable | undefined,
|
|
1301
|
+
guide: boolean,
|
|
1302
|
+
): boolean {
|
|
1303
|
+
const synthVoice = !engine && !sampler && usesSynthVoice(track);
|
|
1304
|
+
let stereo = engine
|
|
1305
|
+
? engine.stereo(track!)
|
|
1306
|
+
: synthVoice && isStereoVoice(track);
|
|
1307
|
+
if (stereo) dryR.fill(0);
|
|
1308
|
+
if (engine && track) {
|
|
1309
|
+
engine.render(
|
|
1310
|
+
dry,
|
|
1311
|
+
stereo ? dryR : undefined,
|
|
1312
|
+
played,
|
|
1313
|
+
track,
|
|
1314
|
+
{
|
|
1315
|
+
...context,
|
|
1316
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
1317
|
+
...(tuning ? { tuning } : {}),
|
|
1318
|
+
},
|
|
1319
|
+
bank,
|
|
1320
|
+
);
|
|
1321
|
+
} else if (sampler) {
|
|
1322
|
+
// A resampled stereo file plays as stereo (0.6.1).
|
|
1323
|
+
if (track)
|
|
1324
|
+
stereo = renderSamplerNotes(dry, played, track, context, bank, dryR);
|
|
1325
|
+
} else if (synthVoice && track) {
|
|
1326
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
1327
|
+
const voice = {
|
|
1328
|
+
...context,
|
|
1329
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
1330
|
+
...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
|
|
1331
|
+
...(tuning ? { tuning } : {}),
|
|
1332
|
+
};
|
|
1333
|
+
const warp = context.warp;
|
|
1334
|
+
for (const note of played) {
|
|
1335
|
+
const { start, length } = noteSpan(note, context);
|
|
1336
|
+
// The voice asks for `startTick + elapsed / samplesPerTick`;
|
|
1337
|
+
// through a tempo map that sample's tick comes from the map.
|
|
1338
|
+
const noteGainAt = warp
|
|
1339
|
+
? (tick: number) =>
|
|
1340
|
+
gainAt(
|
|
1341
|
+
warp.tick(
|
|
1342
|
+
start + (tick - note.startTick) * context.samplesPerTick,
|
|
1343
|
+
),
|
|
1344
|
+
)
|
|
1345
|
+
: gainAt;
|
|
1346
|
+
renderSynthNote(
|
|
1347
|
+
dry,
|
|
1348
|
+
stereo ? dryR : undefined,
|
|
1349
|
+
note,
|
|
1350
|
+
track,
|
|
1351
|
+
start,
|
|
1352
|
+
length,
|
|
1353
|
+
voice,
|
|
1354
|
+
noteGainAt,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
} else if (track?.clips && isGuideInstrument(track.instrument)) {
|
|
1358
|
+
// A `vocal` track's notes guide its clips: silent in a render,
|
|
1359
|
+
// a soft sine in the live loop with `guide` on. (Without clips,
|
|
1360
|
+
// an older project's `vocal` keeps its tone.)
|
|
1361
|
+
if (guide)
|
|
1362
|
+
for (const note of played)
|
|
1363
|
+
renderToneNote(
|
|
1364
|
+
dry,
|
|
1365
|
+
{ ...note, velocity: note.velocity * GUIDE_LEVEL },
|
|
1366
|
+
{ ...track, instrument: "sine" },
|
|
1367
|
+
context,
|
|
1368
|
+
tuning,
|
|
1369
|
+
);
|
|
1370
|
+
} else {
|
|
1371
|
+
const drums = isDrumInstrument(track?.instrument);
|
|
1372
|
+
// Calibration 1+ (0.7): a closed or pedal hat chokes a ringing open hat.
|
|
1373
|
+
const chokes =
|
|
1374
|
+
drums && (context.score.calibration ?? 0) >= 1
|
|
1375
|
+
? hatChokes(played, context)
|
|
1376
|
+
: undefined;
|
|
1377
|
+
for (const note of played) {
|
|
1378
|
+
if (drums) renderDrumNote(dry, note, track, context, chokes?.get(note));
|
|
1379
|
+
else renderToneNote(dry, note, track, context, tuning);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
// Audio clips (0.7) sum into the dry buffer before the chain.
|
|
1383
|
+
if (track?.clips) {
|
|
1384
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
1385
|
+
renderClips(dry, track, context, bank, gainAt, score);
|
|
1386
|
+
if (stereo) renderClips(dryR, track, context, bank, gainAt, score);
|
|
1387
|
+
}
|
|
1388
|
+
return stereo;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Sample voices of a sampler track into its mono dry buffer. When a voice
|
|
1393
|
+
* plays a resampled stereo file (0.6.1), `target` takes the left channel,
|
|
1394
|
+
* `targetRight` the right, and the result is true (the track is stereo).
|
|
1395
|
+
*/
|
|
945
1396
|
function renderSamplerNotes(
|
|
946
1397
|
target: Float64Array,
|
|
947
1398
|
notes: readonly Note[],
|
|
948
1399
|
track: Track,
|
|
949
1400
|
context: RenderContext,
|
|
950
1401
|
bank: SampleBank,
|
|
951
|
-
|
|
1402
|
+
targetRight: Float64Array,
|
|
1403
|
+
): boolean {
|
|
952
1404
|
const timing = {
|
|
953
1405
|
score: context.score,
|
|
954
1406
|
sampleRate: context.sampleRate,
|
|
955
1407
|
...(context.warp ? { warp: context.warp } : {}),
|
|
956
1408
|
};
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
timing,
|
|
961
|
-
|
|
962
|
-
|
|
1409
|
+
const voices = planSamplerVoices(track, notes, bank, timing);
|
|
1410
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
1411
|
+
if (!voices.some((voice) => voice.stereo)) {
|
|
1412
|
+
renderSamplerVoices(target, voices, timing, gainAt);
|
|
1413
|
+
return false;
|
|
1414
|
+
}
|
|
1415
|
+
targetRight.fill(0);
|
|
1416
|
+
renderSamplerVoices(target, voices, timing, gainAt, "left");
|
|
1417
|
+
renderSamplerVoices(targetRight, voices, timing, gainAt, "right");
|
|
1418
|
+
return true;
|
|
963
1419
|
}
|
|
964
1420
|
|
|
965
1421
|
/** Track volume and volume automation at a score tick (pan is applied in stereo). */
|
|
@@ -973,6 +1429,24 @@ function trackGainAt(track: Track | undefined, tick: number): number {
|
|
|
973
1429
|
return trackGain * automatedGain;
|
|
974
1430
|
}
|
|
975
1431
|
|
|
1432
|
+
/** The notes as note-aware effects see them (EffectContext.notes). */
|
|
1433
|
+
function effectNotes(
|
|
1434
|
+
notes: readonly Note[],
|
|
1435
|
+
context: RenderContext,
|
|
1436
|
+
tuning: TuningTable | undefined,
|
|
1437
|
+
): EffectNote[] {
|
|
1438
|
+
return notes.map((note) => {
|
|
1439
|
+
const { start, length } = noteSpan(note, context);
|
|
1440
|
+
return {
|
|
1441
|
+
id: note.id,
|
|
1442
|
+
start,
|
|
1443
|
+
length,
|
|
1444
|
+
hz: noteHz(note.pitch, note.cents, tuning),
|
|
1445
|
+
velocity: note.velocity,
|
|
1446
|
+
};
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
976
1450
|
function noteSpan(
|
|
977
1451
|
note: Note,
|
|
978
1452
|
context: RenderContext,
|
|
@@ -1013,6 +1487,7 @@ function renderToneNote(
|
|
|
1013
1487
|
const performance = (note as PerformedNote).performance;
|
|
1014
1488
|
const cents = performance?.cents;
|
|
1015
1489
|
let bent = 0;
|
|
1490
|
+
const wave = legacyWaveFor(instrument);
|
|
1016
1491
|
for (let index = start; index < end; index += 1) {
|
|
1017
1492
|
const elapsed = index - start;
|
|
1018
1493
|
const remaining = end - index;
|
|
@@ -1039,7 +1514,7 @@ function renderToneNote(
|
|
|
1039
1514
|
const t = elapsed / sampleRate;
|
|
1040
1515
|
const held =
|
|
1041
1516
|
damp && t > damp.from ? Math.exp(-(t - damp.from) / damp.tau) : 1;
|
|
1042
|
-
target[index]! +=
|
|
1517
|
+
target[index]! += wave(phase) * envelope * held;
|
|
1043
1518
|
}
|
|
1044
1519
|
}
|
|
1045
1520
|
|
|
@@ -1048,11 +1523,40 @@ function renderToneNote(
|
|
|
1048
1523
|
* id and start tick, so a hit sounds identical on every render and in every
|
|
1049
1524
|
* window regardless of note order.
|
|
1050
1525
|
*/
|
|
1526
|
+
/** Closed hat (42) and pedal hat (44) GM numbers that choke an open hat. */
|
|
1527
|
+
const CHOKING_HATS = new Set([42, 44]);
|
|
1528
|
+
/** Open hat fade once choked, in seconds. */
|
|
1529
|
+
const CHOKE_SECONDS = 0.008;
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* For each open hat (46), the sample where the next closed or pedal hat
|
|
1533
|
+
* on the same track starts, as on a real hi-hat stand.
|
|
1534
|
+
*/
|
|
1535
|
+
function hatChokes(
|
|
1536
|
+
notes: readonly Note[],
|
|
1537
|
+
context: RenderContext,
|
|
1538
|
+
): Map<Note, number> {
|
|
1539
|
+
const closers = notes
|
|
1540
|
+
.filter((note) => CHOKING_HATS.has(note.pitch))
|
|
1541
|
+
.map((note) => noteSpan(note, context).start)
|
|
1542
|
+
.sort((a, b) => a - b);
|
|
1543
|
+
const chokes = new Map<Note, number>();
|
|
1544
|
+
if (closers.length === 0) return chokes;
|
|
1545
|
+
for (const note of notes) {
|
|
1546
|
+
if (note.pitch !== 46) continue;
|
|
1547
|
+
const start = noteSpan(note, context).start;
|
|
1548
|
+
const next = closers.find((closer) => closer > start);
|
|
1549
|
+
if (next !== undefined) chokes.set(note, next);
|
|
1550
|
+
}
|
|
1551
|
+
return chokes;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1051
1554
|
function renderDrumNote(
|
|
1052
1555
|
target: Float64Array,
|
|
1053
1556
|
note: Note,
|
|
1054
1557
|
track: Track | undefined,
|
|
1055
1558
|
context: RenderContext,
|
|
1559
|
+
chokeAt?: number,
|
|
1056
1560
|
): void {
|
|
1057
1561
|
const { sampleRate, samples, samplesPerTick } = context;
|
|
1058
1562
|
const { start } = noteSpan(note, context);
|
|
@@ -1060,9 +1564,24 @@ function renderDrumNote(
|
|
|
1060
1564
|
// one the voices below render exactly as they always have.
|
|
1061
1565
|
const kit = synthKit(track?.kit);
|
|
1062
1566
|
const kitState = kit ? newKitVoiceState() : undefined;
|
|
1567
|
+
// Calibration 1+ (0.7): pitched toms, GM cymbals and cowbell as metal
|
|
1568
|
+
// instead of a rim click, band-limited kit hats, and hat choke.
|
|
1569
|
+
const calibrated = (context.score.calibration ?? 0) >= 1;
|
|
1570
|
+
const metal = calibrated ? metalKindForPitch(note.pitch) : undefined;
|
|
1571
|
+
const metalState = metal ? newKitVoiceState() : undefined;
|
|
1572
|
+
const ratio = calibrated ? tomRatio(note.pitch) : 1;
|
|
1573
|
+
const chokeEnd =
|
|
1574
|
+
chokeAt === undefined
|
|
1575
|
+
? Number.POSITIVE_INFINITY
|
|
1576
|
+
: chokeAt + Math.ceil(CHOKE_SECONDS * sampleRate);
|
|
1063
1577
|
const end = Math.min(
|
|
1064
1578
|
samples,
|
|
1065
|
-
|
|
1579
|
+
chokeEnd,
|
|
1580
|
+
start +
|
|
1581
|
+
Math.ceil(
|
|
1582
|
+
(metal ? METAL_SECONDS : (kit?.seconds ?? MAX_DRUM_SECONDS)) *
|
|
1583
|
+
sampleRate,
|
|
1584
|
+
),
|
|
1066
1585
|
);
|
|
1067
1586
|
const voice = drumVoiceForPitch(note.pitch);
|
|
1068
1587
|
const random = seededRandom(
|
|
@@ -1079,7 +1598,10 @@ function renderDrumNote(
|
|
|
1079
1598
|
const bright = noise - previousNoise;
|
|
1080
1599
|
previousNoise = noise;
|
|
1081
1600
|
let sample = 0;
|
|
1082
|
-
if (
|
|
1601
|
+
if (metal && metalState) {
|
|
1602
|
+
sample = metalSample(metal, t, bright, metalState, sampleRate);
|
|
1603
|
+
if (kit) sample *= kit.gain;
|
|
1604
|
+
} else if (kit && kitState)
|
|
1083
1605
|
sample = kitDrumSample(
|
|
1084
1606
|
kit,
|
|
1085
1607
|
voice,
|
|
@@ -1088,6 +1610,7 @@ function renderDrumNote(
|
|
|
1088
1610
|
bright,
|
|
1089
1611
|
kitState,
|
|
1090
1612
|
sampleRate,
|
|
1613
|
+
calibrated ? { tomRatio: ratio } : undefined,
|
|
1091
1614
|
);
|
|
1092
1615
|
else if (voice === "kick") {
|
|
1093
1616
|
const frequency = 45 + 105 * Math.exp(-t * 28);
|
|
@@ -1096,7 +1619,7 @@ function renderDrumNote(
|
|
|
1096
1619
|
Math.sin(2 * Math.PI * phase) * Math.exp(-t * 7.5) +
|
|
1097
1620
|
noise * 0.12 * Math.exp(-t * 300);
|
|
1098
1621
|
} else if (voice === "tom") {
|
|
1099
|
-
const frequency = 105 + 95 * Math.exp(-t * 18);
|
|
1622
|
+
const frequency = (105 + 95 * Math.exp(-t * 18)) * ratio;
|
|
1100
1623
|
phase += frequency / sampleRate;
|
|
1101
1624
|
sample = Math.sin(2 * Math.PI * phase) * Math.exp(-t * 9);
|
|
1102
1625
|
} else if (voice === "snare") {
|
|
@@ -1123,10 +1646,10 @@ function renderDrumNote(
|
|
|
1123
1646
|
1,
|
|
1124
1647
|
(index - start) / Math.max(1, sampleRate * 0.001),
|
|
1125
1648
|
);
|
|
1126
|
-
const release =
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1649
|
+
const release =
|
|
1650
|
+
chokeAt !== undefined && index >= chokeAt
|
|
1651
|
+
? Math.max(0, chokeEnd - index) / Math.max(1, chokeEnd - chokeAt)
|
|
1652
|
+
: Math.min(1, (end - index) / Math.max(1, sampleRate * 0.005));
|
|
1130
1653
|
target[index]! +=
|
|
1131
1654
|
sample *
|
|
1132
1655
|
attack *
|