@hraness/dawg 0.4.1 → 0.5.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 +70 -0
- package/DAWG.md +293 -31
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -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 +27 -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 +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -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/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -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/effects/bus.ts +5 -4
- 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/space.ts +92 -2
- package/src/audio/engine.ts +90 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -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 +74 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +301 -26
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +709 -33
- package/src/render.ts +109 -7
- 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/menu-time.ts +401 -0
- package/src/tui/menu.ts +635 -14
- 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 +414 -41
- 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,8 +20,33 @@ 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 {
|
|
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";
|
|
25
50
|
import {
|
|
26
51
|
LiveSynth,
|
|
27
52
|
MAX_LIVE_NOTE_SECONDS,
|
|
@@ -43,10 +68,12 @@ import {
|
|
|
43
68
|
type PlayedChord,
|
|
44
69
|
} from "./play-chords.ts";
|
|
45
70
|
import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
|
|
71
|
+
import { resolveTuning, type Tuning } from "../../core/tuning.ts";
|
|
46
72
|
import {
|
|
47
73
|
NOTE_KEYS,
|
|
48
74
|
PlayKeyboard,
|
|
49
75
|
REPEAT_DELAY_MS,
|
|
76
|
+
degreeLayout,
|
|
50
77
|
playLayoutFor,
|
|
51
78
|
stripCells,
|
|
52
79
|
type PlayAction,
|
|
@@ -75,6 +102,11 @@ export interface PlayHost {
|
|
|
75
102
|
playing(): boolean;
|
|
76
103
|
/** Transport beat (unwrapped) at monotonic `ms`. */
|
|
77
104
|
beatAt(ms: number): number;
|
|
105
|
+
/**
|
|
106
|
+
* The score beat transport `beat` plays: inside a looped section, or the
|
|
107
|
+
* matching bar of the form pass. Absent means the transport is the score.
|
|
108
|
+
*/
|
|
109
|
+
scoreBeat?(beat: number): number;
|
|
78
110
|
engine(): LiveEngine | undefined;
|
|
79
111
|
samples?(): SampleBank | undefined;
|
|
80
112
|
commit(
|
|
@@ -122,6 +154,12 @@ type Pending = {
|
|
|
122
154
|
/** Unwrapped transport beat of the press. */
|
|
123
155
|
beat: number;
|
|
124
156
|
releaseAtMs: number;
|
|
157
|
+
/**
|
|
158
|
+
* When the key itself let go. Differs from `releaseAtMs` only while the
|
|
159
|
+
* sustain pedal holds the note: the recorded length is the key's, and the
|
|
160
|
+
* recorded pedal events do the sustaining (as a MIDI CC64 recording does).
|
|
161
|
+
*/
|
|
162
|
+
keyUpMs: number;
|
|
125
163
|
/** Set when the key played a chord: laid out with `perform` on flush. */
|
|
126
164
|
chord?: RecordedChord;
|
|
127
165
|
};
|
|
@@ -157,7 +195,16 @@ const LIVE_PLAN_BEATS = 64;
|
|
|
157
195
|
/** Live chord voices use ids far above the keyboard's. */
|
|
158
196
|
const VOICE_ID_BASE = 1_000_000_000;
|
|
159
197
|
|
|
160
|
-
type CountIn = {
|
|
198
|
+
type CountIn = {
|
|
199
|
+
startMs: number;
|
|
200
|
+
beats: number;
|
|
201
|
+
startBeat: number;
|
|
202
|
+
/** Count-in tempo: the tempo where recording starts. */
|
|
203
|
+
bpm: number;
|
|
204
|
+
/** Beats per counted bar and per click (meter-aware). */
|
|
205
|
+
barBeats: number;
|
|
206
|
+
clickBeats: number;
|
|
207
|
+
};
|
|
161
208
|
|
|
162
209
|
export type PlayKeyResult =
|
|
163
210
|
| { type: "handled" }
|
|
@@ -174,13 +221,22 @@ export class PlaySession {
|
|
|
174
221
|
public countInBars = 1;
|
|
175
222
|
public grid = DEFAULT_GRID;
|
|
176
223
|
public status: string | undefined;
|
|
224
|
+
/** Scale-degree layout on (`i`); follows key and tuning changes. */
|
|
225
|
+
public degreesOn = false;
|
|
226
|
+
private degreeKey: string | undefined;
|
|
177
227
|
private active = false;
|
|
178
228
|
private synth: LiveSynth | undefined;
|
|
179
229
|
private readonly pending = new Map<number, Pending>();
|
|
230
|
+
/** Sustain changes while recording (Tab latch or Shift), as pedal events. */
|
|
231
|
+
private pendingPedal: { beat: number; state: PedalState }[] = [];
|
|
232
|
+
/** The pedal state last recorded this pass. */
|
|
233
|
+
private pedalDown = false;
|
|
180
234
|
/** Played note id → recorded score note id, for this pass. */
|
|
181
235
|
private readonly recordedIds = new Set<string>();
|
|
182
236
|
/** In-loop bars the playhead finished while replacing. */
|
|
183
237
|
private readonly replaceBars = new Set<number>();
|
|
238
|
+
/** Live pedal state as the playhead entered each loop bar (replace). */
|
|
239
|
+
private readonly barPedal = new Map<number, PedalState>();
|
|
184
240
|
private lastBar: number | undefined;
|
|
185
241
|
private countIn: CountIn | undefined;
|
|
186
242
|
private flushing: Promise<void> = Promise.resolve();
|
|
@@ -212,8 +268,15 @@ export class PlaySession {
|
|
|
212
268
|
this.layout = playLayoutFor(track);
|
|
213
269
|
const settings = options.chords ?? defaultChordSettings();
|
|
214
270
|
// Auto chords by default on chord-capable tracks, until chosen by hand.
|
|
271
|
+
// Diatonic triads assume 12-TET and a polyphonic part: a track in
|
|
272
|
+
// another tuning, or a mono/legato glide line, plays single notes.
|
|
215
273
|
if (!settings.explicit)
|
|
216
|
-
settings.mode =
|
|
274
|
+
settings.mode =
|
|
275
|
+
chordCapable(track) &&
|
|
276
|
+
twelveTet(this.host.score(), track) &&
|
|
277
|
+
(track?.glide === undefined || track.glide.mode === "poly")
|
|
278
|
+
? "auto"
|
|
279
|
+
: "manual";
|
|
217
280
|
this.chords = new ChordPad(settings, () => this.host.score().key);
|
|
218
281
|
this.keyboard = new PlayKeyboard({ base: this.layout.base });
|
|
219
282
|
this.clickOn = options.clickOn ?? false;
|
|
@@ -236,7 +299,17 @@ export class PlaySession {
|
|
|
236
299
|
|
|
237
300
|
/** Gate for a single press: one grid step at the current tempo. */
|
|
238
301
|
public gateMs(): number {
|
|
239
|
-
return (this.gridStep * 60_000) / this.
|
|
302
|
+
return (this.gridStep * 60_000) / this.currentBpm();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Tempo at the playhead (the song tempo without a tempo map). */
|
|
306
|
+
private currentBpm(): number {
|
|
307
|
+
const score = this.host.score();
|
|
308
|
+
if (!hasTempoMap(score)) return score.tempoBpm;
|
|
309
|
+
return bpmAtTick(
|
|
310
|
+
score,
|
|
311
|
+
loopTickAt(score, this.host.beatAt(this.host.now())),
|
|
312
|
+
);
|
|
240
313
|
}
|
|
241
314
|
|
|
242
315
|
public async enter(): Promise<void> {
|
|
@@ -265,11 +338,27 @@ export class PlaySession {
|
|
|
265
338
|
/** The click bus the engine mixes: count-in, then the transport. */
|
|
266
339
|
public clickBus(): ClickBus {
|
|
267
340
|
const score = this.host.score();
|
|
341
|
+
const count = this.countIn;
|
|
342
|
+
// Without meter changes the fixed beatsPerBar grid clicks as in 0.4.
|
|
343
|
+
let grid: ClickBus["grid"];
|
|
344
|
+
if (count && hasMeterChanges(score))
|
|
345
|
+
grid = (from, to) =>
|
|
346
|
+
countInClicks(
|
|
347
|
+
count.startBeat,
|
|
348
|
+
count.beats / count.barBeats,
|
|
349
|
+
count.barBeats,
|
|
350
|
+
count.clickBeats,
|
|
351
|
+
from,
|
|
352
|
+
to,
|
|
353
|
+
);
|
|
354
|
+
else if (!count && hasMeterChanges(score))
|
|
355
|
+
grid = meterClickGrid(() => this.host.score());
|
|
268
356
|
return {
|
|
269
357
|
volume: this.clickVolume,
|
|
270
358
|
beatsPerBar: score.beatsPerBar,
|
|
271
359
|
subdivision: 1,
|
|
272
360
|
beatAt: (ms) => this.clickBeatAt(ms),
|
|
361
|
+
...(grid ? { grid } : {}),
|
|
273
362
|
};
|
|
274
363
|
}
|
|
275
364
|
|
|
@@ -277,7 +366,7 @@ export class PlaySession {
|
|
|
277
366
|
public clickBeatAt(ms: number): number | undefined {
|
|
278
367
|
const count = this.countIn;
|
|
279
368
|
if (count) {
|
|
280
|
-
const beatMs = 60_000 /
|
|
369
|
+
const beatMs = 60_000 / count.bpm;
|
|
281
370
|
return count.startBeat - count.beats + (ms - count.startMs) / beatMs;
|
|
282
371
|
}
|
|
283
372
|
if (!this.clickOn || !this.host.playing()) return undefined;
|
|
@@ -320,6 +409,7 @@ export class PlaySession {
|
|
|
320
409
|
/** Handle one decoded key; mode commands return to the caller. */
|
|
321
410
|
public press(value: string): PlayKeyResult {
|
|
322
411
|
const now = this.host.now();
|
|
412
|
+
this.syncDegrees();
|
|
323
413
|
let key = value;
|
|
324
414
|
if (this.chords.on && value === "n") {
|
|
325
415
|
// Route the suggestion through the note key of its root, so holding
|
|
@@ -340,16 +430,21 @@ export class PlaySession {
|
|
|
340
430
|
}
|
|
341
431
|
}
|
|
342
432
|
const action = this.keyboard.press(key, now, this.gateMs());
|
|
343
|
-
|
|
433
|
+
const result = this.apply(action, now);
|
|
434
|
+
this.recordPedal(now);
|
|
435
|
+
return result;
|
|
344
436
|
}
|
|
345
437
|
|
|
346
438
|
/** The suggested next chord and the lowest note key on its root. */
|
|
347
439
|
private nextRoute(): { key: string; chord: PlayedChord["chord"] } {
|
|
348
440
|
const chord = this.chords.next();
|
|
349
|
-
const keys = Object.
|
|
350
|
-
|
|
351
|
-
(
|
|
352
|
-
|
|
441
|
+
const keys = Object.keys(NOTE_KEYS)
|
|
442
|
+
.map((key) => [key, this.keyboard.pitchFor(key)] as const)
|
|
443
|
+
.filter(
|
|
444
|
+
(entry): entry is readonly [string, number] => entry[1] !== undefined,
|
|
445
|
+
)
|
|
446
|
+
.sort((a, b) => a[1] - b[1]);
|
|
447
|
+
const found = keys.find(([, pitch]) => (pitch - chord.root) % 12 === 0);
|
|
353
448
|
return { key: found?.[0] ?? "a", chord };
|
|
354
449
|
}
|
|
355
450
|
|
|
@@ -402,7 +497,15 @@ export class PlaySession {
|
|
|
402
497
|
this.stopChord(action.absorbed);
|
|
403
498
|
this.pending.delete(action.absorbed);
|
|
404
499
|
}
|
|
405
|
-
if (pending)
|
|
500
|
+
if (pending) {
|
|
501
|
+
pending.releaseAtMs = action.releaseAtMs;
|
|
502
|
+
pending.keyUpMs = Math.max(
|
|
503
|
+
pending.keyUpMs,
|
|
504
|
+
pending.chord || Number.isFinite(action.releaseAtMs)
|
|
505
|
+
? action.releaseAtMs
|
|
506
|
+
: now + this.gateMs(),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
406
509
|
const live = this.liveChords.get(action.id);
|
|
407
510
|
if (live) {
|
|
408
511
|
live.releaseAtMs = action.releaseAtMs;
|
|
@@ -442,9 +545,47 @@ export class PlaySession {
|
|
|
442
545
|
this.status = this.clickCommand("toggle");
|
|
443
546
|
return { type: "handled" };
|
|
444
547
|
}
|
|
548
|
+
if (command === "degrees") {
|
|
549
|
+
this.status = this.toggleDegrees();
|
|
550
|
+
return { type: "handled" };
|
|
551
|
+
}
|
|
445
552
|
return { type: "command", command };
|
|
446
553
|
}
|
|
447
554
|
|
|
555
|
+
/**
|
|
556
|
+
* `i`: the home row plays scale degrees (the song key's scale, or every
|
|
557
|
+
* step of a non-12 tuning) instead of chromatic keys. Drum kits and
|
|
558
|
+
* sampler slots keep their pads.
|
|
559
|
+
*/
|
|
560
|
+
public toggleDegrees(on = !this.degreesOn): string {
|
|
561
|
+
if (on && this.layout.labels.size > 0)
|
|
562
|
+
return "scale degrees need a melodic track";
|
|
563
|
+
this.degreesOn = on;
|
|
564
|
+
this.degreeKey = undefined;
|
|
565
|
+
this.syncDegrees();
|
|
566
|
+
return on
|
|
567
|
+
? `scale degrees · ${this.keyboard.degrees!.name} · ${this.keyboard.range}`
|
|
568
|
+
: `chromatic · ${this.keyboard.range}`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Follows the song key and tuning while degree mode is on. */
|
|
572
|
+
private syncDegrees(): void {
|
|
573
|
+
if (!this.degreesOn) {
|
|
574
|
+
this.keyboard.degrees = undefined;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const score = this.host.score();
|
|
578
|
+
const track = this.trackData();
|
|
579
|
+
const signature = JSON.stringify([score.key, score.tuning, track?.tuning]);
|
|
580
|
+
if (signature === this.degreeKey && this.keyboard.degrees) return;
|
|
581
|
+
this.degreeKey = signature;
|
|
582
|
+
const table =
|
|
583
|
+
score.tuning || track?.tuning
|
|
584
|
+
? resolveTuning(score.tuning, track?.tuning, score.key)
|
|
585
|
+
: undefined;
|
|
586
|
+
this.keyboard.degrees = degreeLayout(score.key, table);
|
|
587
|
+
}
|
|
588
|
+
|
|
448
589
|
/** Render the note through the track's own voice and start it. */
|
|
449
590
|
private sound(id: number, note: PlayedNote): void {
|
|
450
591
|
const engine = this.host.engine();
|
|
@@ -455,13 +596,17 @@ export class PlaySession {
|
|
|
455
596
|
const samples = this.host.samples?.();
|
|
456
597
|
if (this.synth?.rate !== engine.sampleRate)
|
|
457
598
|
this.synth = new LiveSynth(engine.sampleRate);
|
|
599
|
+
const score = this.host.score();
|
|
458
600
|
const pcm = this.synth.render({
|
|
459
|
-
score
|
|
601
|
+
score,
|
|
460
602
|
trackId: this.trackId,
|
|
461
603
|
pitch: note.pitch,
|
|
462
604
|
velocity: note.velocity / 127,
|
|
463
605
|
seconds,
|
|
464
606
|
...(samples ? { samples } : {}),
|
|
607
|
+
...(score.time?.tempo
|
|
608
|
+
? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
|
|
609
|
+
: {}),
|
|
465
610
|
});
|
|
466
611
|
if (!pcm) return;
|
|
467
612
|
const scheduled = engine.noteOn(id, pcm);
|
|
@@ -475,7 +620,10 @@ export class PlaySession {
|
|
|
475
620
|
const live = this.liveChords.get(id);
|
|
476
621
|
if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
|
|
477
622
|
const pending = this.pending.get(id);
|
|
478
|
-
if (pending)
|
|
623
|
+
if (pending) {
|
|
624
|
+
pending.releaseAtMs = atMs;
|
|
625
|
+
pending.keyUpMs = Math.min(pending.keyUpMs, atMs);
|
|
626
|
+
}
|
|
479
627
|
}
|
|
480
628
|
}
|
|
481
629
|
|
|
@@ -492,10 +640,38 @@ export class PlaySession {
|
|
|
492
640
|
atMs: note.atMs,
|
|
493
641
|
beat: this.host.beatAt(note.atMs),
|
|
494
642
|
releaseAtMs: note.releaseAtMs,
|
|
643
|
+
// A chord press lays its voices out over the held length (arps keep
|
|
644
|
+
// stepping while the pedal holds), so it keeps that length.
|
|
645
|
+
keyUpMs:
|
|
646
|
+
chord || Number.isFinite(note.releaseAtMs)
|
|
647
|
+
? note.releaseAtMs
|
|
648
|
+
: note.atMs + this.gateMs(),
|
|
495
649
|
...(chord ? { chord } : {}),
|
|
496
650
|
});
|
|
497
651
|
}
|
|
498
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Record a sustain change (Tab latch, or Shift held on note keys) as a
|
|
655
|
+
* pedal event at the playhead, like Logic's Musical Typing records its Tab
|
|
656
|
+
* sustain key as CC64.
|
|
657
|
+
*/
|
|
658
|
+
private recordPedal(now: number): void {
|
|
659
|
+
// Chord presses record the held length on their voices instead.
|
|
660
|
+
const down =
|
|
661
|
+
this.keyboard.sustain &&
|
|
662
|
+
![...this.pending.values()].some((pending) => pending.chord);
|
|
663
|
+
if (!this.recording) {
|
|
664
|
+
if (!down) this.pedalDown = false;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (down === this.pedalDown) return;
|
|
668
|
+
this.pedalDown = down;
|
|
669
|
+
this.pendingPedal.push({
|
|
670
|
+
beat: this.host.beatAt(now),
|
|
671
|
+
state: down ? "down" : "up",
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
499
675
|
private recordedChord(
|
|
500
676
|
played: PlayedChord,
|
|
501
677
|
seed: number,
|
|
@@ -560,7 +736,7 @@ export class PlaySession {
|
|
|
560
736
|
}
|
|
561
737
|
|
|
562
738
|
private beatMs(): number {
|
|
563
|
-
return 60_000 / this.
|
|
739
|
+
return 60_000 / this.currentBpm();
|
|
564
740
|
}
|
|
565
741
|
|
|
566
742
|
/** Start due voices, end finished ones, drop released chords. */
|
|
@@ -639,17 +815,25 @@ export class PlaySession {
|
|
|
639
815
|
public startWithCountIn(): boolean {
|
|
640
816
|
if (!this.armed || this.host.playing() || this.countIn) return false;
|
|
641
817
|
const score = this.host.score();
|
|
642
|
-
const startBeat =
|
|
643
|
-
|
|
644
|
-
score.
|
|
818
|
+
const startBeat = transportBarStart(
|
|
819
|
+
score,
|
|
820
|
+
transportBar(score, this.host.beatAt(this.host.now())),
|
|
821
|
+
);
|
|
645
822
|
if (this.countInBars === 0) {
|
|
646
823
|
void this.host.startTransport(startBeat);
|
|
647
824
|
return true;
|
|
648
825
|
}
|
|
826
|
+
// Count in the meter and tempo of the bar recording starts in.
|
|
827
|
+
const at = barAt(score, loopTickAt(score, startBeat));
|
|
828
|
+
const tpb = score.ticksPerBeat;
|
|
829
|
+
const barBeats = at.barTicks / tpb;
|
|
649
830
|
this.countIn = {
|
|
650
831
|
startMs: this.host.now(),
|
|
651
|
-
beats: this.countInBars *
|
|
832
|
+
beats: this.countInBars * barBeats,
|
|
652
833
|
startBeat,
|
|
834
|
+
bpm: bpmAtTick(score, at.tick),
|
|
835
|
+
barBeats,
|
|
836
|
+
clickBeats: clickTicksOf(at, tpb) / tpb,
|
|
653
837
|
};
|
|
654
838
|
this.applyClick();
|
|
655
839
|
return true;
|
|
@@ -662,7 +846,7 @@ export class PlaySession {
|
|
|
662
846
|
this.pumpChords(now);
|
|
663
847
|
const count = this.countIn;
|
|
664
848
|
if (count) {
|
|
665
|
-
const beatMs = 60_000 /
|
|
849
|
+
const beatMs = 60_000 / count.bpm;
|
|
666
850
|
if (now - count.startMs >= count.beats * beatMs) {
|
|
667
851
|
this.countIn = undefined;
|
|
668
852
|
this.applyClick();
|
|
@@ -677,14 +861,28 @@ export class PlaySession {
|
|
|
677
861
|
return;
|
|
678
862
|
}
|
|
679
863
|
const score = this.host.score();
|
|
680
|
-
const bar =
|
|
864
|
+
const bar = transportBar(score, this.host.beatAt(now));
|
|
865
|
+
if (bar !== this.lastBar)
|
|
866
|
+
this.barPedal.set(this.loopBar(bar), this.pedalDown ? "down" : "up");
|
|
681
867
|
if (this.lastBar !== undefined && bar !== this.lastBar) {
|
|
682
|
-
if (this.replace)
|
|
868
|
+
if (this.replace)
|
|
869
|
+
this.replaceBars.add(
|
|
870
|
+
this.loopBar(
|
|
871
|
+
Math.floor(
|
|
872
|
+
this.toScoreBeat(this.lastBar * score.beatsPerBar) /
|
|
873
|
+
score.beatsPerBar,
|
|
874
|
+
),
|
|
875
|
+
),
|
|
876
|
+
);
|
|
683
877
|
this.queueFlush(bar, now, false);
|
|
684
878
|
}
|
|
685
879
|
this.lastBar = bar;
|
|
686
880
|
}
|
|
687
881
|
|
|
882
|
+
private toScoreBeat(beat: number): number {
|
|
883
|
+
return this.host.scoreBeat ? this.host.scoreBeat(beat) : beat;
|
|
884
|
+
}
|
|
885
|
+
|
|
688
886
|
private loopBar(bar: number): number {
|
|
689
887
|
const bars = this.host.score().bars;
|
|
690
888
|
return ((bar % bars) + bars) % bars;
|
|
@@ -693,6 +891,10 @@ export class PlaySession {
|
|
|
693
891
|
/** Commit what record armed collected; called when recording ends. */
|
|
694
892
|
public async stopRecording(): Promise<void> {
|
|
695
893
|
const now = this.host.now();
|
|
894
|
+
// A pedal still down when recording stops lifts here.
|
|
895
|
+
if (this.pedalDown && this.host.playing())
|
|
896
|
+
this.pendingPedal.push({ beat: this.host.beatAt(now), state: "up" });
|
|
897
|
+
this.pedalDown = false;
|
|
696
898
|
this.queueFlush(Number.POSITIVE_INFINITY, now, true);
|
|
697
899
|
await this.flushing;
|
|
698
900
|
this.recordedIds.clear();
|
|
@@ -722,24 +924,52 @@ export class PlaySession {
|
|
|
722
924
|
const score = this.host.score();
|
|
723
925
|
const ready: Pending[] = [];
|
|
724
926
|
for (const pending of this.pending.values()) {
|
|
725
|
-
const bar =
|
|
726
|
-
const released = pending.
|
|
927
|
+
const bar = transportBar(score, pending.beat);
|
|
928
|
+
const released = pending.keyUpMs <= now;
|
|
727
929
|
if (all || (bar < currentBar && released)) ready.push(pending);
|
|
728
930
|
}
|
|
729
931
|
const erase = [...this.replaceBars];
|
|
730
932
|
this.replaceBars.clear();
|
|
731
|
-
|
|
732
|
-
|
|
933
|
+
const pedal = this.pendingPedal.filter(
|
|
934
|
+
(event) => all || transportBar(score, event.beat) < currentBar,
|
|
935
|
+
);
|
|
936
|
+
this.pendingPedal = this.pendingPedal.filter(
|
|
937
|
+
(event) => !pedal.includes(event),
|
|
938
|
+
);
|
|
939
|
+
if (ready.length === 0 && erase.length === 0 && pedal.length === 0) return;
|
|
940
|
+
const eraseStates = new Map(
|
|
941
|
+
erase.map((bar) => [bar, this.barPedal.get(bar) ?? "up"] as const),
|
|
942
|
+
);
|
|
943
|
+
try {
|
|
944
|
+
await this.commitTake(score, ready, erase, eraseStates, pedal, now);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
// Nothing was committed: keep the take so the next flush retries it.
|
|
947
|
+
for (const pending of ready) this.pending.set(pending.id, pending);
|
|
948
|
+
for (const bar of erase) this.replaceBars.add(bar);
|
|
949
|
+
this.pendingPedal = [...pedal, ...this.pendingPedal];
|
|
950
|
+
throw error;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private async commitTake(
|
|
955
|
+
score: TrackScore,
|
|
956
|
+
ready: readonly Pending[],
|
|
957
|
+
erase: readonly number[],
|
|
958
|
+
eraseStates: ReadonlyMap<number, PedalState>,
|
|
959
|
+
pedal: readonly { beat: number; state: PedalState }[],
|
|
960
|
+
now: number,
|
|
961
|
+
): Promise<void> {
|
|
733
962
|
const operations = recordOperations(score, {
|
|
734
963
|
trackId: this.trackId,
|
|
735
964
|
notes: ready.flatMap((pending) => {
|
|
736
|
-
const beats =
|
|
737
|
-
(
|
|
738
|
-
|
|
739
|
-
|
|
965
|
+
const beats = hasTempoMap(score)
|
|
966
|
+
? this.host.beatAt(Math.min(pending.keyUpMs, now)) -
|
|
967
|
+
this.host.beatAt(pending.atMs)
|
|
968
|
+
: ((Math.min(pending.keyUpMs, now) - pending.atMs) * score.tempoBpm) /
|
|
969
|
+
60_000;
|
|
740
970
|
return pending.chord
|
|
741
971
|
? chordNotes(pending.chord, {
|
|
742
|
-
beat: pending.beat,
|
|
972
|
+
beat: this.toScoreBeat(pending.beat),
|
|
743
973
|
beats,
|
|
744
974
|
velocity: pending.velocity,
|
|
745
975
|
grid: this.gridStep,
|
|
@@ -748,7 +978,7 @@ export class PlaySession {
|
|
|
748
978
|
{
|
|
749
979
|
pitch: pending.pitch,
|
|
750
980
|
velocity: pending.velocity,
|
|
751
|
-
beat: pending.beat,
|
|
981
|
+
beat: this.toScoreBeat(pending.beat),
|
|
752
982
|
beats,
|
|
753
983
|
},
|
|
754
984
|
];
|
|
@@ -758,22 +988,37 @@ export class PlaySession {
|
|
|
758
988
|
keep: this.recordedIds,
|
|
759
989
|
newId: () => this.host.newNoteId(),
|
|
760
990
|
});
|
|
991
|
+
// A pedal lane that cannot take more events never costs the notes.
|
|
992
|
+
let pedalNote = "";
|
|
993
|
+
try {
|
|
994
|
+
const pedalOperation = recordPedalOperation(score, {
|
|
995
|
+
trackId: this.trackId,
|
|
996
|
+
events: pedal,
|
|
997
|
+
eraseBars: erase,
|
|
998
|
+
eraseStates,
|
|
999
|
+
});
|
|
1000
|
+
if (pedalOperation) operations.push(pedalOperation);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (!(error instanceof ExpressionValidationError)) throw error;
|
|
1003
|
+
pedalNote = ` · pedal not recorded (${error.message})`;
|
|
1004
|
+
}
|
|
761
1005
|
if (operations.length === 0) return;
|
|
762
1006
|
let next = score;
|
|
763
1007
|
for (const operation of operations)
|
|
764
1008
|
next = applyScoreOperation(next, operation);
|
|
765
|
-
for (const operation of operations)
|
|
766
|
-
if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
|
|
767
1009
|
const added = operations.filter((op) => op.type === "addNote").length;
|
|
768
|
-
const removed = operations.
|
|
1010
|
+
const removed = operations.filter((op) => op.type === "removeNote").length;
|
|
769
1011
|
await this.host.commit(next, "score.record", {
|
|
770
1012
|
trackId: this.trackId,
|
|
771
1013
|
operations,
|
|
772
1014
|
replace: this.replace,
|
|
773
1015
|
});
|
|
1016
|
+
for (const pending of ready) this.pending.delete(pending.id);
|
|
1017
|
+
for (const operation of operations)
|
|
1018
|
+
if (operation.type === "addNote") this.recordedIds.add(operation.note.id);
|
|
774
1019
|
this.host.card(
|
|
775
|
-
`recorded ${added} note${added === 1 ? "" : "s"}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}`,
|
|
776
|
-
"success",
|
|
1020
|
+
`recorded ${added} note${added === 1 ? "" : "s"}${pedal.length && !pedalNote ? ` · ${pedal.length} pedal` : ""}${removed ? ` · replaced ${removed}` : ""} · ${this.trackId}${pedalNote}`,
|
|
1021
|
+
pedalNote ? "warning" : "success",
|
|
777
1022
|
);
|
|
778
1023
|
}
|
|
779
1024
|
|
|
@@ -783,13 +1028,13 @@ export class PlaySession {
|
|
|
783
1028
|
const count = this.countIn;
|
|
784
1029
|
let beat: PlayHeaderView["beat"];
|
|
785
1030
|
let countIn: string | undefined;
|
|
786
|
-
const beatMs = 60_000 / score.tempoBpm;
|
|
1031
|
+
const beatMs = 60_000 / (count?.bpm ?? score.tempoBpm);
|
|
787
1032
|
const at = count
|
|
788
1033
|
? count.startBeat - count.beats + (now - count.startMs) / beatMs
|
|
789
1034
|
: this.host.playing()
|
|
790
1035
|
? this.host.beatAt(now)
|
|
791
1036
|
: undefined;
|
|
792
|
-
if (at !== undefined) {
|
|
1037
|
+
if (at !== undefined && (count || !hasMeterChanges(score))) {
|
|
793
1038
|
const whole = Math.floor(at);
|
|
794
1039
|
const inBar =
|
|
795
1040
|
((whole % score.beatsPerBar) + score.beatsPerBar) % score.beatsPerBar;
|
|
@@ -798,7 +1043,37 @@ export class PlaySession {
|
|
|
798
1043
|
of: score.beatsPerBar,
|
|
799
1044
|
flash: (at - whole) * beatMs < FLASH_MS,
|
|
800
1045
|
};
|
|
801
|
-
if (count)
|
|
1046
|
+
if (count) {
|
|
1047
|
+
const clicks = Math.round(count.barBeats / count.clickBeats);
|
|
1048
|
+
const step = Math.floor(
|
|
1049
|
+
(at - (count.startBeat - count.beats)) / count.clickBeats,
|
|
1050
|
+
);
|
|
1051
|
+
beat = {
|
|
1052
|
+
index: (((step % clicks) + clicks) % clicks) + 1,
|
|
1053
|
+
of: clicks,
|
|
1054
|
+
flash:
|
|
1055
|
+
((at - (count.startBeat - count.beats)) % count.clickBeats) *
|
|
1056
|
+
beatMs <
|
|
1057
|
+
FLASH_MS,
|
|
1058
|
+
};
|
|
1059
|
+
countIn = `count-in ${Math.ceil((count.startBeat - at) / count.clickBeats)}`;
|
|
1060
|
+
}
|
|
1061
|
+
} else if (at !== undefined) {
|
|
1062
|
+
// Meter changes: count the clicks of the bar the playhead is in.
|
|
1063
|
+
const tpb = score.ticksPerBeat;
|
|
1064
|
+
const position = barAt(score, loopTickAt(score, at));
|
|
1065
|
+
const click = clickTicksOf(position, tpb);
|
|
1066
|
+
const inBar = position.offset / click;
|
|
1067
|
+
const whole = Math.floor(inBar);
|
|
1068
|
+
beat = {
|
|
1069
|
+
index: whole + 1,
|
|
1070
|
+
of: Math.round(position.barTicks / click),
|
|
1071
|
+
flash:
|
|
1072
|
+
((inBar - whole) * click * 60_000) /
|
|
1073
|
+
tpb /
|
|
1074
|
+
bpmAtTick(score, position.tick + whole * click) <
|
|
1075
|
+
FLASH_MS,
|
|
1076
|
+
};
|
|
802
1077
|
}
|
|
803
1078
|
return {
|
|
804
1079
|
range: this.keyboard.range,
|
|
@@ -828,6 +1103,7 @@ export class PlaySession {
|
|
|
828
1103
|
}
|
|
829
1104
|
|
|
830
1105
|
public strip(now = this.host.now()): PlayStripKey[] {
|
|
1106
|
+
this.syncDegrees();
|
|
831
1107
|
return stripCells(
|
|
832
1108
|
this.keyboard,
|
|
833
1109
|
this.keyboard.litKeys(now),
|
|
@@ -929,14 +1205,16 @@ export function recordOperations(
|
|
|
929
1205
|
}>,
|
|
930
1206
|
): ScoreOperation[] {
|
|
931
1207
|
const operations: ScoreOperation[] = [];
|
|
932
|
-
const loopBeats = score.bars * score.beatsPerBar;
|
|
933
1208
|
const tpb = score.ticksPerBeat;
|
|
1209
|
+
const loopBeats = loopTicksOf(score) / tpb;
|
|
934
1210
|
const erase = new Set(options.eraseBars ?? []);
|
|
935
1211
|
if (erase.size > 0)
|
|
936
1212
|
for (const note of score.notes) {
|
|
937
1213
|
if (note.trackId !== options.trackId) continue;
|
|
938
1214
|
if (options.keep?.has(note.id)) continue;
|
|
939
|
-
const bar =
|
|
1215
|
+
const bar = hasMeterChanges(score)
|
|
1216
|
+
? barAt(score, note.startTick).bar
|
|
1217
|
+
: Math.floor(note.startTick / tpb / score.beatsPerBar);
|
|
940
1218
|
if (erase.has(bar))
|
|
941
1219
|
operations.push({ type: "removeNote", noteId: note.id });
|
|
942
1220
|
}
|
|
@@ -982,3 +1260,98 @@ export function recordOperations(
|
|
|
982
1260
|
}
|
|
983
1261
|
return operations;
|
|
984
1262
|
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* The `updateTrack` that merges recorded pedal events into the track,
|
|
1266
|
+
* wrapped into the loop (unquantized: pedalling is about the release), after
|
|
1267
|
+
* dropping existing events in `eraseBars` (replace). Undefined when nothing
|
|
1268
|
+
* changes.
|
|
1269
|
+
*/
|
|
1270
|
+
export function recordPedalOperation(
|
|
1271
|
+
score: TrackScore,
|
|
1272
|
+
options: Readonly<{
|
|
1273
|
+
trackId: string;
|
|
1274
|
+
events: readonly { beat: number; state: PedalState }[];
|
|
1275
|
+
eraseBars?: readonly number[];
|
|
1276
|
+
/** The pedal as the take entered each erased bar (default `up`). */
|
|
1277
|
+
eraseStates?: ReadonlyMap<number, PedalState>;
|
|
1278
|
+
}>,
|
|
1279
|
+
): ScoreOperation | undefined {
|
|
1280
|
+
const track = score.tracks.find((item) => item.id === options.trackId);
|
|
1281
|
+
if (!track) return undefined;
|
|
1282
|
+
const tpb = score.ticksPerBeat;
|
|
1283
|
+
// Bars follow the meter map; without meter changes this is the 0.4 grid.
|
|
1284
|
+
const maxTick = loopTicksOf(score);
|
|
1285
|
+
const original = track.pedal ?? [];
|
|
1286
|
+
const erase = new Set(options.eraseBars ?? []);
|
|
1287
|
+
const kept = original.filter(
|
|
1288
|
+
(event) => !erase.has(barAt(score, event.tick).bar),
|
|
1289
|
+
);
|
|
1290
|
+
if (options.events.length === 0 && erase.size === 0) return undefined;
|
|
1291
|
+
const byTick = new Map<number, PedalEvent>(
|
|
1292
|
+
kept.map((event) => [event.tick, event]),
|
|
1293
|
+
);
|
|
1294
|
+
// Replace: an erased bar starts in the state the take had there, and the
|
|
1295
|
+
// old pedal resumes after it, so no stale press is left without its lift.
|
|
1296
|
+
for (const bar of erase) {
|
|
1297
|
+
const start = barStartTick(score, bar);
|
|
1298
|
+
const end = barStartTick(score, bar + 1);
|
|
1299
|
+
byTick.set(start, {
|
|
1300
|
+
tick: start,
|
|
1301
|
+
state: options.eraseStates?.get(bar) ?? "up",
|
|
1302
|
+
});
|
|
1303
|
+
if (end < maxTick && !erase.has(bar + 1) && !byTick.has(end))
|
|
1304
|
+
byTick.set(end, { tick: end, state: pedalStateAt(original, end - 1) });
|
|
1305
|
+
}
|
|
1306
|
+
for (const event of options.events) {
|
|
1307
|
+
let tick = Math.round(event.beat * tpb) % maxTick;
|
|
1308
|
+
if (tick < 0) tick += maxTick;
|
|
1309
|
+
byTick.set(tick, { tick, state: event.state });
|
|
1310
|
+
}
|
|
1311
|
+
const sorted = [...byTick.values()].sort((a, b) => a.tick - b.tick);
|
|
1312
|
+
// A pedal held across the loop seam is down again from tick 0.
|
|
1313
|
+
const seam = sorted[sorted.length - 1]?.state ?? "up";
|
|
1314
|
+
if (options.events.length > 0 && seam !== "up" && sorted[0]?.tick !== 0)
|
|
1315
|
+
sorted.unshift({ tick: 0, state: seam });
|
|
1316
|
+
// Drop events that do not change the state (the lane has a size limit).
|
|
1317
|
+
const compact: PedalEvent[] = [];
|
|
1318
|
+
for (const event of sorted) {
|
|
1319
|
+
const before = compact[compact.length - 1]?.state ?? "up";
|
|
1320
|
+
if (event.state !== before) compact.push(event);
|
|
1321
|
+
}
|
|
1322
|
+
if (compact.length > EXPRESSION_LIMITS.maxPedalEvents)
|
|
1323
|
+
throw new ExpressionValidationError(
|
|
1324
|
+
`the pedal lane is full (${EXPRESSION_LIMITS.maxPedalEvents} events)`,
|
|
1325
|
+
);
|
|
1326
|
+
const pedal = normalizePedal(compact, maxTick);
|
|
1327
|
+
const same =
|
|
1328
|
+
(pedal?.length ?? 0) === original.length &&
|
|
1329
|
+
(pedal ?? []).every(
|
|
1330
|
+
(event, index) =>
|
|
1331
|
+
event.tick === original[index]!.tick &&
|
|
1332
|
+
event.state === original[index]!.state,
|
|
1333
|
+
);
|
|
1334
|
+
if (same) return undefined;
|
|
1335
|
+
return {
|
|
1336
|
+
type: "updateTrack",
|
|
1337
|
+
trackId: options.trackId,
|
|
1338
|
+
patch: { pedal: pedal && pedal.length > 0 ? pedal : null },
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** Whether `track` sounds in plain 12-TET (no tuning, or one equal to it). */
|
|
1343
|
+
function twelveTet(
|
|
1344
|
+
score: Readonly<{ tuning?: Tuning; key?: string | null }>,
|
|
1345
|
+
track: Readonly<{ tuning?: Tuning }> | undefined,
|
|
1346
|
+
): boolean {
|
|
1347
|
+
if (!score.tuning && !track?.tuning) return true;
|
|
1348
|
+
const table = resolveTuning(score.tuning, track?.tuning, score.key);
|
|
1349
|
+
if (!table) return true;
|
|
1350
|
+
if (table.size !== 12 || Math.abs(table.period - 1200) > 0.01) return false;
|
|
1351
|
+
for (let key = 1; key < 128; key += 1) {
|
|
1352
|
+
const [low, high] = [table.hz[key - 1]!, table.hz[key]!];
|
|
1353
|
+
if (low <= 0 || high <= 0) return false;
|
|
1354
|
+
if (Math.abs(1200 * Math.log2(high / low) - 100) > 0.5) return false;
|
|
1355
|
+
}
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|