@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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl-k menu rows for modal percussion (0.6): the "Mallets and bells"
|
|
3
|
+
* group under Sound › browse sounds, and the MODAL_PARAMS rows under
|
|
4
|
+
* Sound › Parameters on a modal track. Every row runs a `modal …` command.
|
|
5
|
+
*/
|
|
6
|
+
import type { EnumParam, NumberParam } from "../../core/params.ts";
|
|
7
|
+
import {
|
|
8
|
+
GAMELAN_PRESETS,
|
|
9
|
+
MODAL_INSTRUMENT,
|
|
10
|
+
MODAL_MALLET_NAMES,
|
|
11
|
+
MODAL_PARAMS,
|
|
12
|
+
MODAL_PRESETS,
|
|
13
|
+
MODAL_PRESET_NAMES,
|
|
14
|
+
MODAL_SIMPLE,
|
|
15
|
+
modalSettings,
|
|
16
|
+
type ModalPresetName,
|
|
17
|
+
} from "../../core/resonators.ts";
|
|
18
|
+
import type { Track } from "../../core/score.ts";
|
|
19
|
+
import { num, specStep, type MenuNode } from "./menu.ts";
|
|
20
|
+
|
|
21
|
+
/** Sound › browse sounds › Mallets and bells: one row per preset. */
|
|
22
|
+
export function malletsMenu(): MenuNode {
|
|
23
|
+
return {
|
|
24
|
+
kind: "menu",
|
|
25
|
+
id: "mallets",
|
|
26
|
+
label: "Mallets and bells",
|
|
27
|
+
detail: `${MODAL_PRESET_NAMES.length} modal presets · marimba, vibes, gong …`,
|
|
28
|
+
help: "struck bars, bells and bowls on the modal resonator engine",
|
|
29
|
+
build: () => [
|
|
30
|
+
// The core presets keep their rows (gong too); the gamelan bronzes
|
|
31
|
+
// added in 0.6.1 sit in their own sub-group.
|
|
32
|
+
...MODAL_PRESET_NAMES.filter(
|
|
33
|
+
(name) => name === "gong" || !GAMELAN_PRESETS.includes(name),
|
|
34
|
+
).map(presetRow),
|
|
35
|
+
gamelanMenu(),
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function presetRow(name: ModalPresetName): MenuNode {
|
|
41
|
+
return {
|
|
42
|
+
kind: "action",
|
|
43
|
+
label: `${name} ${MODAL_PRESETS[name].doc}`,
|
|
44
|
+
command: `modal ${name}`,
|
|
45
|
+
help: MODAL_PRESETS[name].styles,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mallets and bells › Gamelan (f061-gamelan-winds): the Javanese and
|
|
51
|
+
* Balinese bronzes. `modal gamelan` points here.
|
|
52
|
+
*/
|
|
53
|
+
export function gamelanMenu(): MenuNode {
|
|
54
|
+
return {
|
|
55
|
+
kind: "menu",
|
|
56
|
+
id: "gamelan",
|
|
57
|
+
label: "Gamelan",
|
|
58
|
+
detail: `${GAMELAN_PRESETS.length} bronzes · saron, gangsa (ombak), gong …`,
|
|
59
|
+
help: "Javanese and Balinese gamelan; pair two gangsa tracks with modal pair, tune with tuning slendro or pelog",
|
|
60
|
+
build: () => GAMELAN_PRESETS.map(presetRow),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function numberRow(
|
|
65
|
+
name: string,
|
|
66
|
+
spec: NumberParam,
|
|
67
|
+
value: number,
|
|
68
|
+
fallback: number,
|
|
69
|
+
): MenuNode {
|
|
70
|
+
return {
|
|
71
|
+
kind: "number",
|
|
72
|
+
label: name,
|
|
73
|
+
value,
|
|
74
|
+
min: spec.min,
|
|
75
|
+
max: spec.max,
|
|
76
|
+
step: specStep(spec),
|
|
77
|
+
format: (v) => (spec.unit ? `${num(v)} ${spec.unit}` : num(v)),
|
|
78
|
+
command: (v) => `modal ${name} ${num(v)}`,
|
|
79
|
+
reset: `modal ${name} off`,
|
|
80
|
+
help: `${spec.doc} · preset ${num(fallback)} · x resets`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Sound › Parameters rows on a modal track. */
|
|
85
|
+
export function modalParameterNodes(track: Track): MenuNode[] {
|
|
86
|
+
if (track.instrument !== MODAL_INSTRUMENT) return [];
|
|
87
|
+
const modal = track.modal;
|
|
88
|
+
const preset = MODAL_PRESETS[modal?.preset ?? "marimba"].settings;
|
|
89
|
+
const resolved = modalSettings(modal) as Readonly<
|
|
90
|
+
Record<string, number | string>
|
|
91
|
+
>;
|
|
92
|
+
const row = (name: string): MenuNode | undefined => {
|
|
93
|
+
const spec = MODAL_PARAMS[name]!;
|
|
94
|
+
if (name === "mallet")
|
|
95
|
+
return {
|
|
96
|
+
kind: "choice",
|
|
97
|
+
label: "mallet",
|
|
98
|
+
value: modal?.mallet ?? "preset",
|
|
99
|
+
options: ["preset", ...MODAL_MALLET_NAMES],
|
|
100
|
+
command: (option) =>
|
|
101
|
+
option === "preset" ? "modal mallet off" : `modal mallet ${option}`,
|
|
102
|
+
help: (spec as EnumParam).doc,
|
|
103
|
+
};
|
|
104
|
+
if (spec.kind !== "number") return undefined;
|
|
105
|
+
return numberRow(
|
|
106
|
+
name,
|
|
107
|
+
spec,
|
|
108
|
+
Number(resolved[name]),
|
|
109
|
+
Number(preset[name as keyof typeof preset]),
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
const nodes: MenuNode[] = [
|
|
113
|
+
{
|
|
114
|
+
kind: "choice",
|
|
115
|
+
label: "preset",
|
|
116
|
+
value: modal?.preset ?? "marimba",
|
|
117
|
+
options: MODAL_PRESET_NAMES,
|
|
118
|
+
command: (option) => `modal preset ${option}`,
|
|
119
|
+
help: "the instrument: mode table, mallet and ring",
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
for (const name of MODAL_SIMPLE) {
|
|
123
|
+
const node = row(name);
|
|
124
|
+
if (node) nodes.push(node);
|
|
125
|
+
}
|
|
126
|
+
const rest = Object.keys(MODAL_PARAMS).filter(
|
|
127
|
+
(name) => !(MODAL_SIMPLE as readonly string[]).includes(name),
|
|
128
|
+
);
|
|
129
|
+
nodes.push({
|
|
130
|
+
kind: "menu",
|
|
131
|
+
id: "modal:advanced",
|
|
132
|
+
label: "advanced",
|
|
133
|
+
detail: `motor, ombak, buzz, click, strike glide, gain`,
|
|
134
|
+
help: "every modal parameter",
|
|
135
|
+
build: () => rest.flatMap((name) => row(name) ?? []),
|
|
136
|
+
});
|
|
137
|
+
if (modal && Object.keys(modal).some((key) => key !== "preset"))
|
|
138
|
+
nodes.push({
|
|
139
|
+
kind: "action",
|
|
140
|
+
label: "reset to preset",
|
|
141
|
+
command: "modal reset",
|
|
142
|
+
help: "clear overrides, keep the preset",
|
|
143
|
+
});
|
|
144
|
+
return nodes;
|
|
145
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
VELOCITY_CURVES,
|
|
13
13
|
} from "../../core/expression.ts";
|
|
14
14
|
import type { Note, Track } from "../../core/score.ts";
|
|
15
|
+
import { isPianoFamily } from "../../core/keys.ts";
|
|
15
16
|
import {
|
|
16
17
|
BEND_SHAPES,
|
|
17
18
|
DEFAULT_HUMANIZE,
|
|
@@ -50,12 +51,50 @@ export function performanceDetail(track: Track | undefined): string {
|
|
|
50
51
|
const parts = [
|
|
51
52
|
track.glide ? `glide ${track.glide.mode}` : "",
|
|
52
53
|
track.pedal?.length ? "pedal" : "",
|
|
54
|
+
track.softPedal?.length ? "soft" : "",
|
|
55
|
+
track.sostenuto?.length ? "sost" : "",
|
|
53
56
|
track.velocityCurve ? `vel ${track.velocityCurve.curve}` : "",
|
|
54
57
|
track.humanize ? "humanized" : "",
|
|
55
58
|
].filter(Boolean);
|
|
56
59
|
return parts.length ? parts.join(" · ") : "as written";
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/** Soft pedal and Sostenuto rows: off, the current lane, or the whole loop. */
|
|
63
|
+
function pianoPedalNodes(track: Track): MenuNode[] {
|
|
64
|
+
const rows = [
|
|
65
|
+
{
|
|
66
|
+
lane: "soft",
|
|
67
|
+
label: "soft pedal",
|
|
68
|
+
events: track.softPedal,
|
|
69
|
+
help: "una corda: the hammers shift to strike fewer strings, softer and darker; read at each note's onset",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
lane: "sost",
|
|
73
|
+
label: "sostenuto",
|
|
74
|
+
events: track.sostenuto,
|
|
75
|
+
help: "holds only the keys already down when it presses (the middle pedal); later notes damp normally",
|
|
76
|
+
},
|
|
77
|
+
] as const;
|
|
78
|
+
return rows
|
|
79
|
+
.filter((row) => isPianoFamily(track.instrument) || row.events?.length)
|
|
80
|
+
.map((row): MenuNode => {
|
|
81
|
+
const value = row.events?.length ? `${row.events.length} events` : "off";
|
|
82
|
+
return {
|
|
83
|
+
kind: "choice",
|
|
84
|
+
label: row.label,
|
|
85
|
+
value,
|
|
86
|
+
options: ["off", ...(value === "off" ? [] : [value]), "bars"],
|
|
87
|
+
command: (option) =>
|
|
88
|
+
option === "bars"
|
|
89
|
+
? `pedal ${row.lane} bars`
|
|
90
|
+
: option === "off"
|
|
91
|
+
? `pedal ${row.lane} off`
|
|
92
|
+
: `pedal ${row.lane}`,
|
|
93
|
+
help: row.help,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
export function performanceNodes(context: MenuContext): MenuNode[] {
|
|
60
99
|
const track = context.score.tracks.find(
|
|
61
100
|
(item) => item.id === context.trackId,
|
|
@@ -148,6 +187,9 @@ export function performanceNodes(context: MenuContext): MenuNode[] {
|
|
|
148
187
|
: "pedal",
|
|
149
188
|
help: "bars replaces the lane with a re-pedal on each downbeat; in play mode press Tab (latch) or hold Shift while recording",
|
|
150
189
|
},
|
|
190
|
+
// keys-electric (0.6.1): the piano's other two pedals, on modelled piano
|
|
191
|
+
// tracks (or wherever a lane is already set).
|
|
192
|
+
...pianoPedalNodes(track),
|
|
151
193
|
{
|
|
152
194
|
kind: "choice",
|
|
153
195
|
label: "velocity curve",
|
package/src/tui/play-chords.ts
CHANGED
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
EXTENSIONS,
|
|
36
36
|
MAX_VOICING_STEP,
|
|
37
37
|
PERFORM_MODES,
|
|
38
|
+
DEFAULT_STROKE_SPEED,
|
|
39
|
+
STROKE_PATTERN_NAMES,
|
|
40
|
+
strokeGrid,
|
|
41
|
+
type PerformOptions,
|
|
38
42
|
PROGRESSION_PRESETS,
|
|
39
43
|
PROGRESSION_STYLES,
|
|
40
44
|
SPREADS,
|
|
@@ -100,6 +104,10 @@ export type ChordSettings = {
|
|
|
100
104
|
/** `none` or a PROGRESSION_PRESETS name; drives the "next" suggestion. */
|
|
101
105
|
preset: string;
|
|
102
106
|
style: ProgressionStyle;
|
|
107
|
+
/** Guitar perform mode: a STROKE_PATTERNS name or a D U d u x - . grid. */
|
|
108
|
+
strokes: string;
|
|
109
|
+
/** Guitar perform mode: ms a full six-string down stroke takes. */
|
|
110
|
+
speed: number;
|
|
103
111
|
};
|
|
104
112
|
|
|
105
113
|
export function defaultChordSettings(): ChordSettings {
|
|
@@ -116,9 +124,45 @@ export function defaultChordSettings(): ChordSettings {
|
|
|
116
124
|
sevenths: false,
|
|
117
125
|
preset: "none",
|
|
118
126
|
style: "pop",
|
|
127
|
+
strokes: "down",
|
|
128
|
+
speed: DEFAULT_STROKE_SPEED * 1000,
|
|
119
129
|
};
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
/** Play mode's `[` / `]` strum speed step in ms. */
|
|
133
|
+
export const STROKE_SPEED_STEP = 5;
|
|
134
|
+
|
|
135
|
+
/** Strum speed bounds in ms (0 = every string at once). */
|
|
136
|
+
export const STROKE_SPEED_MS = Object.freeze({ min: 0, max: 200 });
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A strum speed from `/chords speed` text: milliseconds (`22`, `22ms`) or
|
|
140
|
+
* beats (`1/32b`, `0.05b`) at `bpm`. Undefined when not a speed.
|
|
141
|
+
*/
|
|
142
|
+
export function parseStrokeSpeed(
|
|
143
|
+
text: string | undefined,
|
|
144
|
+
bpm: number,
|
|
145
|
+
): number | undefined {
|
|
146
|
+
const raw = (text ?? "").trim().toLowerCase();
|
|
147
|
+
const beats = /^(\d+(?:\.\d+)?)(?:\/(\d+))?\s*(?:b|beats?)$/.exec(raw);
|
|
148
|
+
let ms: number;
|
|
149
|
+
if (beats) {
|
|
150
|
+
const value = Number(beats[1]) / (beats[2] ? Number(beats[2]) : 1);
|
|
151
|
+
ms = (value * 60_000) / bpm;
|
|
152
|
+
} else {
|
|
153
|
+
const plain = /^(\d+(?:\.\d+)?)\s*(?:ms)?$/.exec(raw);
|
|
154
|
+
if (!plain) return undefined;
|
|
155
|
+
ms = Number(plain[1]);
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
!Number.isFinite(ms) ||
|
|
159
|
+
ms < STROKE_SPEED_MS.min ||
|
|
160
|
+
ms > STROKE_SPEED_MS.max
|
|
161
|
+
)
|
|
162
|
+
return undefined;
|
|
163
|
+
return Math.round(ms * 10) / 10;
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
/**
|
|
123
167
|
* Tracks where auto chords are the default: anything pitched that is not a
|
|
124
168
|
* bass, a drum kit or a one-shot sampler.
|
|
@@ -236,6 +280,16 @@ export class ChordPad {
|
|
|
236
280
|
s.perform = PERFORM_MODES[(index + 1) % PERFORM_MODES.length]!;
|
|
237
281
|
return { type: "status", status: `perform ${s.perform}` };
|
|
238
282
|
}
|
|
283
|
+
if ((value === "[" || value === "]") && s.perform === "guitar") {
|
|
284
|
+
// Strum speed: slower ([) or faster (]) in 5 ms steps.
|
|
285
|
+
const wanted =
|
|
286
|
+
s.speed + (value === "[" ? STROKE_SPEED_STEP : -STROKE_SPEED_STEP);
|
|
287
|
+
s.speed = Math.max(
|
|
288
|
+
STROKE_SPEED_MS.min,
|
|
289
|
+
Math.min(STROKE_SPEED_MS.max, Math.round(wanted)),
|
|
290
|
+
);
|
|
291
|
+
return { type: "status", status: `strum speed ${s.speed}ms` };
|
|
292
|
+
}
|
|
239
293
|
if (value === "b") {
|
|
240
294
|
const index = BASS_MODES.indexOf(s.bass);
|
|
241
295
|
s.bass = BASS_MODES[(index + 1) % BASS_MODES.length]!;
|
|
@@ -392,6 +446,9 @@ export class ChordPad {
|
|
|
392
446
|
{ key: "0", label: "clear", on: false },
|
|
393
447
|
{ key: "-=", label: `voicing ${signed(s.inversion)}`, on: false },
|
|
394
448
|
{ key: "9", label: perform, on: false },
|
|
449
|
+
...(s.perform === "guitar"
|
|
450
|
+
? [{ key: "[]", label: `speed ${s.speed}ms`, on: false }]
|
|
451
|
+
: []),
|
|
395
452
|
{ key: "b", label: `bass ${s.bass}`, on: false },
|
|
396
453
|
{ key: "n", label: "next", on: false },
|
|
397
454
|
{ key: "q", label: s.mode === "auto" ? "auto" : "manual", on: false },
|
|
@@ -412,6 +469,8 @@ export class ChordPad {
|
|
|
412
469
|
if (s.spread !== "close") parts.push(s.spread);
|
|
413
470
|
if (s.perform === "pattern")
|
|
414
471
|
parts.push(`pattern ${patternLabel(s.pattern)}`);
|
|
472
|
+
else if (s.perform === "guitar")
|
|
473
|
+
parts.push(`guitar ${s.strokes} ${s.speed}ms`);
|
|
415
474
|
else if (s.perform !== "block") parts.push(s.perform);
|
|
416
475
|
if (s.bass !== "off") parts.push(`bass ${s.bass}`);
|
|
417
476
|
return parts.join(" · ");
|
|
@@ -435,15 +494,18 @@ function signed(value: number): string {
|
|
|
435
494
|
/**
|
|
436
495
|
* `/chords …`: `auto|manual|off`, `voicing <-12..12>`, `spread <close|open|
|
|
437
496
|
* wide>`, `bass off|chords|unison|single|solo` (`on` = chords), `perform
|
|
438
|
-
* <mode>`, `pattern <1..13|name>`, `
|
|
497
|
+
* <mode>`, `pattern <1..13|name>`, `strokes <name|D-DU-UDU>` (guitar
|
|
498
|
+
* mode), `speed <ms|1/32b>`, `rate <grid|1/8…>`, `octaves
|
|
439
499
|
* <1..4>`, `sevenths on|off`, `preset <name|none>`, `style <pop|jazz|…>`.
|
|
440
500
|
* Mutates `settings`; returns the receipt or an error.
|
|
441
501
|
*/
|
|
442
502
|
export function applyChordsCommand(
|
|
443
503
|
settings: ChordSettings,
|
|
444
504
|
argument: string,
|
|
505
|
+
bpm = 120,
|
|
445
506
|
): { ok: boolean; message: string } {
|
|
446
|
-
const
|
|
507
|
+
const raw = argument.trim().split(/\s+/).filter(Boolean);
|
|
508
|
+
const words = raw.map((word) => word.toLowerCase());
|
|
447
509
|
const [head, value] = words;
|
|
448
510
|
const done = (message: string) => ({ ok: true, message });
|
|
449
511
|
const bad = (message: string) => ({ ok: false, message });
|
|
@@ -505,6 +567,28 @@ export function applyChordsCommand(
|
|
|
505
567
|
return bad(`rate takes ${ARP_RATES.join("|")}`);
|
|
506
568
|
settings.rate = value as ArpRate;
|
|
507
569
|
return done(`chords rate ${value}`);
|
|
570
|
+
case "strokes": {
|
|
571
|
+
// Case matters: D is a full down stroke, d a light one.
|
|
572
|
+
const grid = raw.slice(1).join("");
|
|
573
|
+
if (!strokeGrid(grid))
|
|
574
|
+
return bad(
|
|
575
|
+
`strokes takes ${STROKE_PATTERN_NAMES.join("|")} or a grid of D U d u x - .`,
|
|
576
|
+
);
|
|
577
|
+
settings.strokes = (STROKE_PATTERN_NAMES as readonly string[]).includes(
|
|
578
|
+
grid.toLowerCase(),
|
|
579
|
+
)
|
|
580
|
+
? grid.toLowerCase()
|
|
581
|
+
: grid;
|
|
582
|
+
settings.perform = "guitar";
|
|
583
|
+
return done(`chords strokes ${settings.strokes}`);
|
|
584
|
+
}
|
|
585
|
+
case "speed": {
|
|
586
|
+
const ms = parseStrokeSpeed(words.slice(1).join(""), bpm);
|
|
587
|
+
if (ms === undefined)
|
|
588
|
+
return bad("speed takes 0..200 ms (22, 22ms) or beats (1/32b)");
|
|
589
|
+
settings.speed = ms;
|
|
590
|
+
return done(`chords speed ${ms}ms`);
|
|
591
|
+
}
|
|
508
592
|
case "octaves": {
|
|
509
593
|
const n = Number(value);
|
|
510
594
|
if (!Number.isInteger(n) || n < 1 || n > 4)
|
|
@@ -526,7 +610,7 @@ export function applyChordsCommand(
|
|
|
526
610
|
return done(`chords style ${value}`);
|
|
527
611
|
default:
|
|
528
612
|
return bad(
|
|
529
|
-
"/chords auto|manual|off · voicing · spread · bass · perform · pattern · rate · octaves · sevenths · preset · style",
|
|
613
|
+
"/chords auto|manual|off · voicing · spread · bass · perform · pattern · strokes · speed · rate · octaves · sevenths · preset · style",
|
|
530
614
|
);
|
|
531
615
|
}
|
|
532
616
|
}
|
|
@@ -539,6 +623,8 @@ export function chordsSummary(settings: ChordSettings): string {
|
|
|
539
623
|
s.spread,
|
|
540
624
|
s.perform,
|
|
541
625
|
`pattern ${patternLabel(s.pattern)}`,
|
|
626
|
+
`strokes ${s.strokes}`,
|
|
627
|
+
`speed ${s.speed}ms`,
|
|
542
628
|
`rate ${s.rate}`,
|
|
543
629
|
`octaves ${s.octaves}`,
|
|
544
630
|
`bass ${s.bass}`,
|
|
@@ -603,6 +689,7 @@ export function chordPhrase(
|
|
|
603
689
|
seed: 1,
|
|
604
690
|
velocity: 0.8,
|
|
605
691
|
...(mode === "pattern" ? { pattern: settings.pattern } : {}),
|
|
692
|
+
...(mode === "guitar" ? guitarPerform(settings, score, track) : {}),
|
|
606
693
|
},
|
|
607
694
|
});
|
|
608
695
|
// With chords off a key plays one note: the root, in the chord's octave.
|
|
@@ -630,3 +717,17 @@ export function chordPhrase(
|
|
|
630
717
|
}
|
|
631
718
|
return notes;
|
|
632
719
|
}
|
|
720
|
+
|
|
721
|
+
/** Perform options for the guitar mode: strokes, speed, tempo and fretting. */
|
|
722
|
+
export function guitarPerform(
|
|
723
|
+
settings: Pick<ChordSettings, "strokes" | "speed">,
|
|
724
|
+
score: Pick<TrackScore, "tempoBpm">,
|
|
725
|
+
track: Pick<Track, "guitar"> | undefined,
|
|
726
|
+
): Partial<PerformOptions> {
|
|
727
|
+
return {
|
|
728
|
+
strokes: settings.strokes,
|
|
729
|
+
speed: settings.speed / 1000,
|
|
730
|
+
tempo: score.tempoBpm,
|
|
731
|
+
...(track?.guitar ? { guitar: track.guitar } : {}),
|
|
732
|
+
};
|
|
733
|
+
}
|
package/src/tui/play-mode.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Shift sustain while held Tab sustain latch Esc leave the mode
|
|
13
13
|
* (while recording, sustain changes record as track pedal events)
|
|
14
14
|
* I scale degrees: the home row plays the key's scale (any tuning)
|
|
15
|
+
* [ / ] strum slower / faster (chords perform guitar, 5 ms steps)
|
|
15
16
|
*
|
|
16
17
|
* Terminals report key-down only, so a held key is synthesized from the
|
|
17
18
|
* keyboard's auto-repeat: a press sounds for the gate (one grid step by
|
package/src/tui/play-session.ts
CHANGED
|
@@ -48,21 +48,25 @@ import {
|
|
|
48
48
|
transportBarStart,
|
|
49
49
|
} from "../../core/tempo.ts";
|
|
50
50
|
import {
|
|
51
|
+
LiveFullRenderer,
|
|
51
52
|
LiveSynth,
|
|
52
53
|
MAX_LIVE_NOTE_SECONDS,
|
|
53
54
|
type LiveNotePcm,
|
|
54
55
|
} from "../audio/live.ts";
|
|
55
56
|
import type { SampleBank } from "../audio/samples.ts";
|
|
57
|
+
import { fitting, onFitReady } from "../audio/fit.ts";
|
|
56
58
|
import {
|
|
57
59
|
DEFAULT_STRUM,
|
|
58
60
|
perform,
|
|
59
61
|
type PerformMode,
|
|
62
|
+
type PerformOptions,
|
|
60
63
|
type PerformedNote,
|
|
61
64
|
} from "../../core/chords.ts";
|
|
62
65
|
import {
|
|
63
66
|
ChordPad,
|
|
64
67
|
chordCapable,
|
|
65
68
|
defaultChordSettings,
|
|
69
|
+
guitarPerform,
|
|
66
70
|
songKey,
|
|
67
71
|
type ChordSettings,
|
|
68
72
|
type PlayedChord,
|
|
@@ -91,6 +95,8 @@ export interface LiveEngine {
|
|
|
91
95
|
setLeadMs(ms: number | undefined): void;
|
|
92
96
|
noteOn(id: number, note: LiveNotePcm): number;
|
|
93
97
|
noteOff(id: number): void;
|
|
98
|
+
/** Frames of voice `id` mixed so far, or undefined once it has ended. */
|
|
99
|
+
voicePosition?(id: number): number | undefined;
|
|
94
100
|
setClick(click: ClickBus | undefined): void;
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -134,6 +140,8 @@ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
|
|
|
134
140
|
export const DEFAULT_GRID = "1/16";
|
|
135
141
|
/** Play mode's queue lead: short enough that a key feels immediate. */
|
|
136
142
|
export const PLAY_LEAD_MS = 60;
|
|
143
|
+
/** Status while a long fitted sample window computes (fit.ts). */
|
|
144
|
+
const FITTING_STATUS = "fitting · the sample plays once ready";
|
|
137
145
|
export const FLASH_MS = 110;
|
|
138
146
|
|
|
139
147
|
export function gridBeats(label: string): number | undefined {
|
|
@@ -175,6 +183,8 @@ export type RecordedChord = Readonly<{
|
|
|
175
183
|
seed: number;
|
|
176
184
|
/** CHORD_PATTERNS name when `mode` is `pattern`. */
|
|
177
185
|
pattern?: string;
|
|
186
|
+
/** Guitar mode: strokes, speed, tempo, fretting and the chord root. */
|
|
187
|
+
guitar?: Partial<PerformOptions>;
|
|
178
188
|
}>;
|
|
179
189
|
|
|
180
190
|
/** A chord sounding live: its voices start and stop from `tick()`. */
|
|
@@ -221,11 +231,16 @@ export class PlaySession {
|
|
|
221
231
|
public countInBars = 1;
|
|
222
232
|
public grid = DEFAULT_GRID;
|
|
223
233
|
public status: string | undefined;
|
|
234
|
+
/** Unsubscribes the pending "fit ready" listener (fit.ts). */
|
|
235
|
+
private stopFitWait: (() => void) | undefined;
|
|
224
236
|
/** Scale-degree layout on (`i`); follows key and tuning changes. */
|
|
225
237
|
public degreesOn = false;
|
|
226
238
|
private degreeKey: string | undefined;
|
|
227
239
|
private active = false;
|
|
228
240
|
private synth: LiveSynth | undefined;
|
|
241
|
+
/** Rig voices awaiting their full render: id → released since. */
|
|
242
|
+
private readonly windows = new Map<number, boolean>();
|
|
243
|
+
private readonly fullRenderer = new LiveFullRenderer();
|
|
229
244
|
private readonly pending = new Map<number, Pending>();
|
|
230
245
|
/** Sustain changes while recording (Tab latch or Shift), as pedal events. */
|
|
231
246
|
private pendingPedal: { beat: number; state: PedalState }[] = [];
|
|
@@ -597,7 +612,7 @@ export class PlaySession {
|
|
|
597
612
|
if (this.synth?.rate !== engine.sampleRate)
|
|
598
613
|
this.synth = new LiveSynth(engine.sampleRate);
|
|
599
614
|
const score = this.host.score();
|
|
600
|
-
const
|
|
615
|
+
const request = {
|
|
601
616
|
score,
|
|
602
617
|
trackId: this.trackId,
|
|
603
618
|
pitch: note.pitch,
|
|
@@ -607,16 +622,53 @@ export class PlaySession {
|
|
|
607
622
|
...(score.time?.tempo
|
|
608
623
|
? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
|
|
609
624
|
: {}),
|
|
610
|
-
}
|
|
625
|
+
};
|
|
626
|
+
const synth = this.synth;
|
|
627
|
+
const pcm = synth.render(request);
|
|
611
628
|
if (!pcm) return;
|
|
629
|
+
if (pcm.fitting) {
|
|
630
|
+
// A long fitted sample window is computing: silent, never off-pitch.
|
|
631
|
+
this.status = FITTING_STATUS;
|
|
632
|
+
this.stopFitWait ??= onFitReady(() => {
|
|
633
|
+
if (fitting()) return;
|
|
634
|
+
this.stopFitWait?.();
|
|
635
|
+
this.stopFitWait = undefined;
|
|
636
|
+
if (this.status === FITTING_STATUS)
|
|
637
|
+
this.status = "fit ready · play the key again";
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
612
641
|
const scheduled = engine.noteOn(id, pcm);
|
|
613
642
|
this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
|
|
643
|
+
// A guitar rig sounds its first window now; the rest renders after the
|
|
644
|
+
// key event and replaces the voice in place while it is still sounding.
|
|
645
|
+
this.windows.delete(id);
|
|
646
|
+
if (pcm.partial) this.windows.set(id, false);
|
|
647
|
+
// The full pass runs on a worker, in key order, so a strummed chord or a
|
|
648
|
+
// fast run never stalls key handling behind an oversampled render.
|
|
649
|
+
if (pcm.partial)
|
|
650
|
+
void this.fullRenderer
|
|
651
|
+
.full(
|
|
652
|
+
synth,
|
|
653
|
+
pcm.clock === undefined ? request : { ...request, clock: pcm.clock },
|
|
654
|
+
)
|
|
655
|
+
.then((full) => {
|
|
656
|
+
// Not after note-off: a swap would cancel the release fade.
|
|
657
|
+
if (
|
|
658
|
+
full &&
|
|
659
|
+
this.windows.get(id) === false &&
|
|
660
|
+
engine.voicePosition?.(id) !== undefined
|
|
661
|
+
)
|
|
662
|
+
engine.noteOn(id, full);
|
|
663
|
+
this.windows.delete(id);
|
|
664
|
+
});
|
|
614
665
|
}
|
|
615
666
|
|
|
616
667
|
private release(ids: readonly number[], atMs: number): void {
|
|
617
668
|
const engine = this.host.engine();
|
|
618
669
|
for (const id of ids) {
|
|
619
670
|
engine?.noteOff(id);
|
|
671
|
+
if (this.windows.has(id)) this.windows.set(id, true);
|
|
620
672
|
const live = this.liveChords.get(id);
|
|
621
673
|
if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
|
|
622
674
|
const pending = this.pending.get(id);
|
|
@@ -686,6 +738,20 @@ export class PlaySession {
|
|
|
686
738
|
octaves: settings.octaves,
|
|
687
739
|
seed,
|
|
688
740
|
...(mode === "pattern" ? { pattern: settings.pattern } : {}),
|
|
741
|
+
...(mode === "guitar" ? { guitar: this.guitarOptions(played) } : {}),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Guitar-mode perform options for a played chord (strokes, speed, frets). */
|
|
746
|
+
private guitarOptions(played: PlayedChord): Partial<PerformOptions> {
|
|
747
|
+
return {
|
|
748
|
+
...guitarPerform(
|
|
749
|
+
this.chords.settings,
|
|
750
|
+
{ tempoBpm: this.currentBpm() },
|
|
751
|
+
this.trackData(),
|
|
752
|
+
),
|
|
753
|
+
root: played.chord.root,
|
|
754
|
+
...(played.chord.bass !== undefined ? { slash: played.chord.bass } : {}),
|
|
689
755
|
};
|
|
690
756
|
}
|
|
691
757
|
|
|
@@ -706,6 +772,7 @@ export class PlaySession {
|
|
|
706
772
|
seed: note.id,
|
|
707
773
|
pattern: settings.pattern,
|
|
708
774
|
velocity: 1,
|
|
775
|
+
...(mode === "guitar" ? this.guitarOptions(played) : {}),
|
|
709
776
|
});
|
|
710
777
|
if (played.bass !== undefined)
|
|
711
778
|
plan.push({
|
|
@@ -1170,6 +1237,7 @@ export function chordNotes(
|
|
|
1170
1237
|
seed: chord.seed,
|
|
1171
1238
|
...(chord.pattern !== undefined ? { pattern: chord.pattern } : {}),
|
|
1172
1239
|
velocity: 1,
|
|
1240
|
+
...chord.guitar,
|
|
1173
1241
|
}).map((note) => ({
|
|
1174
1242
|
pitch: note.pitch,
|
|
1175
1243
|
velocity: scaleVelocity(press.velocity, note.velocity),
|