@hraness/dawg 0.3.0 → 0.4.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 +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/src/tui/play-session.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* (`addNote`, plus `removeNote` when replacing), committed once per bar as
|
|
10
10
|
* the playhead leaves it: one undo entry per recorded bar, synced to other
|
|
11
11
|
* windows and the project files like any other edit.
|
|
12
|
+
*
|
|
13
|
+
* Chord mode (play-chords.ts) turns a note key into a voiced chord: every
|
|
14
|
+
* voice sounds through the same live path, strums and arpeggios are paced by
|
|
15
|
+
* `tick()`, and a recorded chord is laid out with core/chords.ts `perform`
|
|
16
|
+
* so the score holds exactly what an agent's `write_chords` would write.
|
|
12
17
|
*/
|
|
13
18
|
import {
|
|
14
19
|
applyScoreOperation,
|
|
@@ -23,9 +28,25 @@ import {
|
|
|
23
28
|
type LiveNotePcm,
|
|
24
29
|
} from "../audio/live.ts";
|
|
25
30
|
import type { SampleBank } from "../audio/samples.ts";
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_STRUM,
|
|
33
|
+
perform,
|
|
34
|
+
type PerformMode,
|
|
35
|
+
type PerformedNote,
|
|
36
|
+
} from "../../core/chords.ts";
|
|
37
|
+
import {
|
|
38
|
+
ChordPad,
|
|
39
|
+
chordCapable,
|
|
40
|
+
defaultChordSettings,
|
|
41
|
+
songKey,
|
|
42
|
+
type ChordSettings,
|
|
43
|
+
type PlayedChord,
|
|
44
|
+
} from "./play-chords.ts";
|
|
26
45
|
import type { PlayHeaderView, PlayStripKey } from "../../tui/play-strip.ts";
|
|
27
46
|
import {
|
|
47
|
+
NOTE_KEYS,
|
|
28
48
|
PlayKeyboard,
|
|
49
|
+
REPEAT_DELAY_MS,
|
|
29
50
|
playLayoutFor,
|
|
30
51
|
stripCells,
|
|
31
52
|
type PlayAction,
|
|
@@ -101,8 +122,41 @@ type Pending = {
|
|
|
101
122
|
/** Unwrapped transport beat of the press. */
|
|
102
123
|
beat: number;
|
|
103
124
|
releaseAtMs: number;
|
|
125
|
+
/** Set when the key played a chord: laid out with `perform` on flush. */
|
|
126
|
+
chord?: RecordedChord;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What a recorded chord press expands to. */
|
|
130
|
+
export type RecordedChord = Readonly<{
|
|
131
|
+
pitches: readonly number[];
|
|
132
|
+
bass?: number | undefined;
|
|
133
|
+
mode: PerformMode;
|
|
134
|
+
/** Arp step in beats. */
|
|
135
|
+
rate: number;
|
|
136
|
+
octaves: number;
|
|
137
|
+
seed: number;
|
|
138
|
+
/** CHORD_PATTERNS name when `mode` is `pattern`. */
|
|
139
|
+
pattern?: string;
|
|
140
|
+
}>;
|
|
141
|
+
|
|
142
|
+
/** A chord sounding live: its voices start and stop from `tick()`. */
|
|
143
|
+
type LiveChord = {
|
|
144
|
+
played: PlayedChord;
|
|
145
|
+
velocity: number;
|
|
146
|
+
startMs: number;
|
|
147
|
+
releaseAtMs: number;
|
|
148
|
+
/** Voices in beats from the press, sorted by start. */
|
|
149
|
+
plan: PerformedNote[];
|
|
150
|
+
next: number;
|
|
151
|
+
/** Live voice id → its plan index, start and own end (Infinity: the key's). */
|
|
152
|
+
sounding: Map<number, { index: number; atMs: number; endMs: number }>;
|
|
104
153
|
};
|
|
105
154
|
|
|
155
|
+
/** Beats a held live chord's plan covers (arps keep stepping this long). */
|
|
156
|
+
const LIVE_PLAN_BEATS = 64;
|
|
157
|
+
/** Live chord voices use ids far above the keyboard's. */
|
|
158
|
+
const VOICE_ID_BASE = 1_000_000_000;
|
|
159
|
+
|
|
106
160
|
type CountIn = { startMs: number; beats: number; startBeat: number };
|
|
107
161
|
|
|
108
162
|
export type PlayKeyResult =
|
|
@@ -133,6 +187,12 @@ export class PlaySession {
|
|
|
133
187
|
/** Most recent key-to-sound schedule, ms (lead before device latency). */
|
|
134
188
|
public lastLatencyMs: number | undefined;
|
|
135
189
|
private readonly trackId: string;
|
|
190
|
+
public readonly chords: ChordPad;
|
|
191
|
+
private readonly liveChords = new Map<number, LiveChord>();
|
|
192
|
+
private nextVoiceId = VOICE_ID_BASE;
|
|
193
|
+
/** The `n` key's last route, so its auto-repeats hold one chord. */
|
|
194
|
+
private nRoute:
|
|
195
|
+
{ key: string; chord: PlayedChord["chord"]; atMs: number } | undefined;
|
|
136
196
|
|
|
137
197
|
/** The track this session plays and records into. */
|
|
138
198
|
public get track(): string {
|
|
@@ -141,10 +201,20 @@ export class PlaySession {
|
|
|
141
201
|
|
|
142
202
|
public constructor(
|
|
143
203
|
private readonly host: PlayHost,
|
|
144
|
-
options: Readonly<{
|
|
204
|
+
options: Readonly<{
|
|
205
|
+
clickOn?: boolean;
|
|
206
|
+
clickVolume?: number;
|
|
207
|
+
chords?: ChordSettings;
|
|
208
|
+
}> = {},
|
|
145
209
|
) {
|
|
146
210
|
this.trackId = host.trackId();
|
|
147
|
-
|
|
211
|
+
const track = this.trackData();
|
|
212
|
+
this.layout = playLayoutFor(track);
|
|
213
|
+
const settings = options.chords ?? defaultChordSettings();
|
|
214
|
+
// Auto chords by default on chord-capable tracks, until chosen by hand.
|
|
215
|
+
if (!settings.explicit)
|
|
216
|
+
settings.mode = chordCapable(track) ? "auto" : "manual";
|
|
217
|
+
this.chords = new ChordPad(settings, () => this.host.score().key);
|
|
148
218
|
this.keyboard = new PlayKeyboard({ base: this.layout.base });
|
|
149
219
|
this.clickOn = options.clickOn ?? false;
|
|
150
220
|
this.clickVolume = options.clickVolume ?? DEFAULT_CLICK_VOLUME;
|
|
@@ -250,10 +320,39 @@ export class PlaySession {
|
|
|
250
320
|
/** Handle one decoded key; mode commands return to the caller. */
|
|
251
321
|
public press(value: string): PlayKeyResult {
|
|
252
322
|
const now = this.host.now();
|
|
253
|
-
|
|
323
|
+
let key = value;
|
|
324
|
+
if (this.chords.on && value === "n") {
|
|
325
|
+
// Route the suggestion through the note key of its root, so holding
|
|
326
|
+
// `n` sustains it like any held key.
|
|
327
|
+
const route =
|
|
328
|
+
this.nRoute && now - this.nRoute.atMs <= REPEAT_DELAY_MS
|
|
329
|
+
? this.nRoute
|
|
330
|
+
: { ...this.nextRoute(), atMs: now };
|
|
331
|
+
route.atMs = now;
|
|
332
|
+
this.nRoute = route;
|
|
333
|
+
this.chords.force(route.chord);
|
|
334
|
+
key = route.key;
|
|
335
|
+
} else {
|
|
336
|
+
const pad = this.chords.press(value);
|
|
337
|
+
if (pad.type === "status") {
|
|
338
|
+
this.status = pad.status;
|
|
339
|
+
return { type: "handled" };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const action = this.keyboard.press(key, now, this.gateMs());
|
|
254
343
|
return this.apply(action, now);
|
|
255
344
|
}
|
|
256
345
|
|
|
346
|
+
/** The suggested next chord and the lowest note key on its root. */
|
|
347
|
+
private nextRoute(): { key: string; chord: PlayedChord["chord"] } {
|
|
348
|
+
const chord = this.chords.next();
|
|
349
|
+
const keys = Object.entries(NOTE_KEYS).sort((a, b) => a[1] - b[1]);
|
|
350
|
+
const found = keys.find(
|
|
351
|
+
([, offset]) => (this.keyboard.base + offset - chord.root) % 12 === 0,
|
|
352
|
+
);
|
|
353
|
+
return { key: found?.[0] ?? "a", chord };
|
|
354
|
+
}
|
|
355
|
+
|
|
257
356
|
private apply(action: PlayAction, now: number): PlayKeyResult {
|
|
258
357
|
switch (action.type) {
|
|
259
358
|
case "unmapped":
|
|
@@ -272,18 +371,44 @@ export class PlaySession {
|
|
|
272
371
|
this.release(action.released, action.atMs);
|
|
273
372
|
this.status = action.on ? "sustain latched" : "sustain off";
|
|
274
373
|
return { type: "handled" };
|
|
275
|
-
case "note":
|
|
374
|
+
case "note": {
|
|
276
375
|
this.release(action.released, now);
|
|
376
|
+
const chord = this.chords.chordFor(action.note.pitch);
|
|
377
|
+
if (chord) {
|
|
378
|
+
const played = this.chords.voice(chord, action.note.pitch);
|
|
379
|
+
this.startChord(action.note, played);
|
|
380
|
+
this.record(action.note, this.recordedChord(played, action.note.id));
|
|
381
|
+
this.status = played.name;
|
|
382
|
+
return { type: "handled" };
|
|
383
|
+
}
|
|
384
|
+
// Bass modes that sound bass under single notes (unison, single, solo).
|
|
385
|
+
const single = this.chords.single(action.note.pitch);
|
|
386
|
+
if (single) {
|
|
387
|
+
this.startChord(action.note, single, "block");
|
|
388
|
+
this.record(
|
|
389
|
+
action.note,
|
|
390
|
+
this.recordedChord(single, action.note.id, "block"),
|
|
391
|
+
);
|
|
392
|
+
return { type: "handled" };
|
|
393
|
+
}
|
|
277
394
|
this.sound(action.note.id, action.note);
|
|
278
395
|
this.record(action.note);
|
|
279
396
|
return { type: "handled" };
|
|
397
|
+
}
|
|
280
398
|
case "extend": {
|
|
281
399
|
const pending = this.pending.get(action.id);
|
|
282
400
|
if (action.absorbed !== undefined) {
|
|
283
401
|
this.host.engine()?.noteOff(action.absorbed);
|
|
402
|
+
this.stopChord(action.absorbed);
|
|
284
403
|
this.pending.delete(action.absorbed);
|
|
285
404
|
}
|
|
286
405
|
if (pending) pending.releaseAtMs = action.releaseAtMs;
|
|
406
|
+
const live = this.liveChords.get(action.id);
|
|
407
|
+
if (live) {
|
|
408
|
+
live.releaseAtMs = action.releaseAtMs;
|
|
409
|
+
this.resoundChord(live);
|
|
410
|
+
return { type: "handled" };
|
|
411
|
+
}
|
|
287
412
|
const pitch = this.keyboard.pitchFor(action.key);
|
|
288
413
|
if (pitch !== undefined)
|
|
289
414
|
this.sound(action.id, {
|
|
@@ -347,6 +472,8 @@ export class PlaySession {
|
|
|
347
472
|
const engine = this.host.engine();
|
|
348
473
|
for (const id of ids) {
|
|
349
474
|
engine?.noteOff(id);
|
|
475
|
+
const live = this.liveChords.get(id);
|
|
476
|
+
if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
|
|
350
477
|
const pending = this.pending.get(id);
|
|
351
478
|
if (pending) pending.releaseAtMs = atMs;
|
|
352
479
|
}
|
|
@@ -356,7 +483,7 @@ export class PlaySession {
|
|
|
356
483
|
return this.armed && this.host.playing() && this.countIn === undefined;
|
|
357
484
|
}
|
|
358
485
|
|
|
359
|
-
private record(note: PlayedNote): void {
|
|
486
|
+
private record(note: PlayedNote, chord?: RecordedChord): void {
|
|
360
487
|
if (!this.recording) return;
|
|
361
488
|
this.pending.set(note.id, {
|
|
362
489
|
id: note.id,
|
|
@@ -365,6 +492,142 @@ export class PlaySession {
|
|
|
365
492
|
atMs: note.atMs,
|
|
366
493
|
beat: this.host.beatAt(note.atMs),
|
|
367
494
|
releaseAtMs: note.releaseAtMs,
|
|
495
|
+
...(chord ? { chord } : {}),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private recordedChord(
|
|
500
|
+
played: PlayedChord,
|
|
501
|
+
seed: number,
|
|
502
|
+
mode: PerformMode = this.chords.settings.perform,
|
|
503
|
+
): RecordedChord {
|
|
504
|
+
const settings = this.chords.settings;
|
|
505
|
+
return {
|
|
506
|
+
pitches: played.pitches,
|
|
507
|
+
bass: played.bass,
|
|
508
|
+
mode,
|
|
509
|
+
rate: this.chords.rateBeats(this.gridStep),
|
|
510
|
+
octaves: settings.octaves,
|
|
511
|
+
seed,
|
|
512
|
+
...(mode === "pattern" ? { pattern: settings.pattern } : {}),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── live chords ──────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
private startChord(
|
|
519
|
+
note: PlayedNote,
|
|
520
|
+
played: PlayedChord,
|
|
521
|
+
mode: PerformMode = this.chords.settings.perform,
|
|
522
|
+
): void {
|
|
523
|
+
const settings = this.chords.settings;
|
|
524
|
+
// Velocity 1: pattern hits carry their own accents, scaled by the press.
|
|
525
|
+
const plan = perform(played.pitches, 0, LIVE_PLAN_BEATS, {
|
|
526
|
+
mode,
|
|
527
|
+
rate: this.chords.rateBeats(this.gridStep),
|
|
528
|
+
octaves: settings.octaves,
|
|
529
|
+
strum: mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
|
|
530
|
+
seed: note.id,
|
|
531
|
+
pattern: settings.pattern,
|
|
532
|
+
velocity: 1,
|
|
533
|
+
});
|
|
534
|
+
if (played.bass !== undefined)
|
|
535
|
+
plan.push({
|
|
536
|
+
pitch: played.bass,
|
|
537
|
+
start: 0,
|
|
538
|
+
length: LIVE_PLAN_BEATS,
|
|
539
|
+
velocity: 1,
|
|
540
|
+
});
|
|
541
|
+
plan.sort((a, b) => a.start - b.start);
|
|
542
|
+
this.liveChords.set(note.id, {
|
|
543
|
+
played,
|
|
544
|
+
velocity: note.velocity,
|
|
545
|
+
startMs: note.atMs,
|
|
546
|
+
releaseAtMs: note.releaseAtMs,
|
|
547
|
+
plan,
|
|
548
|
+
next: 0,
|
|
549
|
+
sounding: new Map(),
|
|
550
|
+
});
|
|
551
|
+
this.pumpChords(this.host.now());
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private stopChord(id: number): void {
|
|
555
|
+
const live = this.liveChords.get(id);
|
|
556
|
+
if (!live) return;
|
|
557
|
+
const engine = this.host.engine();
|
|
558
|
+
for (const voice of live.sounding.keys()) engine?.noteOff(voice);
|
|
559
|
+
this.liveChords.delete(id);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private beatMs(): number {
|
|
563
|
+
return 60_000 / this.host.score().tempoBpm;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Start due voices, end finished ones, drop released chords. */
|
|
567
|
+
private pumpChords(now: number): void {
|
|
568
|
+
if (this.liveChords.size === 0) return;
|
|
569
|
+
const engine = this.host.engine();
|
|
570
|
+
const beatMs = this.beatMs();
|
|
571
|
+
for (const [id, live] of this.liveChords) {
|
|
572
|
+
for (const [voice, state] of live.sounding)
|
|
573
|
+
if (state.endMs <= now || live.releaseAtMs <= now) {
|
|
574
|
+
engine?.noteOff(voice);
|
|
575
|
+
live.sounding.delete(voice);
|
|
576
|
+
}
|
|
577
|
+
while (live.next < live.plan.length) {
|
|
578
|
+
const planned = live.plan[live.next]!;
|
|
579
|
+
const atMs = live.startMs + planned.start * beatMs;
|
|
580
|
+
if (atMs > now || atMs >= live.releaseAtMs) break;
|
|
581
|
+
live.next += 1;
|
|
582
|
+
const endMs = live.startMs + (planned.start + planned.length) * beatMs;
|
|
583
|
+
// Notes that ring to the end of the plan follow the key instead.
|
|
584
|
+
const own = planned.start + planned.length < LIVE_PLAN_BEATS - 1e-6;
|
|
585
|
+
const voice = this.nextVoiceId++;
|
|
586
|
+
live.sounding.set(voice, {
|
|
587
|
+
index: live.next - 1,
|
|
588
|
+
atMs,
|
|
589
|
+
endMs: own ? endMs : Infinity,
|
|
590
|
+
});
|
|
591
|
+
this.soundVoice(
|
|
592
|
+
voice,
|
|
593
|
+
live,
|
|
594
|
+
planned,
|
|
595
|
+
atMs,
|
|
596
|
+
own ? endMs : live.releaseAtMs,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
const exhausted =
|
|
600
|
+
live.next >= live.plan.length ||
|
|
601
|
+
live.startMs + live.plan[live.next]!.start * beatMs >= live.releaseAtMs;
|
|
602
|
+
if (live.releaseAtMs <= now || (exhausted && live.sounding.size === 0))
|
|
603
|
+
this.stopChord(id);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** A held chord's release moved: re-render the voices that follow it. */
|
|
608
|
+
private resoundChord(live: LiveChord): void {
|
|
609
|
+
for (const [voice, state] of live.sounding)
|
|
610
|
+
if (!Number.isFinite(state.endMs)) {
|
|
611
|
+
const planned = live.plan[state.index]!;
|
|
612
|
+
this.soundVoice(voice, live, planned, state.atMs, live.releaseAtMs);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private soundVoice(
|
|
617
|
+
voice: number,
|
|
618
|
+
live: LiveChord,
|
|
619
|
+
planned: Readonly<{ pitch: number; velocity: number }>,
|
|
620
|
+
atMs: number,
|
|
621
|
+
endMs: number,
|
|
622
|
+
): void {
|
|
623
|
+
this.sound(voice, {
|
|
624
|
+
id: voice,
|
|
625
|
+
key: "",
|
|
626
|
+
pitch: planned.pitch,
|
|
627
|
+
velocity: scaleVelocity(live.velocity, planned.velocity),
|
|
628
|
+
atMs,
|
|
629
|
+
releaseAtMs: endMs,
|
|
630
|
+
sustain: !Number.isFinite(endMs),
|
|
368
631
|
});
|
|
369
632
|
}
|
|
370
633
|
|
|
@@ -396,6 +659,7 @@ export class PlaySession {
|
|
|
396
659
|
public tick(): void {
|
|
397
660
|
if (!this.active) return;
|
|
398
661
|
const now = this.host.now();
|
|
662
|
+
this.pumpChords(now);
|
|
399
663
|
const count = this.countIn;
|
|
400
664
|
if (count) {
|
|
401
665
|
const beatMs = 60_000 / this.host.score().tempoBpm;
|
|
@@ -468,15 +732,27 @@ export class PlaySession {
|
|
|
468
732
|
for (const pending of ready) this.pending.delete(pending.id);
|
|
469
733
|
const operations = recordOperations(score, {
|
|
470
734
|
trackId: this.trackId,
|
|
471
|
-
notes: ready.
|
|
472
|
-
|
|
473
|
-
velocity: pending.velocity,
|
|
474
|
-
beat: pending.beat,
|
|
475
|
-
beats:
|
|
735
|
+
notes: ready.flatMap((pending) => {
|
|
736
|
+
const beats =
|
|
476
737
|
((Math.min(pending.releaseAtMs, now) - pending.atMs) *
|
|
477
738
|
score.tempoBpm) /
|
|
478
|
-
60_000
|
|
479
|
-
|
|
739
|
+
60_000;
|
|
740
|
+
return pending.chord
|
|
741
|
+
? chordNotes(pending.chord, {
|
|
742
|
+
beat: pending.beat,
|
|
743
|
+
beats,
|
|
744
|
+
velocity: pending.velocity,
|
|
745
|
+
grid: this.gridStep,
|
|
746
|
+
})
|
|
747
|
+
: [
|
|
748
|
+
{
|
|
749
|
+
pitch: pending.pitch,
|
|
750
|
+
velocity: pending.velocity,
|
|
751
|
+
beat: pending.beat,
|
|
752
|
+
beats,
|
|
753
|
+
},
|
|
754
|
+
];
|
|
755
|
+
}),
|
|
480
756
|
grid: this.gridStep,
|
|
481
757
|
eraseBars: erase,
|
|
482
758
|
keep: this.recordedIds,
|
|
@@ -535,17 +811,37 @@ export class PlaySession {
|
|
|
535
811
|
countIn,
|
|
536
812
|
beat,
|
|
537
813
|
grid: `grid ${this.grid}`,
|
|
814
|
+
chords: this.chords.glance(songKey(this.host.score().key).set),
|
|
538
815
|
status: this.status,
|
|
539
816
|
keys: this.strip(now),
|
|
817
|
+
legend: this.chords.on ? this.chords.legend() : undefined,
|
|
540
818
|
};
|
|
541
819
|
}
|
|
542
820
|
|
|
821
|
+
/** Secondary state for the `?` panel (kept out of the header). */
|
|
822
|
+
public details(): string[] {
|
|
823
|
+
const keySet = songKey(this.host.score().key).set;
|
|
824
|
+
return [
|
|
825
|
+
`velocity ${this.keyboard.velocity} · grid ${this.grid} · click ${this.clickOn ? "on" : "off"} · count-in ${this.countInBars} bar${this.countInBars === 1 ? "" : "s"}`,
|
|
826
|
+
...(this.chords.on ? [`chords ${this.chords.headerText(keySet)}`] : []),
|
|
827
|
+
];
|
|
828
|
+
}
|
|
829
|
+
|
|
543
830
|
public strip(now = this.host.now()): PlayStripKey[] {
|
|
544
831
|
return stripCells(
|
|
545
832
|
this.keyboard,
|
|
546
833
|
this.keyboard.litKeys(now),
|
|
547
834
|
this.layout.labels,
|
|
548
|
-
).map((cell) =>
|
|
835
|
+
).map((cell) => {
|
|
836
|
+
const chord = this.keyboard.pitchFor(cell.key);
|
|
837
|
+
const name =
|
|
838
|
+
chord === undefined ? undefined : this.chords.keyLabel(chord);
|
|
839
|
+
return name ? { ...cell, label: name } : this.noteCell(cell);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private noteCell(cell: PlayStripKey): PlayStripKey {
|
|
844
|
+
return {
|
|
549
845
|
...cell,
|
|
550
846
|
// Octave digits only on C keep 18 keys inside 80 columns.
|
|
551
847
|
label:
|
|
@@ -553,7 +849,7 @@ export class PlaySession {
|
|
|
553
849
|
(cell.label.startsWith("C") && !cell.label.startsWith("C#"))
|
|
554
850
|
? cell.label
|
|
555
851
|
: cell.label.replace(/-?\d+$/, ""),
|
|
556
|
-
}
|
|
852
|
+
};
|
|
557
853
|
}
|
|
558
854
|
}
|
|
559
855
|
|
|
@@ -565,8 +861,57 @@ export type RecordedNote = Readonly<{
|
|
|
565
861
|
beat: number;
|
|
566
862
|
/** Held length in beats. */
|
|
567
863
|
beats: number;
|
|
864
|
+
/** Already on the grid (a laid-out chord voice): keep beat and length. */
|
|
865
|
+
exact?: boolean;
|
|
568
866
|
}>;
|
|
569
867
|
|
|
868
|
+
/** A press velocity (1..127) times a pattern accent (0..1), at least 1. */
|
|
869
|
+
function scaleVelocity(velocity: number, accent: number): number {
|
|
870
|
+
return accent >= 1 ? velocity : Math.max(1, Math.round(velocity * accent));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* A recorded chord press as notes: the press snaps to `grid`, its held
|
|
875
|
+
* length rounds to whole grid steps (at least one), and `perform` lays the
|
|
876
|
+
* voices out inside it; the bass holds the whole length.
|
|
877
|
+
*/
|
|
878
|
+
export function chordNotes(
|
|
879
|
+
chord: RecordedChord,
|
|
880
|
+
press: Readonly<{
|
|
881
|
+
beat: number;
|
|
882
|
+
beats: number;
|
|
883
|
+
velocity: number;
|
|
884
|
+
grid: number;
|
|
885
|
+
}>,
|
|
886
|
+
): RecordedNote[] {
|
|
887
|
+
const start = quantize(press.beat, press.grid);
|
|
888
|
+
const length = Math.max(press.grid, quantize(press.beats, press.grid));
|
|
889
|
+
const notes = perform(chord.pitches, start, length, {
|
|
890
|
+
mode: chord.mode,
|
|
891
|
+
rate: chord.rate,
|
|
892
|
+
octaves: chord.octaves,
|
|
893
|
+
strum: chord.mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
|
|
894
|
+
seed: chord.seed,
|
|
895
|
+
...(chord.pattern !== undefined ? { pattern: chord.pattern } : {}),
|
|
896
|
+
velocity: 1,
|
|
897
|
+
}).map((note) => ({
|
|
898
|
+
pitch: note.pitch,
|
|
899
|
+
velocity: scaleVelocity(press.velocity, note.velocity),
|
|
900
|
+
beat: note.start,
|
|
901
|
+
beats: note.length,
|
|
902
|
+
exact: true,
|
|
903
|
+
}));
|
|
904
|
+
if (chord.bass !== undefined)
|
|
905
|
+
notes.push({
|
|
906
|
+
pitch: chord.bass,
|
|
907
|
+
velocity: press.velocity,
|
|
908
|
+
beat: start,
|
|
909
|
+
beats: length,
|
|
910
|
+
exact: true,
|
|
911
|
+
});
|
|
912
|
+
return notes;
|
|
913
|
+
}
|
|
914
|
+
|
|
570
915
|
/**
|
|
571
916
|
* The ScoreOperations one recorded bar commits: notes quantized to `grid`
|
|
572
917
|
* and wrapped into the loop (overdub), plus removals of existing notes that
|
|
@@ -608,9 +953,12 @@ export function recordOperations(
|
|
|
608
953
|
.map((note) => `${note.pitch}:${note.startTick}`),
|
|
609
954
|
);
|
|
610
955
|
for (const note of options.notes) {
|
|
611
|
-
let start =
|
|
956
|
+
let start =
|
|
957
|
+
(note.exact ? note.beat : quantize(note.beat, options.grid)) % loopBeats;
|
|
612
958
|
if (start < 0) start += loopBeats;
|
|
613
|
-
const beats =
|
|
959
|
+
const beats = note.exact
|
|
960
|
+
? note.beats
|
|
961
|
+
: Math.max(options.grid, quantize(note.beats, options.grid));
|
|
614
962
|
const startTick = Math.round(start * tpb);
|
|
615
963
|
const durationTicks = Math.max(
|
|
616
964
|
1,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line response sketches for the focused menu row: a filter curve for
|
|
3
|
+
* a cutoff, the envelope shape for attack/decay/sustain/release, and the
|
|
4
|
+
* position inside a wavetable. Sixteen cells, so they sit beside the row's
|
|
5
|
+
* note at 80 columns. Pure and cheap: they run once per menu view.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CELLS = 16;
|
|
9
|
+
const LEVELS = "▁▂▃▄▅▆▇█";
|
|
10
|
+
|
|
11
|
+
/** The parameter a menu command sets: `fx filter cutoff 800` → cutoff. */
|
|
12
|
+
export function commandParam(command: string): string | undefined {
|
|
13
|
+
const words = command.trim().split(/\s+/);
|
|
14
|
+
if (words.length < 2) return undefined;
|
|
15
|
+
const key = words.at(-2)!.toLowerCase();
|
|
16
|
+
return /^[a-z][a-z0-9]*$/.test(key) ? key : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Low- or high-pass response over 20 Hz..20 kHz on a log axis. */
|
|
20
|
+
export function filterSketch(hz: number, highpass = false): string {
|
|
21
|
+
const at = Math.round(
|
|
22
|
+
((CELLS - 1) * Math.log(Math.max(20, Math.min(20_000, hz)) / 20)) /
|
|
23
|
+
Math.log(1000),
|
|
24
|
+
);
|
|
25
|
+
let out = "";
|
|
26
|
+
for (let cell = 0; cell < CELLS; cell += 1) {
|
|
27
|
+
const past = highpass ? at - cell : cell - at;
|
|
28
|
+
out += past < 0 ? "▇" : past === 0 ? "▅" : past === 1 ? "▂" : "▁";
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** ADSR amplitude over time; sustain is held for a quarter of the rest. */
|
|
34
|
+
export function envelopeSketch(
|
|
35
|
+
attack: number,
|
|
36
|
+
decay: number,
|
|
37
|
+
sustain: number,
|
|
38
|
+
release: number,
|
|
39
|
+
): string {
|
|
40
|
+
const a = Math.max(0, attack);
|
|
41
|
+
const d = Math.max(0, decay);
|
|
42
|
+
const r = Math.max(0, release);
|
|
43
|
+
const s = Math.max(0, Math.min(1, sustain));
|
|
44
|
+
const hold = Math.max(0.05, 0.25 * (a + d + r));
|
|
45
|
+
const total = a + d + hold + r;
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let cell = 0; cell < CELLS; cell += 1) {
|
|
48
|
+
const t = ((cell + 0.5) / CELLS) * total;
|
|
49
|
+
const amp =
|
|
50
|
+
t < a
|
|
51
|
+
? t / a
|
|
52
|
+
: t < a + d
|
|
53
|
+
? 1 - ((t - a) / d) * (1 - s)
|
|
54
|
+
: t < a + d + hold
|
|
55
|
+
? s
|
|
56
|
+
: r > 0
|
|
57
|
+
? s * (1 - (t - a - d - hold) / r)
|
|
58
|
+
: 0;
|
|
59
|
+
out += LEVELS[Math.round(Math.max(0, Math.min(1, amp)) * 7)]!;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A marker at `value` in min..max. */
|
|
65
|
+
export function positionSketch(value: number, min = 0, max = 1): string {
|
|
66
|
+
const span = max - min || 1;
|
|
67
|
+
const at = Math.round(
|
|
68
|
+
(CELLS - 1) * Math.max(0, Math.min(1, (value - min) / span)),
|
|
69
|
+
);
|
|
70
|
+
return "─".repeat(at) + "●" + "─".repeat(CELLS - 1 - at);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ENVELOPE = new Set(["attack", "decay", "sustain", "release"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The sketch for a focused number row, or undefined when it has none.
|
|
77
|
+
* `valueOf` reads sibling rows by parameter (the other envelope stages);
|
|
78
|
+
* `filterType` is the filter's type row (a band-pass draws no sketch).
|
|
79
|
+
*/
|
|
80
|
+
export function sketchFor(
|
|
81
|
+
command: string,
|
|
82
|
+
value: number,
|
|
83
|
+
valueOf: (param: string) => number | undefined,
|
|
84
|
+
filterType = "lpf",
|
|
85
|
+
): string | undefined {
|
|
86
|
+
const param = commandParam(command);
|
|
87
|
+
if (!param) return undefined;
|
|
88
|
+
if (param === "cutoff")
|
|
89
|
+
return filterType === "hpf"
|
|
90
|
+
? filterSketch(value, true)
|
|
91
|
+
: filterType === "lpf"
|
|
92
|
+
? filterSketch(value)
|
|
93
|
+
: undefined;
|
|
94
|
+
if (param === "lpf") return filterSketch(value);
|
|
95
|
+
if (param === "hpf") return filterSketch(value, true);
|
|
96
|
+
if (param === "wt") return positionSketch(value);
|
|
97
|
+
if (ENVELOPE.has(param)) {
|
|
98
|
+
const stage = (name: string, fallback: number) =>
|
|
99
|
+
name === param ? value : (valueOf(name) ?? fallback);
|
|
100
|
+
return envelopeSketch(
|
|
101
|
+
stage("attack", 0.01),
|
|
102
|
+
stage("decay", 0.1),
|
|
103
|
+
stage("sustain", 0.7),
|
|
104
|
+
stage("release", 0.2),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
package/src/web/search.ts
CHANGED
|
@@ -400,7 +400,10 @@ async function gatewaySearch(
|
|
|
400
400
|
options.onSpend?.({
|
|
401
401
|
provider: "gateway",
|
|
402
402
|
tool,
|
|
403
|
-
|
|
403
|
+
// The gateway's reported cost already includes the search tool fee
|
|
404
|
+
// (captured 2026-10-07: cost 0.013273 = inference 0.006273 + exa 0.007),
|
|
405
|
+
// so the fee is only an estimate when no cost is reported.
|
|
406
|
+
usd: round(reportedCost(json) || SEARCH_TOOL_USD[tool]),
|
|
404
407
|
});
|
|
405
408
|
return { provider: "gateway", tool, results };
|
|
406
409
|
}
|