@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/instruments.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
* render takes exactly today's code path.
|
|
8
8
|
*/
|
|
9
9
|
import type { PerformedNote } from "../../core/expression.ts";
|
|
10
|
-
import type { Track } from "../../core/score.ts";
|
|
10
|
+
import type { Track, TrackScore } from "../../core/score.ts";
|
|
11
11
|
import type { TuningTable } from "../../core/tuning.ts";
|
|
12
|
+
import { WIND_ENGINE } from "./winds/engine.ts";
|
|
13
|
+
import { SING_ENGINE } from "./sing/engine.ts";
|
|
12
14
|
import { MODAL_ENGINE } from "./resonators.ts";
|
|
13
15
|
import type { SampleBank } from "./samples.ts";
|
|
14
16
|
import type { RenderContext } from "./wav.ts";
|
|
15
17
|
import { STRING_ENGINE } from "./strings/engine.ts";
|
|
16
18
|
import { GRANULAR_ENGINE } from "./granular.ts";
|
|
17
19
|
import { KEYS_ENGINES } from "./keys/engine.ts";
|
|
20
|
+
import { VOCODER_ENGINE } from "./vocoder/index.ts";
|
|
18
21
|
|
|
19
22
|
/** What an engine renders with: the track's render context plus its tuning. */
|
|
20
23
|
export type EngineContext = RenderContext &
|
|
@@ -24,6 +27,9 @@ export type EngineContext = RenderContext &
|
|
|
24
27
|
tuning?: TuningTable;
|
|
25
28
|
}>;
|
|
26
29
|
|
|
30
|
+
/** Fade at a ring-out cap (live note length, voice cap, loop fold), in seconds. */
|
|
31
|
+
export const RING_OUT_FADE_SECONDS = 0.25;
|
|
32
|
+
|
|
27
33
|
export type InstrumentEngine = Readonly<{
|
|
28
34
|
/** The `Track.instrument` value this engine plays. */
|
|
29
35
|
id: string;
|
|
@@ -52,10 +58,34 @@ export type InstrumentEngine = Readonly<{
|
|
|
52
58
|
* (a piano's damper); absent uses `tailSeconds`.
|
|
53
59
|
*/
|
|
54
60
|
releaseSeconds?(track: Track, pitch: number, hz: number): number;
|
|
61
|
+
/**
|
|
62
|
+
* True when a released live key keeps ringing to the end of its rendered
|
|
63
|
+
* tail (an undamped bar or gong) instead of fading at note-off.
|
|
64
|
+
*/
|
|
65
|
+
ringOut?(track: Track): boolean;
|
|
55
66
|
/** True when the engine writes a separate right channel. */
|
|
56
67
|
stereo(track: Track): boolean;
|
|
57
|
-
/**
|
|
58
|
-
|
|
68
|
+
/**
|
|
69
|
+
* State the engine carries into an arranged window that its pre-roll
|
|
70
|
+
* cannot rebuild (an organ's rotors along a lane), as a string: `history`
|
|
71
|
+
* is the song before the window and `frames` its length in samples.
|
|
72
|
+
* Arranged renders hand it back as `context.seedState[track.id]`.
|
|
73
|
+
*/
|
|
74
|
+
windowSeed?(
|
|
75
|
+
history: TrackScore,
|
|
76
|
+
track: Track,
|
|
77
|
+
frames: number,
|
|
78
|
+
sampleRate: number,
|
|
79
|
+
): string | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Digests of any assets the engine reads, joined to the stem cache key.
|
|
82
|
+
* `score` is the song, for engines that read other tracks' notes.
|
|
83
|
+
*/
|
|
84
|
+
assetDigests?(
|
|
85
|
+
track: Track,
|
|
86
|
+
bank: SampleBank,
|
|
87
|
+
score?: TrackScore,
|
|
88
|
+
): readonly string[];
|
|
59
89
|
}>;
|
|
60
90
|
|
|
61
91
|
const engines = new Map<string, InstrumentEngine>();
|
|
@@ -109,3 +139,6 @@ registerEngine(STRING_ENGINE);
|
|
|
109
139
|
registerEngine(GRANULAR_ENGINE);
|
|
110
140
|
for (const engine of KEYS_ENGINES) registerEngine(engine);
|
|
111
141
|
registerEngine(MODAL_ENGINE);
|
|
142
|
+
registerEngine(WIND_ENGINE);
|
|
143
|
+
registerEngine(SING_ENGINE);
|
|
144
|
+
registerEngine(VOCODER_ENGINE);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibration 1 (q08): per-preset, key-tracked level trims for the keys
|
|
3
|
+
* engine and the harpsichord. The modelled pianos, electric pianos and
|
|
4
|
+
* clavs were levelled one family at a time, so at velocity 0.8 they sat 2
|
|
5
|
+
* to 40 dB under the `piano` synth and fell steeply up the keyboard (the
|
|
6
|
+
* hammer and tangent pulses filter the upper keys' partials hard). Each
|
|
7
|
+
* row is the gain in dB, fitted from renders, that brings one note at
|
|
8
|
+
* velocity 0.8 to the `piano` reference (-18 LUFS momentary maximum), at
|
|
9
|
+
* keys 24, 36, ... 108, capped so the note's sample peak stays at or under
|
|
10
|
+
* -4 dBFS; the top keys' hammer click is all crest, so there the cap wins
|
|
11
|
+
* and they sit a few dB under. Between keys the trim is interpolated in
|
|
12
|
+
* dB, and outside it holds the end value. Trims are linear, so velocity
|
|
13
|
+
* response is kept. Legacy songs (no calibration) never read this table.
|
|
14
|
+
*
|
|
15
|
+
* Pinned: never edit a row; a later revision appends a new table.
|
|
16
|
+
*/
|
|
17
|
+
export const KEYS_TRIM_KEYS = Object.freeze([
|
|
18
|
+
24, 36, 48, 60, 72, 84, 96, 108,
|
|
19
|
+
] as const);
|
|
20
|
+
|
|
21
|
+
/** Highest boost at any key, so the top octave is not pulled out of noise. */
|
|
22
|
+
export const KEYS_TRIM_MAX_DB = 40;
|
|
23
|
+
|
|
24
|
+
export const KEYS_TRIM_DB_1: Readonly<Record<string, readonly number[]>> =
|
|
25
|
+
Object.freeze({
|
|
26
|
+
harpsichord: [4.1, 7.0, 9.9, 13.1, 16.2, 18.6, 18.2, 17.5],
|
|
27
|
+
grand: [-1.4, -1.5, -0.6, 0.8, 3.8, 4.8, 5.5, 8.3],
|
|
28
|
+
ballad: [-4.1, -4.7, -3.4, -2.9, -0.5, 3.1, 5.4, 8.2],
|
|
29
|
+
upright: [1.4, 0.4, 0.5, 2.2, 4.0, 4.2, 4.8, 8.7],
|
|
30
|
+
felt: [3.9, 4.1, 4.5, 5.2, 6.6, 8.9, 10.6, 15.4],
|
|
31
|
+
lofi: [4.1, 4.1, 4.3, 5.7, 7.8, 8.2, 9.2, 15.0],
|
|
32
|
+
honkytonk: [1.2, 2.9, 3.2, 4.1, 4.0, 3.5, 3.6, 6.2],
|
|
33
|
+
prepared: [2.3, -1.5, 1.2, 5.7, 2.0, 6.2, 4.5, 7.6],
|
|
34
|
+
epiano: [6.0, 5.2, 4.8, 5.3, 7.3, 7.6, 6.5, 8.5],
|
|
35
|
+
suitcase: [7.6, 6.3, 5.6, 5.9, 7.7, 7.9, 6.6, 8.5],
|
|
36
|
+
dyno: [3.0, 4.1, 4.5, 5.1, 6.6, 7.5, 6.2, 7.7],
|
|
37
|
+
wurli: [9.6, 6.1, 5.3, 6.0, 7.8, 6.1, 7.6, 11.1],
|
|
38
|
+
clav: [8.1, 8.8, 8.9, 10.4, 11.2, 10.8, 8.5, 2.7],
|
|
39
|
+
funkclav: [7.1, 7.9, 9.0, 9.1, 9.0, 5.1, 7.8, 5.9],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Linear gain for a preset at a key under calibration 1. */
|
|
43
|
+
export function keysTrim(preset: string, key: number): number {
|
|
44
|
+
const row = KEYS_TRIM_DB_1[preset];
|
|
45
|
+
if (!row || row.length !== KEYS_TRIM_KEYS.length) return 1;
|
|
46
|
+
const keys = KEYS_TRIM_KEYS;
|
|
47
|
+
let db: number;
|
|
48
|
+
if (key <= keys[0]) db = row[0]!;
|
|
49
|
+
else if (key >= keys[keys.length - 1]!) db = row[row.length - 1]!;
|
|
50
|
+
else {
|
|
51
|
+
const i = Math.min(keys.length - 2, Math.floor((key - keys[0]) / 12));
|
|
52
|
+
const t = (key - keys[i]!) / 12;
|
|
53
|
+
db = row[i]! + (row[i + 1]! - row[i]!) * t;
|
|
54
|
+
}
|
|
55
|
+
return 10 ** (Math.min(KEYS_TRIM_MAX_DB, db) / 20);
|
|
56
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electric keys (0.6.1 keys-electric lane): the Rhodes-style tine piano
|
|
3
|
+
* (`epiano`), the Wurlitzer-style reed piano (`wurli`) and the clavinet
|
|
4
|
+
* (`clav`), ported from the reviewed prototype
|
|
5
|
+
* (`.plans/dawg-06/proto/keys/keys.ts`, `TineVoice` and `ClavVoice`) with
|
|
6
|
+
* the review's round-2 fixes: the pickups run at twice the sample rate
|
|
7
|
+
* (band-limited both ways, so a hard-driven tine does not alias), the
|
|
8
|
+
* epiano carries its own suitcase stereo vibrato (`vibe`), the wurli its
|
|
9
|
+
* tremolo (`trem`), and clav pickup positions vary per track (seeded).
|
|
10
|
+
*
|
|
11
|
+
* Tine: a clamped-free cantilever, three modes at 1, 6.267 and 17.547 times
|
|
12
|
+
* the fundamental plus the tone bar, read through an electromagnetic pickup
|
|
13
|
+
* whose flux falls off with the tine's distance from the pole piece
|
|
14
|
+
* (1 / (1 + d^2)), differentiated (Faraday). After Shear and Wright, "The
|
|
15
|
+
* Electromagnetically Sustained Rhodes Piano" (NIME 2011) and Gabrielli et
|
|
16
|
+
* al., "Physical modeling of the Rhodes piano" (DAFx-10). Reed: the
|
|
17
|
+
* electrostatic pickup of the Wurlitzer 200 reads capacitance, 1 / (1 - k x),
|
|
18
|
+
* not a derivative; a peak at 1.1 kHz gives the nasal case. Clav: modal
|
|
19
|
+
* string struck near its end by a tangent, two pickup combs sin(n pi p) at
|
|
20
|
+
* neck and bridge, the mute slider damping upper partials and a small
|
|
21
|
+
* negative re-pluck when the string leaves the anvil (Gabrielli, Välimäki,
|
|
22
|
+
* Penttinen, Squartini and Bilbao, EURASIP JASP 2013:103).
|
|
23
|
+
*/
|
|
24
|
+
import { DcBlock, OnePole } from "../dsp/filters.ts";
|
|
25
|
+
import { Oversample2x } from "../dsp/oversample.ts";
|
|
26
|
+
import { unit } from "../dsp/rng.ts";
|
|
27
|
+
import {
|
|
28
|
+
Biquad2,
|
|
29
|
+
Burst,
|
|
30
|
+
clamp,
|
|
31
|
+
hammerPulse,
|
|
32
|
+
lerp,
|
|
33
|
+
LN1000,
|
|
34
|
+
ModeBank,
|
|
35
|
+
panGains,
|
|
36
|
+
pulseMag,
|
|
37
|
+
TAU,
|
|
38
|
+
} from "./dsp.ts";
|
|
39
|
+
|
|
40
|
+
/** Resolved electric voice parameters (core/keys.ts `KEYS_PARAMS`). */
|
|
41
|
+
export type ElectricParams = Readonly<{
|
|
42
|
+
kind: "epiano" | "wurli" | "clav";
|
|
43
|
+
hardness: number;
|
|
44
|
+
touch: number;
|
|
45
|
+
decay: number;
|
|
46
|
+
release: number;
|
|
47
|
+
width: number;
|
|
48
|
+
bark: number;
|
|
49
|
+
bell: number;
|
|
50
|
+
/** Output low-pass in Hz; 0 is off. */
|
|
51
|
+
tone: number;
|
|
52
|
+
pickup: "neck" | "bridge" | "both" | "out";
|
|
53
|
+
mute: number;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
export type ElectricNoteOn = Readonly<{
|
|
57
|
+
pitch: number;
|
|
58
|
+
hz: number;
|
|
59
|
+
velocity: number;
|
|
60
|
+
/** Track seed hash (clav pickup positions are per track). */
|
|
61
|
+
trackSeed: number;
|
|
62
|
+
noteSeed: string;
|
|
63
|
+
/** Song calibration (q08); 1+ keeps the tine hammer under the period. */
|
|
64
|
+
calibration?: number;
|
|
65
|
+
}>;
|
|
66
|
+
|
|
67
|
+
/** Clamped-free cantilever mode ratios. */
|
|
68
|
+
const BEAM = [1, 6.267, 17.547] as const;
|
|
69
|
+
/** Rest position of the tine relative to the pickup pole. */
|
|
70
|
+
const X0 = 0.4;
|
|
71
|
+
|
|
72
|
+
/** The voice interface the keys engine drives (shared with `PianoVoice`). */
|
|
73
|
+
export type KeysVoice = {
|
|
74
|
+
readonly gl: number;
|
|
75
|
+
readonly gr: number;
|
|
76
|
+
readonly released: boolean;
|
|
77
|
+
noteOff(): void;
|
|
78
|
+
setCents(cents: number): void;
|
|
79
|
+
damp(tau: number): void;
|
|
80
|
+
process(out: Float64Array, offset: number, count: number): boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Seconds a released tine or reed takes to fall 60 dB. */
|
|
84
|
+
export function tineReleaseT60(pitch: number, release: number): number {
|
|
85
|
+
return release * (0.09 + 0.25 * clamp((60 - pitch) / 32, 0, 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Seconds a released clav string takes to fall 60 dB (the yarn damper). */
|
|
89
|
+
export function clavReleaseT60(release: number): number {
|
|
90
|
+
return release * 0.06;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Tine (epiano) and reed (wurli) voice. */
|
|
94
|
+
export class TineVoice implements KeysVoice {
|
|
95
|
+
private readonly bank: ModeBank;
|
|
96
|
+
private readonly pulse: Float64Array;
|
|
97
|
+
private pos = 0;
|
|
98
|
+
private readonly over = new Oversample2x();
|
|
99
|
+
private readonly dc: DcBlock;
|
|
100
|
+
private readonly lp: OnePole | undefined;
|
|
101
|
+
private readonly peak: Biquad2 | undefined;
|
|
102
|
+
private readonly norm: number;
|
|
103
|
+
readonly gl: number;
|
|
104
|
+
readonly gr: number;
|
|
105
|
+
private tmp = new Float64Array(0);
|
|
106
|
+
private readonly bursts: Burst[] = [];
|
|
107
|
+
private readonly rhodes: boolean;
|
|
108
|
+
/** Pickup state at the doubled rate. */
|
|
109
|
+
private prevX = 0;
|
|
110
|
+
private prevPhi = 0;
|
|
111
|
+
private releasedFlag = false;
|
|
112
|
+
private age = 0;
|
|
113
|
+
private readonly shape: (x: number) => number;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly note: ElectricNoteOn,
|
|
117
|
+
private readonly p: ElectricParams,
|
|
118
|
+
private readonly sr: number,
|
|
119
|
+
) {
|
|
120
|
+
const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
|
|
121
|
+
const key = note.pitch;
|
|
122
|
+
const f = note.hz;
|
|
123
|
+
const rhodes = p.kind === "epiano";
|
|
124
|
+
this.rhodes = rhodes;
|
|
125
|
+
const T = rhodes
|
|
126
|
+
? p.decay * 9 * 2 ** (-(key - 36) / 26)
|
|
127
|
+
: p.decay * 4.5 * 2 ** (-(key - 40) / 26);
|
|
128
|
+
const bell =
|
|
129
|
+
p.bell *
|
|
130
|
+
(rhodes ? 0.04 + 0.22 * v * v : 0.02 + 0.08 * v * v) *
|
|
131
|
+
2 ** (-(key - 60) / 30);
|
|
132
|
+
const drive = lerp(0.35, rhodes ? 1.6 : 1.5, p.bark);
|
|
133
|
+
const A = (0.15 + 0.85 * v) * drive;
|
|
134
|
+
// Hardness scales the hammer contact (0.5 is the prototype's).
|
|
135
|
+
const legacy = lerp(2.2, 0.9, v) * (1.4 - 0.8 * p.hardness);
|
|
136
|
+
// Calibration 1: the contact stays under 0.6 of the tine's period, so
|
|
137
|
+
// the pulse's first null never lands under the fundamental (the top
|
|
138
|
+
// octave fell 35 dB).
|
|
139
|
+
const contact =
|
|
140
|
+
(note.calibration ?? 0) >= 1 ? Math.min(legacy, 600 / f) : legacy;
|
|
141
|
+
this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.5);
|
|
142
|
+
this.bank = new ModeBank(4);
|
|
143
|
+
this.bank.add(f, T, A * 0.85, 1, sr);
|
|
144
|
+
// Tone bar: a slightly sharp, longer partner (the Rhodes tuning fork).
|
|
145
|
+
if (rhodes) this.bank.add(f * 2 ** (0.4 / 1200), T * 1.3, A * 0.15, 1, sr);
|
|
146
|
+
this.bank.add(f * BEAM[1], 0.35 * 2 ** (-(key - 60) / 24), A * bell, 1, sr);
|
|
147
|
+
if (rhodes) this.bank.add(f * BEAM[2], 0.06, A * bell * 0.3 * v, 1, sr);
|
|
148
|
+
// Normalise by the pickup's small-signal slope at the doubled rate. The
|
|
149
|
+
// tine's 0.36 puts a velocity-0.8 C-major triad near -6.5 dBFS, the
|
|
150
|
+
// house level (the reed's 0.32 already sits there).
|
|
151
|
+
const w = (TAU * f) / (2 * sr);
|
|
152
|
+
this.norm =
|
|
153
|
+
(rhodes ? 0.36 : 0.32) /
|
|
154
|
+
((rhodes ? (2 * X0) / (1 + X0 * X0) ** 2 : 0.5) * w);
|
|
155
|
+
this.prevPhi = this.pickup(0);
|
|
156
|
+
this.dc = new DcBlock(sr, 8);
|
|
157
|
+
this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
|
|
158
|
+
this.peak = rhodes
|
|
159
|
+
? undefined
|
|
160
|
+
: new Biquad2().set("peak", 1100, 0.9, 4, sr);
|
|
161
|
+
[this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
|
|
162
|
+
this.bursts.push(
|
|
163
|
+
new Burst(
|
|
164
|
+
`${note.noteSeed}:tine`,
|
|
165
|
+
rhodes ? 1800 : 1200,
|
|
166
|
+
1.2,
|
|
167
|
+
0.002,
|
|
168
|
+
0.01 * v * v,
|
|
169
|
+
sr,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
// The pickup at 2x: linear interpolation of the modal output between
|
|
173
|
+
// base-rate samples is done by the half-band interpolator; this runs on
|
|
174
|
+
// each doubled-rate sample in order, so it may keep state.
|
|
175
|
+
this.shape = (x: number) => {
|
|
176
|
+
this.prevX = x;
|
|
177
|
+
const phi = this.pickup(x);
|
|
178
|
+
const dy = rhodes ? -(phi - this.prevPhi) : phi - this.prevPhi;
|
|
179
|
+
this.prevPhi = phi;
|
|
180
|
+
const y = dy * this.norm;
|
|
181
|
+
return rhodes ? y : Math.tanh(1.3 * y) / 1.3;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private pickup(x: number): number {
|
|
186
|
+
if (this.rhodes) {
|
|
187
|
+
const d = x - X0;
|
|
188
|
+
return 1 / (1 + d * d);
|
|
189
|
+
}
|
|
190
|
+
return 1 / (1 - 0.5 * Math.min(x, 1.6));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get released(): boolean {
|
|
194
|
+
return this.releasedFlag;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
noteOff(): void {
|
|
198
|
+
if (this.releasedFlag) return;
|
|
199
|
+
this.releasedFlag = true;
|
|
200
|
+
const t = tineReleaseT60(this.note.pitch, this.p.release);
|
|
201
|
+
this.bank.damp(() => t, this.sr);
|
|
202
|
+
this.bursts.push(
|
|
203
|
+
new Burst(
|
|
204
|
+
`${this.note.noteSeed}:damper`,
|
|
205
|
+
180,
|
|
206
|
+
1,
|
|
207
|
+
0.02,
|
|
208
|
+
0.006 * this.note.velocity,
|
|
209
|
+
this.sr,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setCents(cents: number): void {
|
|
215
|
+
this.bank.retune(2 ** (cents / 1200));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
damp(tau: number): void {
|
|
219
|
+
this.bank.damp(() => LN1000 * tau, this.sr);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
223
|
+
if (this.tmp.length < count) this.tmp = new Float64Array(count);
|
|
224
|
+
const tmp = this.tmp;
|
|
225
|
+
tmp.fill(0, 0, count);
|
|
226
|
+
let done = 0;
|
|
227
|
+
if (this.pos < this.pulse.length) {
|
|
228
|
+
done = Math.min(count, this.pulse.length - this.pos);
|
|
229
|
+
this.bank.run(tmp, 0, done, this.pulse, this.pos);
|
|
230
|
+
this.pos += done;
|
|
231
|
+
}
|
|
232
|
+
if (done < count) this.bank.run(tmp, done, count - done, null, 0);
|
|
233
|
+
for (let i = 0; i < count; i += 1) {
|
|
234
|
+
let y = this.over.process(tmp[i]!, this.shape);
|
|
235
|
+
y = this.dc.process(y);
|
|
236
|
+
if (this.peak) y = this.peak.process(y);
|
|
237
|
+
if (this.lp) y = this.lp.process(y);
|
|
238
|
+
for (const b of this.bursts) if (!b.done) y += b.next();
|
|
239
|
+
out[offset + i]! += y;
|
|
240
|
+
}
|
|
241
|
+
this.age += count;
|
|
242
|
+
if (this.age % 256 < count) this.bank.cull(1e-6);
|
|
243
|
+
// The oversampler and DC blocker hold a short tail after the modes go.
|
|
244
|
+
return (
|
|
245
|
+
this.bank.n > 0 ||
|
|
246
|
+
this.bursts.some((b) => !b.done) ||
|
|
247
|
+
Math.abs(this.prevX) > 1e-9
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Clav pickup positions (fractions of the string from the bridge end), with
|
|
254
|
+
* a seeded per-track spread of +/-6%: two clavinets never sound identical.
|
|
255
|
+
*/
|
|
256
|
+
export function clavPositions(trackSeed: number): {
|
|
257
|
+
neck: number;
|
|
258
|
+
bridge: number;
|
|
259
|
+
tangent: number;
|
|
260
|
+
} {
|
|
261
|
+
const spread = (slot: number) => 1 + 0.12 * (unit(trackSeed, 0, slot) - 0.5);
|
|
262
|
+
return {
|
|
263
|
+
neck: 0.19 * spread(0),
|
|
264
|
+
bridge: 0.065 * spread(1),
|
|
265
|
+
tangent: 0.04 * spread(2),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export class ClavVoice implements KeysVoice {
|
|
270
|
+
private readonly bank: ModeBank;
|
|
271
|
+
private pulse: Float64Array;
|
|
272
|
+
private pos = 0;
|
|
273
|
+
private readonly lp: OnePole | undefined;
|
|
274
|
+
private readonly hp: Biquad2;
|
|
275
|
+
readonly gl: number;
|
|
276
|
+
readonly gr: number;
|
|
277
|
+
private tmp = new Float64Array(0);
|
|
278
|
+
private releasedFlag = false;
|
|
279
|
+
private age = 0;
|
|
280
|
+
|
|
281
|
+
constructor(
|
|
282
|
+
note: ElectricNoteOn,
|
|
283
|
+
private readonly p: ElectricParams,
|
|
284
|
+
private readonly sr: number,
|
|
285
|
+
) {
|
|
286
|
+
const v = clamp(lerp(0.8, note.velocity, p.touch), 0.01, 1);
|
|
287
|
+
const key = note.pitch;
|
|
288
|
+
const B = 1.5e-4 * 2 ** ((key - 48) / 16);
|
|
289
|
+
// Partial 1 sits exactly at the tuned pitch.
|
|
290
|
+
const f0 = note.hz / Math.sqrt(1 + B);
|
|
291
|
+
const T = p.decay * 5 * 2 ** (-(key - 40) / 24);
|
|
292
|
+
const { neck, bridge, tangent } = clavPositions(note.trackSeed);
|
|
293
|
+
const contact = lerp(0.6, 0.25, v) * (1.4 - 0.8 * p.hardness);
|
|
294
|
+
this.pulse = hammerPulse(Math.max(2, Math.round(contact * 1e-3 * sr)), 1.2);
|
|
295
|
+
const bank = new ModeBank(80);
|
|
296
|
+
const modes: [number, number, number][] = [];
|
|
297
|
+
let energy = 0;
|
|
298
|
+
for (let n = 1; n <= 80; n += 1) {
|
|
299
|
+
const fn = n * f0 * Math.sqrt(1 + B * n * n);
|
|
300
|
+
if (fn >= 0.45 * sr) break;
|
|
301
|
+
const a = Math.sin(n * Math.PI * neck);
|
|
302
|
+
const b = Math.sin(n * Math.PI * bridge);
|
|
303
|
+
const w =
|
|
304
|
+
p.pickup === "neck"
|
|
305
|
+
? a
|
|
306
|
+
: p.pickup === "bridge"
|
|
307
|
+
? b * 2
|
|
308
|
+
: p.pickup === "out"
|
|
309
|
+
? a - b
|
|
310
|
+
: (a + b) * 0.7;
|
|
311
|
+
const u = Math.sin(n * Math.PI * tangent) * w;
|
|
312
|
+
const t60 =
|
|
313
|
+
(T / (1 + (fn / 3000) ** 2)) *
|
|
314
|
+
(1 - 0.85 * p.mute * Math.min(1, (n - 1) / 4));
|
|
315
|
+
modes.push([fn, t60, u]);
|
|
316
|
+
// Level is set on what the pickup hears through the tangent's
|
|
317
|
+
// contact spectrum, so every pickup switch sits within about 1.5 dB
|
|
318
|
+
// of the others (a C-major triad at velocity 0.8 near -6 dBFS).
|
|
319
|
+
energy += (u * pulseMag(this.pulse, (2 * Math.PI * fn) / sr)) ** 2;
|
|
320
|
+
}
|
|
321
|
+
// Out of phase leaves a peakier, thinner sum: +1.6 dB brings its peak
|
|
322
|
+
// level to the other pickups' (measured at C3, velocity 0.8).
|
|
323
|
+
const trim = p.pickup === "out" ? 1.2 : 1;
|
|
324
|
+
const G = (0.146 * trim * v ** 1.2) / Math.sqrt(Math.max(1e-12, energy));
|
|
325
|
+
for (const [fn, t60, u] of modes)
|
|
326
|
+
bank.add(fn, Math.max(0.02, t60), u * G, 1, sr);
|
|
327
|
+
this.bank = bank;
|
|
328
|
+
this.lp = p.tone > 0 ? new OnePole(p.tone, sr) : undefined;
|
|
329
|
+
this.hp = new Biquad2().set("hpf", 70, 0.7, 0, sr);
|
|
330
|
+
[this.gl, this.gr] = panGains(p.width * clamp((key - 60) / 30, -1, 1));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
get released(): boolean {
|
|
334
|
+
return this.releasedFlag;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
noteOff(): void {
|
|
338
|
+
if (this.releasedFlag) return;
|
|
339
|
+
this.releasedFlag = true;
|
|
340
|
+
// The string leaves the anvil: a small negative pluck, then the yarn damps it.
|
|
341
|
+
const plunk = hammerPulse(Math.max(2, Math.round(0.3e-3 * this.sr)), 1);
|
|
342
|
+
for (let i = 0; i < plunk.length; i += 1) plunk[i]! *= -0.35;
|
|
343
|
+
this.pulse = plunk;
|
|
344
|
+
this.pos = 0;
|
|
345
|
+
const t = clavReleaseT60(this.p.release);
|
|
346
|
+
this.bank.damp(() => t, this.sr);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
setCents(cents: number): void {
|
|
350
|
+
this.bank.retune(2 ** (cents / 1200));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
damp(tau: number): void {
|
|
354
|
+
this.bank.damp(() => LN1000 * tau, this.sr);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
process(out: Float64Array, offset: number, count: number): boolean {
|
|
358
|
+
if (this.tmp.length < count) this.tmp = new Float64Array(count);
|
|
359
|
+
const tmp = this.tmp;
|
|
360
|
+
tmp.fill(0, 0, count);
|
|
361
|
+
let done = 0;
|
|
362
|
+
if (this.pos < this.pulse.length) {
|
|
363
|
+
done = Math.min(count, this.pulse.length - this.pos);
|
|
364
|
+
this.bank.run(tmp, 0, done, this.pulse, this.pos);
|
|
365
|
+
this.pos += done;
|
|
366
|
+
}
|
|
367
|
+
if (done < count) this.bank.run(tmp, done, count - done, null, 0);
|
|
368
|
+
for (let i = 0; i < count; i += 1) {
|
|
369
|
+
let y = this.hp.process(tmp[i]!);
|
|
370
|
+
if (this.lp) y = this.lp.process(y);
|
|
371
|
+
out[offset + i]! += y;
|
|
372
|
+
}
|
|
373
|
+
this.age += count;
|
|
374
|
+
if (this.age % 256 < count) this.bank.cull(1e-6);
|
|
375
|
+
return this.bank.n > 0;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A voice for an electric family. */
|
|
380
|
+
export function electricVoice(
|
|
381
|
+
note: ElectricNoteOn,
|
|
382
|
+
p: ElectricParams,
|
|
383
|
+
sr: number,
|
|
384
|
+
): KeysVoice {
|
|
385
|
+
return p.kind === "clav"
|
|
386
|
+
? new ClavVoice(note, p, sr)
|
|
387
|
+
: new TineVoice(note, p, sr);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The electric post stage, run on the summed track: the epiano's suitcase
|
|
392
|
+
* stereo vibrato (antiphase left/right gain, `vibe` depth at `vibehz`) and
|
|
393
|
+
* the wurli's tremolo (`trem` depth at 5.6 Hz, both channels). The phase is
|
|
394
|
+
* song time (`startSample` of the buffer at the song's sample 0), so a loop
|
|
395
|
+
* and a full render agree. `depthAt(sample)` reads the lane.
|
|
396
|
+
*/
|
|
397
|
+
export function electricPost(
|
|
398
|
+
left: Float64Array,
|
|
399
|
+
right: Float64Array,
|
|
400
|
+
kind: ElectricParams["kind"],
|
|
401
|
+
rateHz: number,
|
|
402
|
+
depthAt: (sample: number) => number,
|
|
403
|
+
sr: number,
|
|
404
|
+
startSample = 0,
|
|
405
|
+
): void {
|
|
406
|
+
if (kind === "clav") return;
|
|
407
|
+
const block = 32;
|
|
408
|
+
for (let i = 0; i < left.length; i += block) {
|
|
409
|
+
const depth = clamp(depthAt(i), 0, 1);
|
|
410
|
+
const end = Math.min(left.length, i + block);
|
|
411
|
+
if (depth <= 0) continue;
|
|
412
|
+
for (let s = i; s < end; s += 1) {
|
|
413
|
+
const lfo = Math.sin((TAU * rateHz * (s + startSample)) / sr);
|
|
414
|
+
if (kind === "epiano") {
|
|
415
|
+
// Constant-power autopan between the two speakers of the suitcase.
|
|
416
|
+
const pan = depth * lfo;
|
|
417
|
+
const a = ((pan + 1) * Math.PI) / 4;
|
|
418
|
+
left[s]! *= Math.SQRT2 * Math.cos(a);
|
|
419
|
+
right[s]! *= Math.SQRT2 * Math.sin(a);
|
|
420
|
+
} else {
|
|
421
|
+
const gain = 1 - 0.5 * depth * (1 - lfo);
|
|
422
|
+
left[s]! *= gain;
|
|
423
|
+
right[s]! *= gain;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|