@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -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<{ clickOn?: boolean; clickVolume?: number }> = {},
204
+ options: Readonly<{
205
+ clickOn?: boolean;
206
+ clickVolume?: number;
207
+ chords?: ChordSettings;
208
+ }> = {},
145
209
  ) {
146
210
  this.trackId = host.trackId();
147
- this.layout = playLayoutFor(this.trackData());
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
- const action = this.keyboard.press(value, now, this.gateMs());
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.map((pending) => ({
472
- pitch: pending.pitch,
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 = quantize(note.beat, options.grid) % loopBeats;
956
+ let start =
957
+ (note.exact ? note.beat : quantize(note.beat, options.grid)) % loopBeats;
612
958
  if (start < 0) start += loopBeats;
613
- const beats = Math.max(options.grid, quantize(note.beats, options.grid));
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
- usd: round(SEARCH_TOOL_USD[tool] + reportedCost(json)),
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
  }