@hraness/dawg 0.6.1 → 0.8.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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  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 +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -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,193 @@
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
+ /** The TAPE reels, one step per beat (op1-ux §9.1). */
66
+ export const REELS = "◐◓◑◒";
67
+ /** ASCII reels for terminals without unicode. */
68
+ export const ASCII_REELS = "|/-\\";
69
+ /** How long the loop brackets stay bright after each wrap (§9.3). */
70
+ export const LOOP_FLASH_MS = 150;
71
+
72
+ /**
73
+ * The TAPE reel glyph: turns one step per transport beat while playing,
74
+ * stands still when stopped or with `/motion off`. A pure function of the
75
+ * beat, so two panes on one transport turn together.
76
+ */
77
+ export function reelGlyph(
78
+ input: Readonly<{
79
+ playing: boolean;
80
+ beat: number;
81
+ reducedMotion: boolean;
82
+ unicode: boolean;
83
+ }>,
84
+ ): string {
85
+ const reels = input.unicode ? REELS : ASCII_REELS;
86
+ if (!input.playing || input.reducedMotion || !Number.isFinite(input.beat))
87
+ return reels[0]!;
88
+ const step = Math.floor(Math.max(0, input.beat));
89
+ return reels[step % reels.length]!;
90
+ }
91
+
92
+ /**
93
+ * Whether the loop brackets flash now: within `LOOP_FLASH_MS` after the
94
+ * transport wraps the loop (each recording pass closes there). Never on
95
+ * the very first pass, never stopped, never with `/motion off`.
96
+ */
97
+ export function loopFlash(
98
+ input: Readonly<{
99
+ playing: boolean;
100
+ /** Transport beat, unwrapped. */
101
+ beat: number;
102
+ loopBeats?: number | undefined;
103
+ bpm: number;
104
+ reducedMotion: boolean;
105
+ }>,
106
+ ): boolean {
107
+ const loop = input.loopBeats;
108
+ if (!input.playing || input.reducedMotion || !loop || loop <= 0) return false;
109
+ if (!Number.isFinite(input.beat) || input.beat < loop) return false;
110
+ const since = input.beat % loop;
111
+ return (since * 60_000) / Math.max(1, input.bpm) < LOOP_FLASH_MS;
112
+ }
113
+
114
+ /** A glowing lane: its pitch and strength (1 at the press, fading to 0). */
115
+ export type LaneGlow = Readonly<{ pitch: number; strength: number }>;
116
+
117
+ /**
118
+ * Session-only delight state. The first loop is remembered per song in the
119
+ * session's metadata (`heardLoop`), handed in by the host, never the score.
120
+ */
121
+ export class Delight {
122
+ /** The song has wrapped before (this window or an earlier session). */
123
+ heardLoop: boolean;
124
+ /** When this window's first-loop sweep started. */
125
+ sweepStartedAtMs: number | undefined;
126
+ private readonly ghosts = new Map<number, number>();
127
+ private songId: string | undefined;
128
+
129
+ constructor(options: { heardLoop?: boolean } = {}) {
130
+ this.heardLoop = options.heardLoop ?? false;
131
+ }
132
+
133
+ /**
134
+ * Watch the transport. Returns true once, on the first wrap of a song
135
+ * that has not wrapped before; the caller shows the card and records it.
136
+ */
137
+ observe(
138
+ input: Readonly<{
139
+ playing: boolean;
140
+ beat: number;
141
+ loopBeats?: number | undefined;
142
+ empty: boolean;
143
+ }>,
144
+ nowMs: number,
145
+ ): boolean {
146
+ if (this.heardLoop || !input.playing || input.empty) return false;
147
+ const loop = input.loopBeats;
148
+ if (!loop || loop <= 0 || !(input.beat >= loop)) return false;
149
+ this.heardLoop = true;
150
+ this.sweepStartedAtMs = nowMs;
151
+ return true;
152
+ }
153
+
154
+ /**
155
+ * Follow the open song: a different session starts fresh from its own
156
+ * metadata; the same one only ever learns that it has wrapped.
157
+ */
158
+ song(id: string, heardLoop: boolean): void {
159
+ if (id !== this.songId) {
160
+ this.songId = id;
161
+ this.heardLoop = heardLoop;
162
+ this.sweepStartedAtMs = undefined;
163
+ } else if (heardLoop) this.heardLoop = true;
164
+ }
165
+
166
+ /** A play key sounded without recording. */
167
+ ghost(pitch: number, nowMs: number): void {
168
+ this.ghosts.set(pitch, nowMs);
169
+ }
170
+
171
+ /** Lanes glowing now, strongest first; expired ones are dropped. */
172
+ glows(nowMs: number, reducedMotion: boolean): LaneGlow[] {
173
+ const glows: LaneGlow[] = [];
174
+ for (const [pitch, atMs] of this.ghosts) {
175
+ const age = nowMs - atMs;
176
+ if (age >= GHOST_MS || age < 0) {
177
+ if (age >= GHOST_MS) this.ghosts.delete(pitch);
178
+ continue;
179
+ }
180
+ if (!reducedMotion) glows.push({ pitch, strength: 1 - age / GHOST_MS });
181
+ }
182
+ return glows.sort((a, b) => b.strength - a.strength);
183
+ }
184
+
185
+ /** Whether anything is animating (the host keeps frames coming). */
186
+ animating(nowMs: number): boolean {
187
+ return (
188
+ this.ghosts.size > 0 ||
189
+ (this.sweepStartedAtMs !== undefined &&
190
+ nowMs - this.sweepStartedAtMs < SWEEP_MS)
191
+ );
192
+ }
193
+ }