@hraness/dawg 0.6.1 → 0.7.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.
Files changed (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Highway clip-row snapshots (0.7 clips lane): each clip on the focused
3
+ * track as song beats on the tempo map, its waveform peaks over the clip's
4
+ * window and whether it continues another clip (a comp seam).
5
+ *
6
+ * Peaks (`MEDIA_LIMITS.waveformBuckets` per file) come from a per-file cache filled off the frame path: the first
7
+ * frame shows flat blocks, and `loadClipPeaks` fills the cache and asks for
8
+ * a redraw.
9
+ */
10
+ import { readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { clipEndTick, clipLength, clipSongTick } from "../core/clips.ts";
13
+ import type { TrackScore, Track } from "../core/score.ts";
14
+ import { SCORE_LIMITS } from "../core/score.ts";
15
+ import { waveformPeaks } from "../src/media/dsp.ts";
16
+ import { parseWav } from "../src/media/vendor/wav.ts";
17
+ import type { ClipSnapshot } from "./highway.ts";
18
+
19
+ type FilePeaks = Readonly<{ seconds: number; peaks: readonly number[] }>;
20
+
21
+ const cache = new Map<string, FilePeaks | null>();
22
+ const pending = new Set<string>();
23
+
24
+ /** Cache key: the clip's pin, or its path when not hashed yet. */
25
+ function keyOf(src: string, sha256: string | undefined): string {
26
+ return sha256 ?? `path:${src}`;
27
+ }
28
+
29
+ /** Cached peaks for a file, without reading it. */
30
+ export function cachedPeaks(
31
+ src: string,
32
+ sha256?: string,
33
+ ): FilePeaks | null | undefined {
34
+ return cache.get(keyOf(src, sha256));
35
+ }
36
+
37
+ /**
38
+ * Read and analyse every clip file of `track` not cached yet. Resolves true
39
+ * when anything new was cached (the caller redraws).
40
+ */
41
+ export async function loadClipPeaks(
42
+ root: string,
43
+ track: Track | undefined,
44
+ ): Promise<boolean> {
45
+ let changed = false;
46
+ for (const clip of track?.clips ?? []) {
47
+ const key = keyOf(clip.src, clip.sha256);
48
+ if (cache.has(key) || pending.has(key) || clip.src.startsWith("pack:"))
49
+ continue;
50
+ pending.add(key);
51
+ try {
52
+ const wav = parseWav(
53
+ new Uint8Array(await readFile(join(root, clip.src))),
54
+ {
55
+ maximumBytes: SCORE_LIMITS.maxSampleFileBytes,
56
+ maximumDurationSeconds: SCORE_LIMITS.maxClipSeconds + 1,
57
+ maximumChannels: 2,
58
+ },
59
+ );
60
+ cache.set(key, {
61
+ seconds: wav.sampleCount / wav.sampleRate,
62
+ peaks: waveformPeaks(wav),
63
+ });
64
+ } catch {
65
+ cache.set(key, null);
66
+ } finally {
67
+ pending.delete(key);
68
+ }
69
+ changed = true;
70
+ }
71
+ return changed;
72
+ }
73
+
74
+ /** The peaks inside a clip's window (`offset .. offset + dur`), reversed for `rev`. */
75
+ function windowPeaks(
76
+ file: FilePeaks,
77
+ offset: number,
78
+ seconds: number,
79
+ rev: boolean,
80
+ ): number[] {
81
+ const count = file.peaks.length;
82
+ const from = Math.floor((offset / file.seconds) * count);
83
+ const to = Math.max(
84
+ from + 1,
85
+ Math.ceil(((offset + seconds) / file.seconds) * count),
86
+ );
87
+ const slice = file.peaks.slice(Math.max(0, from), Math.min(count, to));
88
+ return rev ? slice.reverse() : slice;
89
+ }
90
+
91
+ /** Clip-row snapshots for `trackId`, undefined when it has no clips. */
92
+ export function clipSnapshots(
93
+ score: TrackScore,
94
+ trackId: string,
95
+ ): ClipSnapshot[] | undefined {
96
+ const track = score.tracks.find((t) => t.id === trackId);
97
+ const clips = track?.clips;
98
+ if (!clips || clips.length === 0) return undefined;
99
+ const tpb = score.ticksPerBeat;
100
+ const ends = new Set<number>();
101
+ const shaped = clips.map((clip) => {
102
+ const file = cachedPeaks(clip.src, clip.sha256) ?? undefined;
103
+ const startTick = clipSongTick(clip, track.time);
104
+ const endTick = clipEndTick(score, clip, file?.seconds);
105
+ return { clip, file, startTick, endTick };
106
+ });
107
+ for (const item of shaped) ends.add(Math.round(item.endTick));
108
+ return shaped.map(({ clip, file, startTick, endTick }) => ({
109
+ id: clip.id,
110
+ startBeat: startTick / tpb,
111
+ durationBeats: Math.max(0, endTick - startTick) / tpb,
112
+ ...(file
113
+ ? {
114
+ peaks: windowPeaks(
115
+ file,
116
+ clip.offset ?? 0,
117
+ clipLength(clip, file.seconds),
118
+ clip.rev === true,
119
+ ),
120
+ }
121
+ : {}),
122
+ ...(clip.take !== undefined ? { take: clip.take } : {}),
123
+ ...(clip.text !== undefined ? { text: clip.text } : {}),
124
+ ...(clip.mute || track.muted ? { muted: true } : {}),
125
+ ...(startTick > 0 && ends.has(Math.round(startTick)) ? { seam: true } : {}),
126
+ }));
127
+ }
128
+
129
+ /** Test hook: forget cached peaks. */
130
+ export function clearClipPeaks(): void {
131
+ cache.clear();
132
+ }
package/tui/delight.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Small, quiet moments that answer the music: the header ▶ glints on each
3
+ * downbeat, the first wrap of a new song sweeps once across the hit line,
4
+ * and a play key that is not recording glows its lane. Every one is a pure
5
+ * function of the transport clock and a timestamp, so tests pin the clock;
6
+ * `/motion off` turns them all off. None of them touches the score.
7
+ */
8
+
9
+ /** How long the header ▶ stays bright after a bar downbeat. */
10
+ export const GLINT_MS = 120;
11
+ /** How long the first-loop sweep takes to cross the hit line. */
12
+ export const SWEEP_MS = 900;
13
+ /** How long a play key's lane glows. */
14
+ export const GHOST_MS = 150;
15
+ /** The card the first wrap shows, once per song. */
16
+ export const FIRST_LOOP_CARD = "↻ first loop";
17
+
18
+ export type GlintInput = Readonly<{
19
+ playing: boolean;
20
+ /** Transport beat, unwrapped. */
21
+ beat: number;
22
+ bpm: number;
23
+ beatsPerBar: number;
24
+ /** Bar starts within the loop (meter changes); bars repeat when absent. */
25
+ barBeats?: readonly number[] | undefined;
26
+ loopBeats?: number | undefined;
27
+ reducedMotion: boolean;
28
+ }>;
29
+
30
+ /** Whether the header ▶ glints now: within `GLINT_MS` after a downbeat. */
31
+ export function downbeatGlint(input: GlintInput): boolean {
32
+ if (!input.playing || input.reducedMotion) return false;
33
+ if (!Number.isFinite(input.beat) || input.beat < 0) return false;
34
+ const bpm = Math.max(1, input.bpm);
35
+ const loop =
36
+ input.loopBeats && input.loopBeats > 0 ? input.loopBeats : undefined;
37
+ const inLoop = loop ? input.beat % loop : input.beat;
38
+ let since: number;
39
+ if (input.barBeats && input.barBeats.length > 0) {
40
+ let start = 0;
41
+ for (const bar of input.barBeats) if (bar <= inLoop + 1e-9) start = bar;
42
+ since = inLoop - start;
43
+ } else {
44
+ const bar = Math.max(1, input.beatsPerBar);
45
+ since = inLoop % bar;
46
+ }
47
+ return (since * 60_000) / bpm < GLINT_MS;
48
+ }
49
+
50
+ /**
51
+ * The first-loop sweep's head, 0..1 across the hit line, or undefined when
52
+ * it is not running. It plays once, `SWEEP_MS` long, from `startedAtMs`.
53
+ */
54
+ export function sweepProgress(
55
+ startedAtMs: number | undefined,
56
+ nowMs: number,
57
+ reducedMotion: boolean,
58
+ ): number | undefined {
59
+ if (startedAtMs === undefined || reducedMotion) return undefined;
60
+ const age = nowMs - startedAtMs;
61
+ if (age < 0 || age >= SWEEP_MS) return undefined;
62
+ return age / SWEEP_MS;
63
+ }
64
+
65
+ /** A glowing lane: its pitch and strength (1 at the press, fading to 0). */
66
+ export type LaneGlow = Readonly<{ pitch: number; strength: number }>;
67
+
68
+ /**
69
+ * Session-only delight state. The first loop is remembered per song in the
70
+ * session's metadata (`heardLoop`), handed in by the host, never the score.
71
+ */
72
+ export class Delight {
73
+ /** The song has wrapped before (this window or an earlier session). */
74
+ heardLoop: boolean;
75
+ /** When this window's first-loop sweep started. */
76
+ sweepStartedAtMs: number | undefined;
77
+ private readonly ghosts = new Map<number, number>();
78
+ private songId: string | undefined;
79
+
80
+ constructor(options: { heardLoop?: boolean } = {}) {
81
+ this.heardLoop = options.heardLoop ?? false;
82
+ }
83
+
84
+ /**
85
+ * Watch the transport. Returns true once, on the first wrap of a song
86
+ * that has not wrapped before; the caller shows the card and records it.
87
+ */
88
+ observe(
89
+ input: Readonly<{
90
+ playing: boolean;
91
+ beat: number;
92
+ loopBeats?: number | undefined;
93
+ empty: boolean;
94
+ }>,
95
+ nowMs: number,
96
+ ): boolean {
97
+ if (this.heardLoop || !input.playing || input.empty) return false;
98
+ const loop = input.loopBeats;
99
+ if (!loop || loop <= 0 || !(input.beat >= loop)) return false;
100
+ this.heardLoop = true;
101
+ this.sweepStartedAtMs = nowMs;
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Follow the open song: a different session starts fresh from its own
107
+ * metadata; the same one only ever learns that it has wrapped.
108
+ */
109
+ song(id: string, heardLoop: boolean): void {
110
+ if (id !== this.songId) {
111
+ this.songId = id;
112
+ this.heardLoop = heardLoop;
113
+ this.sweepStartedAtMs = undefined;
114
+ } else if (heardLoop) this.heardLoop = true;
115
+ }
116
+
117
+ /** A play key sounded without recording. */
118
+ ghost(pitch: number, nowMs: number): void {
119
+ this.ghosts.set(pitch, nowMs);
120
+ }
121
+
122
+ /** Lanes glowing now, strongest first; expired ones are dropped. */
123
+ glows(nowMs: number, reducedMotion: boolean): LaneGlow[] {
124
+ const glows: LaneGlow[] = [];
125
+ for (const [pitch, atMs] of this.ghosts) {
126
+ const age = nowMs - atMs;
127
+ if (age >= GHOST_MS || age < 0) {
128
+ if (age >= GHOST_MS) this.ghosts.delete(pitch);
129
+ continue;
130
+ }
131
+ if (!reducedMotion) glows.push({ pitch, strength: 1 - age / GHOST_MS });
132
+ }
133
+ return glows.sort((a, b) => b.strength - a.strength);
134
+ }
135
+
136
+ /** Whether anything is animating (the host keeps frames coming). */
137
+ animating(nowMs: number): boolean {
138
+ return (
139
+ this.ghosts.size > 0 ||
140
+ (this.sweepStartedAtMs !== undefined &&
141
+ nowMs - this.sweepStartedAtMs < SWEEP_MS)
142
+ );
143
+ }
144
+ }
package/tui/drawer.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * habit). The piano roll stays visible above it while the terminal is tall
6
6
  * enough.
7
7
  *
8
- * ╭ fader · filter ────────────────────── ● staged [keep] [revert] ╮
8
+ * ╭ Effects › filter ─── A/B: 1 change staged · enter keep · esc revert ╮
9
9
  * │ › cutoff 1200 Hz ← 800 Hz 20 … 20000 Hz │
10
10
  * │ [−] ━━━━━━━━━━━━━━━━━━●────────────────────────────────── [+] │
11
11
  * │ type lowpass │ highpass │ bandpass │ notch │
@@ -39,6 +39,8 @@ export type DrawerField =
39
39
  committedPosition?: number | undefined;
40
40
  minText: string;
41
41
  maxText: string;
42
+ /** Just caught by a detent: the bar brightens for one frame. */
43
+ flash?: string | undefined;
42
44
  }>
43
45
  | Readonly<{
44
46
  kind: "choice";
@@ -56,6 +58,8 @@ export type DrawerView = Readonly<{
56
58
  typing?: string | undefined;
57
59
  /** Edits differ from the committed score. */
58
60
  dirty: boolean;
61
+ /** `A/B: 1 change staged · enter keep · esc revert` while dirty. */
62
+ badge?: string | undefined;
59
63
  /** `♪ solo · B` while the audition loop plays. */
60
64
  status?: string | undefined;
61
65
  hint: string;
@@ -175,8 +179,13 @@ const ASCII: Glyphs = {
175
179
  export interface DrawerPaint {
176
180
  theme: Theme;
177
181
  unicode: boolean;
182
+ /** /motion off: detents still snap, the flash is not drawn. */
183
+ reducedMotion?: boolean;
178
184
  }
179
185
 
186
+ /** Views whose detent flash has been drawn: a flash lasts one frame. */
187
+ const flashed = new WeakSet<DrawerView>();
188
+
180
189
  /** Paint the drawer into the highway region; returns its layout. */
181
190
  export function paintDrawer(
182
191
  buffer: CellBuffer,
@@ -189,6 +198,9 @@ export function paintDrawer(
189
198
  if (region.height <= 0 || width < 16 || view.fields.length === 0)
190
199
  return undefined;
191
200
  const layout = drawerLayout(view, region);
201
+ const flashing = !options.reducedMotion && !flashed.has(view);
202
+ if (view.fields.some((field) => field.kind === "number" && field.flash))
203
+ flashed.add(view);
192
204
  const roles = options.theme.roles;
193
205
  const glyphs = options.unicode ? UNICODE : ASCII;
194
206
  const panel = roles.panel;
@@ -212,37 +224,58 @@ export function paintDrawer(
212
224
  buffer.set(width - 1, y, box.v, border);
213
225
  }
214
226
  inner = { left: 2, right: width - 2 };
215
- // Title left; staged state and the keep / revert buttons right.
216
- const keep = "[keep]";
217
- const revert = "[revert]";
218
- const buttons = view.dirty ? `${keep} ${revert}` : "";
219
- const state = view.dirty ? `${options.unicode ? "●" : "*"} staged` : "";
220
- const status = [view.status, state].filter(Boolean).join(" · ");
221
- const right = [status, buttons].filter(Boolean).join(" ");
227
+ // Title left; the status and the staged badge right. The badge's
228
+ // `enter keep` and `esc revert` are click targets; when room is short
229
+ // the breadcrumb truncates first, then the badge drops its key words.
230
+ const room = width - 6;
231
+ const full = view.badge ?? (view.dirty ? "A/B: changes staged" : "");
232
+ // The badge already counts staged changes; the audition status's
233
+ // `B staged N` would say it twice.
234
+ const status = (view.status ?? "")
235
+ .split(" · ")
236
+ .filter((part) => !(full && /^B staged \d+$/.test(part)))
237
+ .join(" · ");
238
+ const short = full.replace(/ · enter keep · esc revert$/, "");
239
+ const join = (badge: string) => [status, badge].filter(Boolean).join(" · ");
240
+ const minTitle = Math.min(displayWidth(view.title), 12) + 2;
241
+ let right = join(full);
242
+ if (displayWidth(right) + 3 + minTitle > room) right = join(short);
243
+ if (displayWidth(right) + 3 + minTitle > room) right = short;
244
+ if (displayWidth(right) + 3 + minTitle > room) right = "";
222
245
  const rightWidth = displayWidth(right);
223
- const titleRoom = Math.max(0, width - 6 - (right ? rightWidth + 3 : 0));
246
+ const titleRoom = Math.max(0, room - (right ? rightWidth + 3 : 0));
224
247
  buffer.text(
225
248
  2,
226
249
  top,
227
250
  ` ${truncate(view.title, Math.max(0, titleRoom - 2))} `,
228
251
  on({ ...roles.text, bold: true }),
229
252
  );
230
- if (right && rightWidth + 4 < width - displayWidth(view.title)) {
253
+ if (right) {
231
254
  const rx = width - 3 - rightWidth;
232
255
  buffer.text(
233
256
  rx - 1,
234
257
  top,
235
- ` ${status}${status && buttons ? " " : ""}`,
258
+ ` ${right}`,
236
259
  on(view.dirty ? roles.warning : roles.muted),
237
260
  );
238
- if (buttons) {
239
- const kx = width - 3 - displayWidth(buttons);
240
- buffer.text(kx, top, `${keep} `, on({ ...roles.success, bold: true }));
241
- buffer.text(kx + keep.length + 1, top, revert, on(roles.muted));
242
- hits?.add(kx, top, keep.length, 1, { kind: "fader-keep" });
243
- hits?.add(kx + keep.length + 1, top, revert.length, 1, {
244
- kind: "fader-revert",
245
- });
261
+ for (const [word, kind] of [
262
+ ["enter keep", "fader-keep"],
263
+ ["esc revert", "fader-revert"],
264
+ ] as const) {
265
+ const at = right.lastIndexOf(word);
266
+ if (at < 0) continue;
267
+ const x = rx + displayWidth(right.slice(0, at));
268
+ buffer.text(
269
+ x,
270
+ top,
271
+ word,
272
+ on(
273
+ kind === "fader-keep"
274
+ ? { ...roles.success, bold: true }
275
+ : roles.muted,
276
+ ),
277
+ );
278
+ hits?.add(x, top, word.length, 1, { kind });
246
279
  }
247
280
  }
248
281
  const hint = fitHint(
@@ -306,13 +339,18 @@ export function paintDrawer(
306
339
  on(roles.muted),
307
340
  inner.right - valueX - used,
308
341
  );
309
- const range = `${field.minText} … ${field.maxText}`;
342
+ // For the frame a detent catches, the range gives way to its name.
343
+ const range =
344
+ flashing && field.flash !== undefined
345
+ ? field.flash
346
+ : `${field.minText} … ${field.maxText}`;
310
347
  const rangeX = inner.right - displayWidth(range);
311
348
  if (rangeX > valueX + used + displayWidth(committed) + 2)
312
349
  buffer.text(rangeX, y, range, on(roles.faint));
313
350
  paintBar(buffer, y + 1, inner.left + 2, inner.right, field, index, {
314
351
  glyphs,
315
352
  focused,
353
+ flashing,
316
354
  roles,
317
355
  on,
318
356
  ...(hits ? { hits } : {}),
@@ -335,7 +373,7 @@ export function paintDrawer(
335
373
  inner.right,
336
374
  field,
337
375
  index,
338
- { glyphs, focused, roles, on, ...(hits ? { hits } : {}) },
376
+ { glyphs, focused, flashing, roles, on, ...(hits ? { hits } : {}) },
339
377
  );
340
378
  }
341
379
  } else {
@@ -364,6 +402,8 @@ export function paintDrawer(
364
402
  interface RowPaint {
365
403
  glyphs: Glyphs;
366
404
  focused: boolean;
405
+ /** Draw this field's detent flash (motion on, first frame only). */
406
+ flashing?: boolean;
367
407
  roles: Theme["roles"];
368
408
  on: (style: Style) => Style;
369
409
  hits?: HitMap;
@@ -412,7 +452,15 @@ function paintBar(
412
452
  field.committedPosition === undefined
413
453
  ? undefined
414
454
  : at(field.committedPosition);
415
- const filled = on(paint.focused ? roles.borderFocus : roles.text);
455
+ // A detent catch brightens the bar for one frame (motion on).
456
+ const flash = paint.flashing === true && field.flash !== undefined;
457
+ const filled = on(
458
+ flash
459
+ ? { ...roles.success, bold: true }
460
+ : paint.focused
461
+ ? roles.borderFocus
462
+ : roles.text,
463
+ );
416
464
  const empty = on(roles.faint);
417
465
  for (let column = 0; column < barWidth; column += 1) {
418
466
  const x = barLeft + column;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * When the interactive loop builds a frame. The 30 fps timer used to build
3
+ * the whole view (score snapshot, layout, every panel) on every tick even
4
+ * with nothing moving, only for the differential writer to find no change.
5
+ * The gate builds a frame when something can have changed: a forced frame
6
+ * (input, resize, an edit landing), a change the caller reports (activity,
7
+ * a different score or status key), or a running animation (transport,
8
+ * play mode, an audition, a spinner, a card's glow). Otherwise it builds
9
+ * one heartbeat frame every `heartbeatMs`, which catches anything slow that
10
+ * nothing reported (a loudness reading, a card ageing out).
11
+ */
12
+ export const IDLE_HEARTBEAT_MS = 500;
13
+ /**
14
+ * The shortest gap between two built frames in the interactive loop: a
15
+ * burst of forced frames (keystrokes, edits landing, agent events) is
16
+ * coalesced to at most about 30 a second. A deferred frame stays dirty, so
17
+ * the next 33 ms timer tick builds it; nothing is dropped.
18
+ */
19
+ export const MIN_FRAME_GAP_MS = 33;
20
+
21
+ export class FrameGate {
22
+ private dirty = true;
23
+ private lastBuiltMs = Number.NEGATIVE_INFINITY;
24
+ private lastKeys: readonly unknown[] = [];
25
+ /** Frames built and ticks skipped, for tests and diagnostics. */
26
+ built = 0;
27
+ skipped = 0;
28
+
29
+ constructor(
30
+ readonly heartbeatMs = IDLE_HEARTBEAT_MS,
31
+ readonly minGapMs = 0,
32
+ ) {}
33
+
34
+ /** Something visible changed; the next tick builds. */
35
+ markDirty(): void {
36
+ this.dirty = true;
37
+ }
38
+
39
+ /**
40
+ * True when this tick should build a frame; counts it as built. Each of
41
+ * `keys` is compared by identity (a score object) or value (a status
42
+ * string) with the last built frame's: any difference builds.
43
+ */
44
+ shouldBuild(options: {
45
+ nowMs: number;
46
+ force?: boolean;
47
+ animating?: boolean;
48
+ keys?: readonly unknown[];
49
+ }): boolean {
50
+ const keys = options.keys ?? [];
51
+ const keyChanged =
52
+ keys.length !== this.lastKeys.length ||
53
+ keys.some((key, index) => !Object.is(key, this.lastKeys[index]));
54
+ const build =
55
+ options.force === true ||
56
+ options.animating === true ||
57
+ this.dirty ||
58
+ keyChanged ||
59
+ options.nowMs - this.lastBuiltMs >= this.heartbeatMs;
60
+ if (!build) {
61
+ this.skipped += 1;
62
+ return false;
63
+ }
64
+ if (options.nowMs - this.lastBuiltMs < this.minGapMs) {
65
+ // Too soon after the last frame: build on the next tick instead.
66
+ this.dirty = true;
67
+ this.skipped += 1;
68
+ return false;
69
+ }
70
+ this.dirty = false;
71
+ this.lastKeys = keys;
72
+ this.lastBuiltMs = options.nowMs;
73
+ this.built += 1;
74
+ return true;
75
+ }
76
+ }