@hraness/dawg 0.4.1 → 0.6.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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -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/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/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/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/tui/play-session.ts
CHANGED
|
@@ -20,14 +20,41 @@ import {
|
|
|
20
20
|
type ScoreOperation,
|
|
21
21
|
type TrackScore,
|
|
22
22
|
} from "../../core/score.ts";
|
|
23
|
+
import {
|
|
24
|
+
EXPRESSION_LIMITS,
|
|
25
|
+
ExpressionValidationError,
|
|
26
|
+
normalizePedal,
|
|
27
|
+
pedalStateAt,
|
|
28
|
+
type PedalEvent,
|
|
29
|
+
type PedalState,
|
|
30
|
+
} from "../../core/expression.ts";
|
|
23
31
|
import type { ClickBus } from "../audio/engine.ts";
|
|
24
|
-
import { DEFAULT_CLICK_VOLUME, parseClickArgument } from "../audio/click.ts";
|
|
25
32
|
import {
|
|
33
|
+
DEFAULT_CLICK_VOLUME,
|
|
34
|
+
countInClicks,
|
|
35
|
+
meterClickGrid,
|
|
36
|
+
parseClickArgument,
|
|
37
|
+
} from "../audio/click.ts";
|
|
38
|
+
import {
|
|
39
|
+
barAt,
|
|
40
|
+
barStartTick,
|
|
41
|
+
bpmAtTick,
|
|
42
|
+
clickTicksOf,
|
|
43
|
+
hasMeterChanges,
|
|
44
|
+
hasTempoMap,
|
|
45
|
+
loopTickAt,
|
|
46
|
+
loopTicksOf,
|
|
47
|
+
transportBar,
|
|
48
|
+
transportBarStart,
|
|
49
|
+
} from "../../core/tempo.ts";
|
|
50
|
+
import {
|
|
51
|
+
LiveFullRenderer,
|
|
26
52
|
LiveSynth,
|
|
27
53
|
MAX_LIVE_NOTE_SECONDS,
|
|
28
54
|
type LiveNotePcm,
|
|
29
55
|
} from "../audio/live.ts";
|
|
30
56
|
import type { SampleBank } from "../audio/samples.ts";
|
|
57
|
+
import { fitting, onFitReady } from "../audio/fit.ts";
|
|
31
58
|
import {
|
|
32
59
|
DEFAULT_STRUM,
|
|
33
60
|
perform,
|
|
@@ -43,10 +70,12 @@ import {
|
|
|
43
70
|
type PlayedChord,
|
|
44
71
|
} from "./play-chords.ts";
|
|
45
72
|
import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
|
|
73
|
+
import { resolveTuning, type Tuning } from "../../core/tuning.ts";
|
|
46
74
|
import {
|
|
47
75
|
NOTE_KEYS,
|
|
48
76
|
PlayKeyboard,
|
|
49
77
|
REPEAT_DELAY_MS,
|
|
78
|
+
degreeLayout,
|
|
50
79
|
playLayoutFor,
|
|
51
80
|
stripCells,
|
|
52
81
|
type PlayAction,
|
|
@@ -64,6 +93,8 @@ export interface LiveEngine {
|
|
|
64
93
|
setLeadMs(ms: number | undefined): void;
|
|
65
94
|
noteOn(id: number, note: LiveNotePcm): number;
|
|
66
95
|
noteOff(id: number): void;
|
|
96
|
+
/** Frames of voice `id` mixed so far, or undefined once it has ended. */
|
|
97
|
+
voicePosition?(id: number): number | undefined;
|
|
67
98
|
setClick(click: ClickBus | undefined): void;
|
|
68
99
|
}
|
|
69
100
|
|
|
@@ -75,6 +106,11 @@ export interface PlayHost {
|
|
|
75
106
|
playing(): boolean;
|
|
76
107
|
/** Transport beat (unwrapped) at monotonic `ms`. */
|
|
77
108
|
beatAt(ms: number): number;
|
|
109
|
+
/**
|
|
110
|
+
* The score beat transport `beat` plays: inside a looped section, or the
|
|
111
|
+
* matching bar of the form pass. Absent means the transport is the score.
|
|
112
|
+
*/
|
|
113
|
+
scoreBeat?(beat: number): number;
|
|
78
114
|
engine(): LiveEngine | undefined;
|
|
79
115
|
samples?(): SampleBank | undefined;
|
|
80
116
|
commit(
|
|
@@ -102,6 +138,8 @@ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
|
|
|
102
138
|
export const DEFAULT_GRID = "1/16";
|
|
103
139
|
/** Play mode's queue lead: short enough that a key feels immediate. */
|
|
104
140
|
export const PLAY_LEAD_MS = 60;
|
|
141
|
+
/** Status while a long fitted sample window computes (fit.ts). */
|
|
142
|
+
const FITTING_STATUS = "fitting · the sample plays once ready";
|
|
105
143
|
export const FLASH_MS = 110;
|
|
106
144
|
|
|
107
145
|
export function gridBeats(label: string): number | undefined {
|
|
@@ -122,6 +160,12 @@ type Pending = {
|
|
|
122
160
|
/** Unwrapped transport beat of the press. */
|
|
123
161
|
beat: number;
|
|
124
162
|
releaseAtMs: number;
|
|
163
|
+
/**
|
|
164
|
+
* When the key itself let go. Differs from `releaseAtMs` only while the
|
|
165
|
+
* sustain pedal holds the note: the recorded length is the key's, and the
|
|
166
|
+
* recorded pedal events do the sustaining (as a MIDI CC64 recording does).
|
|
167
|
+
*/
|
|
168
|
+
keyUpMs: number;
|
|
125
169
|
/** Set when the key played a chord: laid out with `perform` on flush. */
|
|
126
170
|
chord?: RecordedChord;
|
|
127
171
|
};
|
|
@@ -157,7 +201,16 @@ const LIVE_PLAN_BEATS = 64;
|
|
|
157
201
|
/** Live chord voices use ids far above the keyboard's. */
|
|
158
202
|
const VOICE_ID_BASE = 1_000_000_000;
|
|
159
203
|
|
|
160
|
-
type CountIn = {
|
|
204
|
+
type CountIn = {
|
|
205
|
+
startMs: number;
|
|
206
|
+
beats: number;
|
|
207
|
+
startBeat: number;
|
|
208
|
+
/** Count-in tempo: the tempo where recording starts. */
|
|
209
|
+
bpm: number;
|
|
210
|
+
/** Beats per counted bar and per click (meter-aware). */
|
|
211
|
+
barBeats: number;
|
|
212
|
+
clickBeats: number;
|
|
213
|
+
};
|
|
161
214
|
|
|
162
215
|
export type PlayKeyResult =
|
|
163
216
|
| { type: "handled" }
|
|
@@ -174,13 +227,27 @@ export class PlaySession {
|
|
|
174
227
|
public countInBars = 1;
|
|
175
228
|
public grid = DEFAULT_GRID;
|
|
176
229
|
public status: string | undefined;
|
|
230
|
+
/** Unsubscribes the pending "fit ready" listener (fit.ts). */
|
|
231
|
+
private stopFitWait: (() => void) | undefined;
|
|
232
|
+
/** Scale-degree layout on (`i`); follows key and tuning changes. */
|
|
233
|
+
public degreesOn = false;
|
|
234
|
+
private degreeKey: string | undefined;
|
|
177
235
|
private active = false;
|
|
178
236
|
private synth: LiveSynth | undefined;
|
|
237
|
+
/** Rig voices awaiting their full render: id → released since. */
|
|
238
|
+
private readonly windows = new Map<number, boolean>();
|
|
239
|
+
private readonly fullRenderer = new LiveFullRenderer();
|
|
179
240
|
private readonly pending = new Map<number, Pending>();
|
|
241
|
+
/** Sustain changes while recording (Tab latch or Shift), as pedal events. */
|
|
242
|
+
private pendingPedal: { beat: number; state: PedalState }[] = [];
|
|
243
|
+
/** The pedal state last recorded this pass. */
|
|
244
|
+
private pedalDown = false;
|
|
180
245
|
/** Played note id → recorded score note id, for this pass. */
|
|
181
246
|
private readonly recordedIds = new Set<string>();
|
|
182
247
|
/** In-loop bars the playhead finished while replacing. */
|
|
183
248
|
private readonly replaceBars = new Set<number>();
|
|
249
|
+
/** Live pedal state as the playhead entered each loop bar (replace). */
|
|
250
|
+
private readonly barPedal = new Map<number, PedalState>();
|
|
184
251
|
private lastBar: number | undefined;
|
|
185
252
|
private countIn: CountIn | undefined;
|
|
186
253
|
private flushing: Promise<void> = Promise.resolve();
|
|
@@ -212,8 +279,15 @@ export class PlaySession {
|
|
|
212
279
|
this.layout = playLayoutFor(track);
|
|
213
280
|
const settings = options.chords ?? defaultChordSettings();
|
|
214
281
|
// Auto chords by default on chord-capable tracks, until chosen by hand.
|
|
282
|
+
// Diatonic triads assume 12-TET and a polyphonic part: a track in
|
|
283
|
+
// another tuning, or a mono/legato glide line, plays single notes.
|
|
215
284
|
if (!settings.explicit)
|
|
216
|
-
settings.mode =
|
|
285
|
+
settings.mode =
|
|
286
|
+
chordCapable(track) &&
|
|
287
|
+
twelveTet(this.host.score(), track) &&
|
|
288
|
+
(track?.glide === undefined || track.glide.mode === "poly")
|
|
289
|
+
? "auto"
|
|
290
|
+
: "manual";
|
|
217
291
|
this.chords = new ChordPad(settings, () => this.host.score().key);
|
|
218
292
|
this.keyboard = new PlayKeyboard({ base: this.layout.base });
|
|
219
293
|
this.clickOn = options.clickOn ?? false;
|
|
@@ -236,7 +310,17 @@ export class PlaySession {
|
|
|
236
310
|
|
|
237
311
|
/** Gate for a single press: one grid step at the current tempo. */
|
|
238
312
|
public gateMs(): number {
|
|
239
|
-
return (this.gridStep * 60_000) / this.
|
|
313
|
+
return (this.gridStep * 60_000) / this.currentBpm();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Tempo at the playhead (the song tempo without a tempo map). */
|
|
317
|
+
private currentBpm(): number {
|
|
318
|
+
const score = this.host.score();
|
|
319
|
+
if (!hasTempoMap(score)) return score.tempoBpm;
|
|
320
|
+
return bpmAtTick(
|
|
321
|
+
score,
|
|
322
|
+
loopTickAt(score, this.host.beatAt(this.host.now())),
|
|
323
|
+
);
|
|
240
324
|
}
|
|
241
325
|
|
|
242
326
|
public async enter(): Promise<void> {
|
|
@@ -265,11 +349,27 @@ export class PlaySession {
|
|
|
265
349
|
/** The click bus the engine mixes: count-in, then the transport. */
|
|
266
350
|
public clickBus(): ClickBus {
|
|
267
351
|
const score = this.host.score();
|
|
352
|
+
const count = this.countIn;
|
|
353
|
+
// Without meter changes the fixed beatsPerBar grid clicks as in 0.4.
|
|
354
|
+
let grid: ClickBus["grid"];
|
|
355
|
+
if (count && hasMeterChanges(score))
|
|
356
|
+
grid = (from, to) =>
|
|
357
|
+
countInClicks(
|
|
358
|
+
count.startBeat,
|
|
359
|
+
count.beats / count.barBeats,
|
|
360
|
+
count.barBeats,
|
|
361
|
+
count.clickBeats,
|
|
362
|
+
from,
|
|
363
|
+
to,
|
|
364
|
+
);
|
|
365
|
+
else if (!count && hasMeterChanges(score))
|
|
366
|
+
grid = meterClickGrid(() => this.host.score());
|
|
268
367
|
return {
|
|
269
368
|
volume: this.clickVolume,
|
|
270
369
|
beatsPerBar: score.beatsPerBar,
|
|
271
370
|
subdivision: 1,
|
|
272
371
|
beatAt: (ms) => this.clickBeatAt(ms),
|
|
372
|
+
...(grid ? { grid } : {}),
|
|
273
373
|
};
|
|
274
374
|
}
|
|
275
375
|
|
|
@@ -277,7 +377,7 @@ export class PlaySession {
|
|
|
277
377
|
public clickBeatAt(ms: number): number | undefined {
|
|
278
378
|
const count = this.countIn;
|
|
279
379
|
if (count) {
|
|
280
|
-
const beatMs = 60_000 /
|
|
380
|
+
const beatMs = 60_000 / count.bpm;
|
|
281
381
|
return count.startBeat - count.beats + (ms - count.startMs) / beatMs;
|
|
282
382
|
}
|
|
283
383
|
if (!this.clickOn || !this.host.playing()) return undefined;
|
|
@@ -320,6 +420,7 @@ export class PlaySession {
|
|
|
320
420
|
/** Handle one decoded key; mode commands return to the caller. */
|
|
321
421
|
public press(value: string): PlayKeyResult {
|
|
322
422
|
const now = this.host.now();
|
|
423
|
+
this.syncDegrees();
|
|
323
424
|
let key = value;
|
|
324
425
|
if (this.chords.on && value === "n") {
|
|
325
426
|
// Route the suggestion through the note key of its root, so holding
|
|
@@ -340,16 +441,21 @@ export class PlaySession {
|
|
|
340
441
|
}
|
|
341
442
|
}
|
|
342
443
|
const action = this.keyboard.press(key, now, this.gateMs());
|
|
343
|
-
|
|
444
|
+
const result = this.apply(action, now);
|
|
445
|
+
this.recordPedal(now);
|
|
446
|
+
return result;
|
|
344
447
|
}
|
|
345
448
|
|
|
346
449
|
/** The suggested next chord and the lowest note key on its root. */
|
|
347
450
|
private nextRoute(): { key: string; chord: PlayedChord["chord"] } {
|
|
348
451
|
const chord = this.chords.next();
|
|
349
|
-
const keys = Object.
|
|
350
|
-
|
|
351
|
-
(
|
|
352
|
-
|
|
452
|
+
const keys = Object.keys(NOTE_KEYS)
|
|
453
|
+
.map((key) => [key, this.keyboard.pitchFor(key)] as const)
|
|
454
|
+
.filter(
|
|
455
|
+
(entry): entry is readonly [string, number] => entry[1] !== undefined,
|
|
456
|
+
)
|
|
457
|
+
.sort((a, b) => a[1] - b[1]);
|
|
458
|
+
const found = keys.find(([, pitch]) => (pitch - chord.root) % 12 === 0);
|
|
353
459
|
return { key: found?.[0] ?? "a", chord };
|
|
354
460
|
}
|
|
355
461
|
|
|
@@ -402,7 +508,15 @@ export class PlaySession {
|
|
|
402
508
|
this.stopChord(action.absorbed);
|
|
403
509
|
this.pending.delete(action.absorbed);
|
|
404
510
|
}
|
|
405
|
-
if (pending)
|
|
511
|
+
if (pending) {
|
|
512
|
+
pending.releaseAtMs = action.releaseAtMs;
|
|
513
|
+
pending.keyUpMs = Math.max(
|
|
514
|
+
pending.keyUpMs,
|
|
515
|
+
pending.chord || Number.isFinite(action.releaseAtMs)
|
|
516
|
+
? action.releaseAtMs
|
|
517
|
+
: now + this.gateMs(),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
406
520
|
const live = this.liveChords.get(action.id);
|
|
407
521
|
if (live) {
|
|
408
522
|
live.releaseAtMs = action.releaseAtMs;
|
|
@@ -442,9 +556,47 @@ export class PlaySession {
|
|
|
442
556
|
this.status = this.clickCommand("toggle");
|
|
443
557
|
return { type: "handled" };
|
|
444
558
|
}
|
|
559
|
+
if (command === "degrees") {
|
|
560
|
+
this.status = this.toggleDegrees();
|
|
561
|
+
return { type: "handled" };
|
|
562
|
+
}
|
|
445
563
|
return { type: "command", command };
|
|
446
564
|
}
|
|
447
565
|
|
|
566
|
+
/**
|
|
567
|
+
* `i`: the home row plays scale degrees (the song key's scale, or every
|
|
568
|
+
* step of a non-12 tuning) instead of chromatic keys. Drum kits and
|
|
569
|
+
* sampler slots keep their pads.
|
|
570
|
+
*/
|
|
571
|
+
public toggleDegrees(on = !this.degreesOn): string {
|
|
572
|
+
if (on && this.layout.labels.size > 0)
|
|
573
|
+
return "scale degrees need a melodic track";
|
|
574
|
+
this.degreesOn = on;
|
|
575
|
+
this.degreeKey = undefined;
|
|
576
|
+
this.syncDegrees();
|
|
577
|
+
return on
|
|
578
|
+
? `scale degrees · ${this.keyboard.degrees!.name} · ${this.keyboard.range}`
|
|
579
|
+
: `chromatic · ${this.keyboard.range}`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Follows the song key and tuning while degree mode is on. */
|
|
583
|
+
private syncDegrees(): void {
|
|
584
|
+
if (!this.degreesOn) {
|
|
585
|
+
this.keyboard.degrees = undefined;
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const score = this.host.score();
|
|
589
|
+
const track = this.trackData();
|
|
590
|
+
const signature = JSON.stringify([score.key, score.tuning, track?.tuning]);
|
|
591
|
+
if (signature === this.degreeKey && this.keyboard.degrees) return;
|
|
592
|
+
this.degreeKey = signature;
|
|
593
|
+
const table =
|
|
594
|
+
score.tuning || track?.tuning
|
|
595
|
+
? resolveTuning(score.tuning, track?.tuning, score.key)
|
|
596
|
+
: undefined;
|
|
597
|
+
this.keyboard.degrees = degreeLayout(score.key, table);
|
|
598
|
+
}
|
|
599
|
+
|
|
448
600
|
/** Render the note through the track's own voice and start it. */
|
|
449
601
|
private sound(id: number, note: PlayedNote): void {
|
|
450
602
|
const engine = this.host.engine();
|
|
@@ -455,27 +607,66 @@ export class PlaySession {
|
|
|
455
607
|
const samples = this.host.samples?.();
|
|
456
608
|
if (this.synth?.rate !== engine.sampleRate)
|
|
457
609
|
this.synth = new LiveSynth(engine.sampleRate);
|
|
458
|
-
const
|
|
459
|
-
|
|
610
|
+
const score = this.host.score();
|
|
611
|
+
const request = {
|
|
612
|
+
score,
|
|
460
613
|
trackId: this.trackId,
|
|
461
614
|
pitch: note.pitch,
|
|
462
615
|
velocity: note.velocity / 127,
|
|
463
616
|
seconds,
|
|
464
617
|
...(samples ? { samples } : {}),
|
|
465
|
-
|
|
618
|
+
...(score.time?.tempo
|
|
619
|
+
? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
|
|
620
|
+
: {}),
|
|
621
|
+
};
|
|
622
|
+
const synth = this.synth;
|
|
623
|
+
const pcm = synth.render(request);
|
|
466
624
|
if (!pcm) return;
|
|
625
|
+
if (pcm.fitting) {
|
|
626
|
+
// A long fitted sample window is computing: silent, never off-pitch.
|
|
627
|
+
this.status = FITTING_STATUS;
|
|
628
|
+
this.stopFitWait ??= onFitReady(() => {
|
|
629
|
+
if (fitting()) return;
|
|
630
|
+
this.stopFitWait?.();
|
|
631
|
+
this.stopFitWait = undefined;
|
|
632
|
+
if (this.status === FITTING_STATUS)
|
|
633
|
+
this.status = "fit ready · play the key again";
|
|
634
|
+
});
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
467
637
|
const scheduled = engine.noteOn(id, pcm);
|
|
468
638
|
this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
|
|
639
|
+
// A guitar rig sounds its first window now; the rest renders after the
|
|
640
|
+
// key event and replaces the voice in place while it is still sounding.
|
|
641
|
+
this.windows.delete(id);
|
|
642
|
+
if (pcm.partial) this.windows.set(id, false);
|
|
643
|
+
// The full pass runs on a worker, in key order, so a strummed chord or a
|
|
644
|
+
// fast run never stalls key handling behind an oversampled render.
|
|
645
|
+
if (pcm.partial)
|
|
646
|
+
void this.fullRenderer.full(synth, request).then((full) => {
|
|
647
|
+
// Not after note-off: a swap would cancel the release fade.
|
|
648
|
+
if (
|
|
649
|
+
full &&
|
|
650
|
+
this.windows.get(id) === false &&
|
|
651
|
+
engine.voicePosition?.(id) !== undefined
|
|
652
|
+
)
|
|
653
|
+
engine.noteOn(id, full);
|
|
654
|
+
this.windows.delete(id);
|
|
655
|
+
});
|
|
469
656
|
}
|
|
470
657
|
|
|
471
658
|
private release(ids: readonly number[], atMs: number): void {
|
|
472
659
|
const engine = this.host.engine();
|
|
473
660
|
for (const id of ids) {
|
|
474
661
|
engine?.noteOff(id);
|
|
662
|
+
if (this.windows.has(id)) this.windows.set(id, true);
|
|
475
663
|
const live = this.liveChords.get(id);
|
|
476
664
|
if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
|
|
477
665
|
const pending = this.pending.get(id);
|
|
478
|
-
if (pending)
|
|
666
|
+
if (pending) {
|
|
667
|
+
pending.releaseAtMs = atMs;
|
|
668
|
+
pending.keyUpMs = Math.min(pending.keyUpMs, atMs);
|
|
669
|
+
}
|
|
479
670
|
}
|
|
480
671
|
}
|
|
481
672
|
|
|
@@ -492,10 +683,38 @@ export class PlaySession {
|
|
|
492
683
|
atMs: note.atMs,
|
|
493
684
|
beat: this.host.beatAt(note.atMs),
|
|
494
685
|
releaseAtMs: note.releaseAtMs,
|
|
686
|
+
// A chord press lays its voices out over the held length (arps keep
|
|
687
|
+
// stepping while the pedal holds), so it keeps that length.
|
|
688
|
+
keyUpMs:
|
|
689
|
+
chord || Number.isFinite(note.releaseAtMs)
|
|
690
|
+
? note.releaseAtMs
|
|
691
|
+
: note.atMs + this.gateMs(),
|
|
495
692
|
...(chord ? { chord } : {}),
|
|
496
693
|
});
|
|
497
694
|
}
|
|
498
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Record a sustain change (Tab latch, or Shift held on note keys) as a
|
|
698
|
+
* pedal event at the playhead, like Logic's Musical Typing records its Tab
|
|
699
|
+
* sustain key as CC64.
|
|
700
|
+
*/
|
|
701
|
+
private recordPedal(now: number): void {
|
|
702
|
+
// Chord presses record the held length on their voices instead.
|
|
703
|
+
const down =
|
|
704
|
+
this.keyboard.sustain &&
|
|
705
|
+
![...this.pending.values()].some((pending) => pending.chord);
|
|
706
|
+
if (!this.recording) {
|
|
707
|
+
if (!down) this.pedalDown = false;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (down === this.pedalDown) return;
|
|
711
|
+
this.pedalDown = down;
|
|
712
|
+
this.pendingPedal.push({
|
|
713
|
+
beat: this.host.beatAt(now),
|
|
714
|
+
state: down ? "down" : "up",
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
499
718
|
private recordedChord(
|
|
500
719
|
played: PlayedChord,
|
|
501
720
|
seed: number,
|
|
@@ -560,7 +779,7 @@ export class PlaySession {
|
|
|
560
779
|
}
|
|
561
780
|
|
|
562
781
|
private beatMs(): number {
|
|
563
|
-
return 60_000 / this.
|
|
782
|
+
return 60_000 / this.currentBpm();
|
|
564
783
|
}
|
|
565
784
|
|
|
566
785
|
/** Start due voices, end finished ones, drop released chords. */
|
|
@@ -639,17 +858,25 @@ export class PlaySession {
|
|
|
639
858
|
public startWithCountIn(): boolean {
|
|
640
859
|
if (!this.armed || this.host.playing() || this.countIn) return false;
|
|
641
860
|
const score = this.host.score();
|
|
642
|
-
const startBeat =
|
|
643
|
-
|
|
644
|
-
score.
|
|
861
|
+
const startBeat = transportBarStart(
|
|
862
|
+
score,
|
|
863
|
+
transportBar(score, this.host.beatAt(this.host.now())),
|
|
864
|
+
);
|
|
645
865
|
if (this.countInBars === 0) {
|
|
646
866
|
void this.host.startTransport(startBeat);
|
|
647
867
|
return true;
|
|
648
868
|
}
|
|
869
|
+
// Count in the meter and tempo of the bar recording starts in.
|
|
870
|
+
const at = barAt(score, loopTickAt(score, startBeat));
|
|
871
|
+
const tpb = score.ticksPerBeat;
|
|
872
|
+
const barBeats = at.barTicks / tpb;
|
|
649
873
|
this.countIn = {
|
|
650
874
|
startMs: this.host.now(),
|
|
651
|
-
beats: this.countInBars *
|
|
875
|
+
beats: this.countInBars * barBeats,
|
|
652
876
|
startBeat,
|
|
877
|
+
bpm: bpmAtTick(score, at.tick),
|
|
878
|
+
barBeats,
|
|
879
|
+
clickBeats: clickTicksOf(at, tpb) / tpb,
|
|
653
880
|
};
|
|
654
881
|
this.applyClick();
|
|
655
882
|
return true;
|
|
@@ -662,7 +889,7 @@ export class PlaySession {
|
|
|
662
889
|
this.pumpChords(now);
|
|
663
890
|
const count = this.countIn;
|
|
664
891
|
if (count) {
|
|
665
|
-
const beatMs = 60_000 /
|
|
892
|
+
const beatMs = 60_000 / count.bpm;
|
|
666
893
|
if (now - count.startMs >= count.beats * beatMs) {
|
|
667
894
|
this.countIn = undefined;
|
|
668
895
|
this.applyClick();
|
|
@@ -677,14 +904,28 @@ export class PlaySession {
|
|
|
677
904
|
return;
|
|
678
905
|
}
|
|
679
906
|
const score = this.host.score();
|
|
680
|
-
const bar =
|
|
907
|
+
const bar = transportBar(score, this.host.beatAt(now));
|
|
908
|
+
if (bar !== this.lastBar)
|
|
909
|
+
this.barPedal.set(this.loopBar(bar), this.pedalDown ? "down" : "up");
|
|
681
910
|
if (this.lastBar !== undefined && bar !== this.lastBar) {
|
|
682
|
-
if (this.replace)
|
|
911
|
+
if (this.replace)
|
|
912
|
+
this.replaceBars.add(
|
|
913
|
+
this.loopBar(
|
|
914
|
+
Math.floor(
|
|
915
|
+
this.toScoreBeat(this.lastBar * score.beatsPerBar) /
|
|
916
|
+
score.beatsPerBar,
|
|
917
|
+
),
|
|
918
|
+
),
|
|
919
|
+
);
|
|
683
920
|
this.queueFlush(bar, now, false);
|
|
684
921
|
}
|
|
685
922
|
this.lastBar = bar;
|
|
686
923
|
}
|
|
687
924
|
|
|
925
|
+
private toScoreBeat(beat: number): number {
|
|
926
|
+
return this.host.scoreBeat ? this.host.scoreBeat(beat) : beat;
|
|
927
|
+
}
|
|
928
|
+
|
|
688
929
|
private loopBar(bar: number): number {
|
|
689
930
|
const bars = this.host.score().bars;
|
|
690
931
|
return ((bar % bars) + bars) % bars;
|
|
@@ -693,6 +934,10 @@ export class PlaySession {
|
|
|
693
934
|
/** Commit what record armed collected; called when recording ends. */
|
|
694
935
|
public async stopRecording(): Promise<void> {
|
|
695
936
|
const now = this.host.now();
|
|
937
|
+
// A pedal still down when recording stops lifts here.
|
|
938
|
+
if (this.pedalDown && this.host.playing())
|
|
939
|
+
this.pendingPedal.push({ beat: this.host.beatAt(now), state: "up" });
|
|
940
|
+
this.pedalDown = false;
|
|
696
941
|
this.queueFlush(Number.POSITIVE_INFINITY, now, true);
|
|
697
942
|
await this.flushing;
|
|
698
943
|
this.recordedIds.clear();
|
|
@@ -722,24 +967,52 @@ export class PlaySession {
|
|
|
722
967
|
const score = this.host.score();
|
|
723
968
|
const ready: Pending[] = [];
|
|
724
969
|
for (const pending of this.pending.values()) {
|
|
725
|
-
const bar =
|
|
726
|
-
const released = pending.
|
|
970
|
+
const bar = transportBar(score, pending.beat);
|
|
971
|
+
const released = pending.keyUpMs <= now;
|
|
727
972
|
if (all || (bar < currentBar && released)) ready.push(pending);
|
|
728
973
|
}
|
|
729
974
|
const erase = [...this.replaceBars];
|
|
730
975
|
this.replaceBars.clear();
|
|
731
|
-
|
|
732
|
-
|
|
976
|
+
const pedal = this.pendingPedal.filter(
|
|
977
|
+
(event) => all || transportBar(score, event.beat) < currentBar,
|
|
978
|
+
);
|
|
979
|
+
this.pendingPedal = this.pendingPedal.filter(
|
|
980
|
+
(event) => !pedal.includes(event),
|
|
981
|
+
);
|
|
982
|
+
if (ready.length === 0 && erase.length === 0 && pedal.length === 0) return;
|
|
983
|
+
const eraseStates = new Map(
|
|
984
|
+
erase.map((bar) => [bar, this.barPedal.get(bar) ?? "up"] as const),
|
|
985
|
+
);
|
|
986
|
+
try {
|
|
987
|
+
await this.commitTake(score, ready, erase, eraseStates, pedal, now);
|
|
988
|
+
} catch (error) {
|
|
989
|
+
// Nothing was committed: keep the take so the next flush retries it.
|
|
990
|
+
for (const pending of ready) this.pending.set(pending.id, pending);
|
|
991
|
+
for (const bar of erase) this.replaceBars.add(bar);
|
|
992
|
+
this.pendingPedal = [...pedal, ...this.pendingPedal];
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private async commitTake(
|
|
998
|
+
score: TrackScore,
|
|
999
|
+
ready: readonly Pending[],
|
|
1000
|
+
erase: readonly number[],
|
|
1001
|
+
eraseStates: ReadonlyMap<number, PedalState>,
|
|
1002
|
+
pedal: readonly { beat: number; state: PedalState }[],
|
|
1003
|
+
now: number,
|
|
1004
|
+
): Promise<void> {
|
|
733
1005
|
const operations = recordOperations(score, {
|
|
734
1006
|
trackId: this.trackId,
|
|
735
1007
|
notes: ready.flatMap((pending) => {
|
|
736
|
-
const beats =
|
|
737
|
-
(
|
|
738
|
-
|
|
739
|
-
|
|
1008
|
+
const beats = hasTempoMap(score)
|
|
1009
|
+
? this.host.beatAt(Math.min(pending.keyUpMs, now)) -
|
|
1010
|
+
this.host.beatAt(pending.atMs)
|
|
1011
|
+
: ((Math.min(pending.keyUpMs, now) - pending.atMs) * score.tempoBpm) /
|
|
1012
|
+
60_000;
|
|
740
1013
|
return pending.chord
|
|
741
1014
|
? chordNotes(pending.chord, {
|
|
742
|
-
beat: pending.beat,
|
|
1015
|
+
beat: this.toScoreBeat(pending.beat),
|
|
743
1016
|
beats,
|
|
744
1017
|
velocity: pending.velocity,
|
|
745
1018
|
grid: this.gridStep,
|
|
@@ -748,7 +1021,7 @@ export class PlaySession {
|
|
|
748
1021
|
{
|
|
749
1022
|
pitch: pending.pitch,
|
|
750
1023
|
velocity: pending.velocity,
|
|
751
|
-
beat: pending.beat,
|
|
1024
|
+
beat: this.toScoreBeat(pending.beat),
|
|
752
1025
|
beats,
|
|
753
1026
|
},
|
|
754
1027
|
];
|
|
@@ -758,22 +1031,37 @@ export class PlaySession {
|
|
|
758
1031
|
keep: this.recordedIds,
|
|
759
1032
|
newId: () => this.host.newNoteId(),
|
|
760
1033
|
});
|
|
1034
|
+
// A pedal lane that cannot take more events never costs the notes.
|
|
1035
|
+
let pedalNote = "";
|
|
1036
|
+
try {
|
|
1037
|
+
const pedalOperation = recordPedalOperation(score, {
|
|
1038
|
+
trackId: this.trackId,
|
|
1039
|
+
events: pedal,
|
|
1040
|
+
eraseBars: erase,
|
|
1041
|
+
eraseStates,
|
|
1042
|
+
});
|
|
1043
|
+
if (pedalOperation) operations.push(pedalOperation);
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
if (!(error instanceof ExpressionValidationError)) throw error;
|
|
1046
|
+
pedalNote = ` · pedal not recorded (${error.message})`;
|
|
1047
|
+
}
|
|
761
1048
|
if (operations.length === 0) return;
|
|
762
1049
|
let next = score;
|
|
763
1050
|
for (const operation of operations)
|
|
764
1051
|
next = applyScoreOperation(next, operation);
|
|
765
|
-
for (const operation of operations)
|
|
766
|
-
if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
|
|
767
1052
|
const added = operations.filter((op) => op.type === "addNote").length;
|
|
768
|
-
const removed = operations.
|
|
1053
|
+
const removed = operations.filter((op) => op.type === "removeNote").length;
|
|
769
1054
|
await this.host.commit(next, "score.record", {
|
|
770
1055
|
trackId: this.trackId,
|
|
771
1056
|
operations,
|
|
772
1057
|
replace: this.replace,
|
|
773
1058
|
});
|
|
1059
|
+
for (const pending of ready) this.pending.delete(pending.id);
|
|
1060
|
+
for (const operation of operations)
|
|
1061
|
+
if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
|
|
774
1062
|
this.host.card(
|
|
775
|
-
`recorded ${added} note${added === 1 ? "" : "s"}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}`,
|
|
776
|
-
"success",
|
|
1063
|
+
`recorded ${added} note${added === 1 ? "" : "s"}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
|
|
1064
|
+
pedalNote ? "warning" : "success",
|
|
777
1065
|
);
|
|
778
1066
|
}
|
|
779
1067
|
|
|
@@ -783,13 +1071,13 @@ export class PlaySession {
|
|
|
783
1071
|
const count = this.countIn;
|
|
784
1072
|
let beat: PlayHeaderView["beat"];
|
|
785
1073
|
let countIn: string | undefined;
|
|
786
|
-
const beatMs = 60_000 / score.tempoBpm;
|
|
1074
|
+
const beatMs = 60_000 / (count?.bpm ?? score.tempoBpm);
|
|
787
1075
|
const at = count
|
|
788
1076
|
? count.startBeat - count.beats + (now - count.startMs) / beatMs
|
|
789
1077
|
: this.host.playing()
|
|
790
1078
|
? this.host.beatAt(now)
|
|
791
1079
|
: undefined;
|
|
792
|
-
if (at !== undefined) {
|
|
1080
|
+
if (at !== undefined && (count || !hasMeterChanges(score))) {
|
|
793
1081
|
const whole = Math.floor(at);
|
|
794
1082
|
const inBar =
|
|
795
1083
|
((whole % score.beatsPerBar) + score.beatsPerBar) % score.beatsPerBar;
|
|
@@ -798,7 +1086,37 @@ export class PlaySession {
|
|
|
798
1086
|
of: score.beatsPerBar,
|
|
799
1087
|
flash: (at - whole) * beatMs < FLASH_MS,
|
|
800
1088
|
};
|
|
801
|
-
if (count)
|
|
1089
|
+
if (count) {
|
|
1090
|
+
const clicks = Math.round(count.barBeats / count.clickBeats);
|
|
1091
|
+
const step = Math.floor(
|
|
1092
|
+
(at - (count.startBeat - count.beats)) / count.clickBeats,
|
|
1093
|
+
);
|
|
1094
|
+
beat = {
|
|
1095
|
+
index: (((step % clicks) + clicks) % clicks) + 1,
|
|
1096
|
+
of: clicks,
|
|
1097
|
+
flash:
|
|
1098
|
+
((at - (count.startBeat - count.beats)) % count.clickBeats) *
|
|
1099
|
+
beatMs <
|
|
1100
|
+
FLASH_MS,
|
|
1101
|
+
};
|
|
1102
|
+
countIn = `count-in ${Math.ceil((count.startBeat - at) / count.clickBeats)}`;
|
|
1103
|
+
}
|
|
1104
|
+
} else if (at !== undefined) {
|
|
1105
|
+
// Meter changes: count the clicks of the bar the playhead is in.
|
|
1106
|
+
const tpb = score.ticksPerBeat;
|
|
1107
|
+
const position = barAt(score, loopTickAt(score, at));
|
|
1108
|
+
const click = clickTicksOf(position, tpb);
|
|
1109
|
+
const inBar = position.offset / click;
|
|
1110
|
+
const whole = Math.floor(inBar);
|
|
1111
|
+
beat = {
|
|
1112
|
+
index: whole + 1,
|
|
1113
|
+
of: Math.round(position.barTicks / click),
|
|
1114
|
+
flash:
|
|
1115
|
+
((inBar - whole) * click * 60_000) /
|
|
1116
|
+
tpb /
|
|
1117
|
+
bpmAtTick(score, position.tick + whole * click) <
|
|
1118
|
+
FLASH_MS,
|
|
1119
|
+
};
|
|
802
1120
|
}
|
|
803
1121
|
return {
|
|
804
1122
|
range: this.keyboard.range,
|
|
@@ -828,6 +1146,7 @@ export class PlaySession {
|
|
|
828
1146
|
}
|
|
829
1147
|
|
|
830
1148
|
public strip(now = this.host.now()): PlayStripKey[] {
|
|
1149
|
+
this.syncDegrees();
|
|
831
1150
|
return stripCells(
|
|
832
1151
|
this.keyboard,
|
|
833
1152
|
this.keyboard.litKeys(now),
|
|
@@ -929,14 +1248,16 @@ export function recordOperations(
|
|
|
929
1248
|
}>,
|
|
930
1249
|
): ScoreOperation[] {
|
|
931
1250
|
const operations: ScoreOperation[] = [];
|
|
932
|
-
const loopBeats = score.bars * score.beatsPerBar;
|
|
933
1251
|
const tpb = score.ticksPerBeat;
|
|
1252
|
+
const loopBeats = loopTicksOf(score) / tpb;
|
|
934
1253
|
const erase = new Set(options.eraseBars ?? []);
|
|
935
1254
|
if (erase.size > 0)
|
|
936
1255
|
for (const note of score.notes) {
|
|
937
1256
|
if (note.trackId !== options.trackId) continue;
|
|
938
1257
|
if (options.keep?.has(note.id)) continue;
|
|
939
|
-
const bar =
|
|
1258
|
+
const bar = hasMeterChanges(score)
|
|
1259
|
+
? barAt(score, note.startTick).bar
|
|
1260
|
+
: Math.floor(note.startTick / tpb / score.beatsPerBar);
|
|
940
1261
|
if (erase.has(bar))
|
|
941
1262
|
operations.push({ type: "removeNote", noteId: note.id });
|
|
942
1263
|
}
|
|
@@ -982,3 +1303,98 @@ export function recordOperations(
|
|
|
982
1303
|
}
|
|
983
1304
|
return operations;
|
|
984
1305
|
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* The `updateTrack` that merges recorded pedal events into the track,
|
|
1309
|
+
* wrapped into the loop (unquantized: pedalling is about the release), after
|
|
1310
|
+
* dropping existing events in `eraseBars` (replace). Undefined when nothing
|
|
1311
|
+
* changes.
|
|
1312
|
+
*/
|
|
1313
|
+
export function recordPedalOperation(
|
|
1314
|
+
score: TrackScore,
|
|
1315
|
+
options: Readonly<{
|
|
1316
|
+
trackId: string;
|
|
1317
|
+
events: readonly { beat: number; state: PedalState }[];
|
|
1318
|
+
eraseBars?: readonly number[];
|
|
1319
|
+
/** The pedal as the take entered each erased bar (default `up`). */
|
|
1320
|
+
eraseStates?: ReadonlyMap<number, PedalState>;
|
|
1321
|
+
}>,
|
|
1322
|
+
): ScoreOperation | undefined {
|
|
1323
|
+
const track = score.tracks.find((item) => item.id === options.trackId);
|
|
1324
|
+
if (!track) return undefined;
|
|
1325
|
+
const tpb = score.ticksPerBeat;
|
|
1326
|
+
// Bars follow the meter map; without meter changes this is the 0.4 grid.
|
|
1327
|
+
const maxTick = loopTicksOf(score);
|
|
1328
|
+
const original = track.pedal ?? [];
|
|
1329
|
+
const erase = new Set(options.eraseBars ?? []);
|
|
1330
|
+
const kept = original.filter(
|
|
1331
|
+
(event) => !erase.has(barAt(score, event.tick).bar),
|
|
1332
|
+
);
|
|
1333
|
+
if (options.events.length === 0 && erase.size === 0) return undefined;
|
|
1334
|
+
const byTick = new Map<number, PedalEvent>(
|
|
1335
|
+
kept.map((event) => [event.tick, event]),
|
|
1336
|
+
);
|
|
1337
|
+
// Replace: an erased bar starts in the state the take had there, and the
|
|
1338
|
+
// old pedal resumes after it, so no stale press is left without its lift.
|
|
1339
|
+
for (const bar of erase) {
|
|
1340
|
+
const start = barStartTick(score, bar);
|
|
1341
|
+
const end = barStartTick(score, bar + 1);
|
|
1342
|
+
byTick.set(start, {
|
|
1343
|
+
tick: start,
|
|
1344
|
+
state: options.eraseStates?.get(bar) ?? "up",
|
|
1345
|
+
});
|
|
1346
|
+
if (end < maxTick && !erase.has(bar + 1) && !byTick.has(end))
|
|
1347
|
+
byTick.set(end, { tick: end, state: pedalStateAt(original, end - 1) });
|
|
1348
|
+
}
|
|
1349
|
+
for (const event of options.events) {
|
|
1350
|
+
let tick = Math.round(event.beat * tpb) % maxTick;
|
|
1351
|
+
if (tick < 0) tick += maxTick;
|
|
1352
|
+
byTick.set(tick, { tick, state: event.state });
|
|
1353
|
+
}
|
|
1354
|
+
const sorted = [...byTick.values()].sort((a, b) => a.tick - b.tick);
|
|
1355
|
+
// A pedal held across the loop seam is down again from tick 0.
|
|
1356
|
+
const seam = sorted[sorted.length - 1]?.state ?? "up";
|
|
1357
|
+
if (options.events.length > 0 && seam !== "up" && sorted[0]?.tick !== 0)
|
|
1358
|
+
sorted.unshift({ tick: 0, state: seam });
|
|
1359
|
+
// Drop events that do not change the state (the lane has a size limit).
|
|
1360
|
+
const compact: PedalEvent[] = [];
|
|
1361
|
+
for (const event of sorted) {
|
|
1362
|
+
const before = compact[compact.length - 1]?.state ?? "up";
|
|
1363
|
+
if (event.state !== before) compact.push(event);
|
|
1364
|
+
}
|
|
1365
|
+
if (compact.length > EXPRESSION_LIMITS.maxPedalEvents)
|
|
1366
|
+
throw new ExpressionValidationError(
|
|
1367
|
+
`the pedal lane is full (${EXPRESSION_LIMITS.maxPedalEvents} events)`,
|
|
1368
|
+
);
|
|
1369
|
+
const pedal = normalizePedal(compact, maxTick);
|
|
1370
|
+
const same =
|
|
1371
|
+
(pedal?.length ?? 0) === original.length &&
|
|
1372
|
+
(pedal ?? []).every(
|
|
1373
|
+
(event, index) =>
|
|
1374
|
+
event.tick === original[index]!.tick &&
|
|
1375
|
+
event.state === original[index]!.state,
|
|
1376
|
+
);
|
|
1377
|
+
if (same) return undefined;
|
|
1378
|
+
return {
|
|
1379
|
+
type: "updateTrack",
|
|
1380
|
+
trackId: options.trackId,
|
|
1381
|
+
patch: { pedal: pedal && pedal.length > 0 ? pedal : null },
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Whether `track` sounds in plain 12-TET (no tuning, or one equal to it). */
|
|
1386
|
+
function twelveTet(
|
|
1387
|
+
score: Readonly<{ tuning?: Tuning; key?: string | null }>,
|
|
1388
|
+
track: Readonly<{ tuning?: Tuning }> | undefined,
|
|
1389
|
+
): boolean {
|
|
1390
|
+
if (!score.tuning && !track?.tuning) return true;
|
|
1391
|
+
const table = resolveTuning(score.tuning, track?.tuning, score.key);
|
|
1392
|
+
if (!table) return true;
|
|
1393
|
+
if (table.size !== 12 || Math.abs(table.period - 1200) > 0.01) return false;
|
|
1394
|
+
for (let key = 1; key < 128; key += 1) {
|
|
1395
|
+
const [low, high] = [table.hz[key - 1]!, table.hz[key]!];
|
|
1396
|
+
if (low <= 0 || high <= 0) return false;
|
|
1397
|
+
if (Math.abs(1200 * Math.log2(high / low) - 100) > 0.5) return false;
|
|
1398
|
+
}
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|