@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.
- package/CHANGELOG.md +92 -0
- package/DAWG.md +343 -116
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +832 -13
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +276 -3
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +1849 -42
- package/core/sections.ts +292 -26
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +126 -14
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +12 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +108 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +11 -7
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +37 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +160 -28
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +558 -0
- package/src/commands/help.ts +610 -380
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +8 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sing.ts +478 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1397 -253
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +759 -177
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +47 -7
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
package/tui/clip-row.ts
ADDED
|
@@ -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
|
-
* ╭
|
|
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;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
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,
|
|
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
|
|
253
|
+
if (right) {
|
|
231
254
|
const rx = width - 3 - rightWidth;
|
|
232
255
|
buffer.text(
|
|
233
256
|
rx - 1,
|
|
234
257
|
top,
|
|
235
|
-
` ${
|
|
258
|
+
` ${right}`,
|
|
236
259
|
on(view.dirty ? roles.warning : roles.muted),
|
|
237
260
|
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|