@hraness/dawg 0.6.0 → 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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- 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/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- 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 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- 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 -12
- 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 +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- 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 +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -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 +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -0
- 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 +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -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 +171 -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/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- 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 +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- 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 +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- 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/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- 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 +244 -0
- 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 +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- 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 +36 -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 +50 -7
- 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 +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- 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 +144 -0
- 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/highway.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* lane per voice with a legend.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { emptyHint } from "./hints.ts";
|
|
13
14
|
import { CellBuffer } from "./screen.ts";
|
|
14
15
|
import { truncate } from "./text.ts";
|
|
15
16
|
import {
|
|
@@ -44,6 +45,24 @@ export interface NoteSnapshot {
|
|
|
44
45
|
* from (`D` for a `D−47` tag), since the lane is the key, not the sound.
|
|
45
46
|
*/
|
|
46
47
|
centsFrom?: string | undefined;
|
|
48
|
+
/** Sung syllable (`Note.lyric`), drawn beside the head like the cents tag. */
|
|
49
|
+
lyric?: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An audio clip on the focused track (0.7), drawn in the clip row. */
|
|
53
|
+
export interface ClipSnapshot {
|
|
54
|
+
id: string;
|
|
55
|
+
/** Song beats where the clip sounds. */
|
|
56
|
+
startBeat: number;
|
|
57
|
+
durationBeats: number;
|
|
58
|
+
/** Normalised RMS over the clip's window (0..1), oldest first. */
|
|
59
|
+
peaks?: readonly number[] | undefined;
|
|
60
|
+
/** Take name and clip text, shown after the id. */
|
|
61
|
+
take?: string | undefined;
|
|
62
|
+
text?: string | undefined;
|
|
63
|
+
muted?: boolean | undefined;
|
|
64
|
+
/** The clip starts where another ends: a comp seam. */
|
|
65
|
+
seam?: boolean | undefined;
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
export interface TrackScoreSnapshot {
|
|
@@ -83,6 +102,49 @@ export interface TrackScoreSnapshot {
|
|
|
83
102
|
* accent. Empty or omitted in focus view.
|
|
84
103
|
*/
|
|
85
104
|
layers?: readonly HighwayLayer[] | undefined;
|
|
105
|
+
/** The focused track sings (instrument `vocal`): the empty hint says how. */
|
|
106
|
+
vocal?: boolean | undefined;
|
|
107
|
+
/** Audio clips on the focused track (0.7): the right-edge clip row. */
|
|
108
|
+
clips?: readonly ClipSnapshot[] | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Detected pitch of the focused track's audio (0.7 `/vocal pitch trace`),
|
|
111
|
+
* sorted by beat. Drawn as a dotted line under the notes, in the warning
|
|
112
|
+
* role where it sits more than 15 cents off the nearest semitone; hidden
|
|
113
|
+
* while one row spans more than a beat.
|
|
114
|
+
*/
|
|
115
|
+
pitchTrace?: readonly PitchTracePoint[] | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One point of a pitch trace: score beats and fractional MIDI pitch. */
|
|
119
|
+
export interface PitchTracePoint {
|
|
120
|
+
beat: number;
|
|
121
|
+
pitch: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Cents a trace point sits away from its nearest semitone before it warns. */
|
|
125
|
+
export const TRACE_WARN_CENTS = 15;
|
|
126
|
+
|
|
127
|
+
/** The trace point nearest `beat` within `half` beats, by binary search. */
|
|
128
|
+
export function tracePointAt(
|
|
129
|
+
trace: readonly PitchTracePoint[],
|
|
130
|
+
beat: number,
|
|
131
|
+
half: number,
|
|
132
|
+
): PitchTracePoint | undefined {
|
|
133
|
+
let lo = 0;
|
|
134
|
+
let hi = trace.length;
|
|
135
|
+
while (lo < hi) {
|
|
136
|
+
const mid = (lo + hi) >> 1;
|
|
137
|
+
if (trace[mid]!.beat < beat) lo = mid + 1;
|
|
138
|
+
else hi = mid;
|
|
139
|
+
}
|
|
140
|
+
let best: PitchTracePoint | undefined;
|
|
141
|
+
for (const i of [lo - 1, lo]) {
|
|
142
|
+
const point = trace[i];
|
|
143
|
+
if (!point || Math.abs(point.beat - beat) > half) continue;
|
|
144
|
+
if (!best || Math.abs(point.beat - beat) < Math.abs(best.beat - beat))
|
|
145
|
+
best = point;
|
|
146
|
+
}
|
|
147
|
+
return best;
|
|
86
148
|
}
|
|
87
149
|
|
|
88
150
|
/** One background track overlaid on the highway. */
|
|
@@ -413,6 +475,12 @@ export interface HighwayOptions {
|
|
|
413
475
|
lookaheadBeats?: number;
|
|
414
476
|
/** Background style painted under the highway. */
|
|
415
477
|
background?: Style;
|
|
478
|
+
/** Seed and agent presence for the empty-state hint (tui/hints.ts). */
|
|
479
|
+
hint?: Readonly<{ seed: string; agent: boolean }> | undefined;
|
|
480
|
+
/** The first-loop sweep's head, 0..1 across the hit line (tui/delight.ts). */
|
|
481
|
+
sweep?: number | undefined;
|
|
482
|
+
/** Play keys sounding without recording: their lanes glow on the hit line. */
|
|
483
|
+
glows?: readonly Readonly<{ pitch: number; strength: number }>[] | undefined;
|
|
416
484
|
}
|
|
417
485
|
|
|
418
486
|
export interface HighwayLayout {
|
|
@@ -473,6 +541,7 @@ interface Painter {
|
|
|
473
541
|
put(x: number, y: number, ch: string, style: Style): void;
|
|
474
542
|
restyle(x: number, y: number, style: Style): void;
|
|
475
543
|
isBlank(x: number, y: number): boolean;
|
|
544
|
+
charAt(x: number, y: number): string | undefined;
|
|
476
545
|
}
|
|
477
546
|
|
|
478
547
|
/**
|
|
@@ -516,6 +585,9 @@ export function paintHighway(
|
|
|
516
585
|
isBlank(x, y) {
|
|
517
586
|
return buffer.get(region.x + x, region.y + y)?.ch === " ";
|
|
518
587
|
},
|
|
588
|
+
charAt(x, y) {
|
|
589
|
+
return buffer.get(region.x + x, region.y + y)?.ch;
|
|
590
|
+
},
|
|
519
591
|
};
|
|
520
592
|
|
|
521
593
|
const rowBeat = (row: number): number => beat + (hitRow - row) / rowsPerBeat;
|
|
@@ -525,13 +597,17 @@ export function paintHighway(
|
|
|
525
597
|
// centred hint says how to start. The hit line and grid still draw.
|
|
526
598
|
const empty =
|
|
527
599
|
score.notes.length === 0 &&
|
|
600
|
+
(score.clips ?? []).length === 0 &&
|
|
528
601
|
!(score.layers ?? []).some((layer) => layer.notes.length > 0);
|
|
529
602
|
|
|
530
603
|
// Beat, bar, and loop rules of increasing strength.
|
|
531
604
|
const barBeats =
|
|
532
605
|
score.barBeats && score.barBeats.length > 0 ? score.barBeats : undefined;
|
|
606
|
+
// An empty highway reserves one row for its hint: no rule scrolls
|
|
607
|
+
// through it, so the words never sit on the ruler, playing or paused.
|
|
608
|
+
const hintRow = empty ? Math.max(0, Math.floor(hitRow / 2)) : undefined;
|
|
533
609
|
for (let row = 0; row <= lastNoteRow; row += 1) {
|
|
534
|
-
if (row === hitRow) continue;
|
|
610
|
+
if (row === hitRow || row === hintRow) continue;
|
|
535
611
|
const center = rowBeat(row);
|
|
536
612
|
const half = 0.5 / rowsPerBeat;
|
|
537
613
|
let k = Math.ceil(center - half - 1e-9);
|
|
@@ -613,37 +689,40 @@ export function paintHighway(
|
|
|
613
689
|
);
|
|
614
690
|
}
|
|
615
691
|
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
if (
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
shade(roles.loopRule, 0.5 - offset * 0.1),
|
|
630
|
-
);
|
|
631
|
-
}
|
|
692
|
+
// First-loop sweep: once per song, a faint band crosses the hit line
|
|
693
|
+
// when the loop first wraps (tui/delight.ts decides when).
|
|
694
|
+
if (options.sweep !== undefined && !reduced) {
|
|
695
|
+
const head = Math.round(gutter + options.sweep * areaWidth);
|
|
696
|
+
for (let offset = 0; offset < 6; offset += 1) {
|
|
697
|
+
const column = head - offset;
|
|
698
|
+
if (column < gutter || column >= region.width) continue;
|
|
699
|
+
painter.put(
|
|
700
|
+
column,
|
|
701
|
+
hitRow,
|
|
702
|
+
glyphs.loopRule,
|
|
703
|
+
shade(roles.loopRule, 0.5 - offset * 0.1),
|
|
704
|
+
);
|
|
632
705
|
}
|
|
633
706
|
}
|
|
634
707
|
|
|
635
708
|
if (empty) {
|
|
636
709
|
const name = score.trackName ?? score.trackId ?? "track";
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
710
|
+
// One seeded line names a way in that works in this session: a
|
|
711
|
+
// request with an agent, a command without one (tui/hints.ts).
|
|
712
|
+
const text = emptyHint(
|
|
713
|
+
{
|
|
714
|
+
seed: options.hint?.seed ?? score.sessionId ?? "dawg",
|
|
715
|
+
agent: options.hint?.agent ?? true,
|
|
716
|
+
filled: false,
|
|
717
|
+
drums: projection.kind !== "pitch",
|
|
718
|
+
vocal: score.vocal === true,
|
|
719
|
+
playing: score.playing === true,
|
|
720
|
+
},
|
|
721
|
+
name,
|
|
643
722
|
areaWidth,
|
|
644
723
|
);
|
|
645
724
|
const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
|
|
646
|
-
const y =
|
|
725
|
+
const y = hintRow ?? 0;
|
|
647
726
|
for (let index = 0; index < text.length; index += 1)
|
|
648
727
|
painter.put(x + index, y, text[index]!, roles.muted);
|
|
649
728
|
}
|
|
@@ -668,6 +747,34 @@ export function paintHighway(
|
|
|
668
747
|
}
|
|
669
748
|
}
|
|
670
749
|
|
|
750
|
+
// 0.7 pitch trace: one dot per row under the notes, at the fractional
|
|
751
|
+
// lane of the detected pitch. Rows wider than a beat hide it.
|
|
752
|
+
const trace = score.pitchTrace;
|
|
753
|
+
if (trace && trace.length > 0 && rowsPerBeat >= 1) {
|
|
754
|
+
const half = 0.5 / rowsPerBeat;
|
|
755
|
+
const width = Math.max(1, layout.laneWidth || 1);
|
|
756
|
+
for (let row = 0; row <= lastNoteRow; row += 1) {
|
|
757
|
+
if (row === hitRow) continue;
|
|
758
|
+
let at = rowBeat(row);
|
|
759
|
+
if (loop) at = ((at % loop) + loop) % loop;
|
|
760
|
+
const point = tracePointAt(trace, at, half);
|
|
761
|
+
if (!point) continue;
|
|
762
|
+
const near = Math.round(point.pitch);
|
|
763
|
+
const lane = projection.laneOf({ startBeat: point.beat, pitch: near });
|
|
764
|
+
if (lane === undefined) continue;
|
|
765
|
+
const off = point.pitch - near;
|
|
766
|
+
const x =
|
|
767
|
+
laneX(layout, lane, areaWidth) +
|
|
768
|
+
Math.max(0, Math.min(width - 1, Math.floor((off + 0.5) * width)));
|
|
769
|
+
painter.put(
|
|
770
|
+
x,
|
|
771
|
+
row,
|
|
772
|
+
glyphs.muted,
|
|
773
|
+
Math.abs(off) * 100 > TRACE_WARN_CENTS ? roles.warning : roles.muted,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
671
778
|
// Note occurrences across loop repeats, ordered so active notes draw last.
|
|
672
779
|
const belowBeats = (lastNoteRow - hitRow + 1) / rowsPerBeat;
|
|
673
780
|
const aheadBeats = hitRow / rowsPerBeat + 1 / rowsPerBeat;
|
|
@@ -835,11 +942,13 @@ export function paintHighway(
|
|
|
835
942
|
: shade(base, 0.45 * glow);
|
|
836
943
|
if (headRow <= hitRow - 1) {
|
|
837
944
|
tile(headRow, headGlyph, style);
|
|
838
|
-
const
|
|
945
|
+
const cents =
|
|
839
946
|
(note.cents !== undefined || note.centsFrom !== undefined) &&
|
|
840
947
|
!note.muted
|
|
841
948
|
? centsTag(note.cents, note.centsFrom)
|
|
842
949
|
: "";
|
|
950
|
+
const lyric = note.lyric && !note.muted ? note.lyric.slice(0, 8) : "";
|
|
951
|
+
const tag = [cents, lyric].filter(Boolean).join(" ");
|
|
843
952
|
if (tag && x + width + tag.length <= region.width)
|
|
844
953
|
for (let index = 0; index < tag.length; index += 1)
|
|
845
954
|
painter.put(x + width + index, headRow, tag[index]!, roles.muted);
|
|
@@ -938,9 +1047,144 @@ export function paintHighway(
|
|
|
938
1047
|
}
|
|
939
1048
|
}
|
|
940
1049
|
}
|
|
1050
|
+
// Ghost notes: a play key that is not recording lights its lane on the
|
|
1051
|
+
// hit line for a moment, so the hand sees where the music would land.
|
|
1052
|
+
if (options.glows && !reduced) {
|
|
1053
|
+
const width = Math.max(1, layout.laneWidth || 1);
|
|
1054
|
+
for (const glow of options.glows) {
|
|
1055
|
+
const lane = projection.laneOf({ startBeat: beat, pitch: glow.pitch });
|
|
1056
|
+
if (lane === undefined) continue;
|
|
1057
|
+
const x = laneX(layout, lane, areaWidth);
|
|
1058
|
+
const style = {
|
|
1059
|
+
...shade(accent, 0.45 * glow.strength),
|
|
1060
|
+
bold: glow.strength > 0.5,
|
|
1061
|
+
};
|
|
1062
|
+
for (let offset = 0; offset < width; offset += 1)
|
|
1063
|
+
painter.put(x + offset, hitRow, glyphs.hitStrong, style);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (score.clips && score.clips.length > 0 && region.width >= 24)
|
|
1067
|
+
paintClipRow(painter, score, {
|
|
1068
|
+
beat,
|
|
1069
|
+
hitRow,
|
|
1070
|
+
lastNoteRow,
|
|
1071
|
+
rowsPerBeat,
|
|
1072
|
+
width: region.width,
|
|
1073
|
+
loop,
|
|
1074
|
+
glyphs,
|
|
1075
|
+
accent,
|
|
1076
|
+
roles,
|
|
1077
|
+
});
|
|
941
1078
|
return layout;
|
|
942
1079
|
}
|
|
943
1080
|
|
|
1081
|
+
function rowHasOnlyRule(
|
|
1082
|
+
painter: Painter,
|
|
1083
|
+
x: number,
|
|
1084
|
+
length: number,
|
|
1085
|
+
row: number,
|
|
1086
|
+
glyphs: HighwayGlyphs,
|
|
1087
|
+
): boolean {
|
|
1088
|
+
for (let index = 0; index < length; index += 1) {
|
|
1089
|
+
const ch = painter.charAt(x + index, row);
|
|
1090
|
+
if (ch !== " " && ch !== glyphs.beatRule) return false;
|
|
1091
|
+
}
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Shading for a clip's level, quiet to loud. */
|
|
1096
|
+
const CLIP_LEVELS_UNICODE = ["▏", "░", "▒", "▓", "█"];
|
|
1097
|
+
const CLIP_LEVELS_ASCII = [":", ".", "o", "O", "#"];
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* The clip row: the rightmost column carries each clip's level as it
|
|
1101
|
+
* falls toward the hit line, a seam (`┄`) where one clip continues
|
|
1102
|
+
* another, and `id take · text` beside the clip's first row.
|
|
1103
|
+
*/
|
|
1104
|
+
function paintClipRow(
|
|
1105
|
+
painter: Painter,
|
|
1106
|
+
score: TrackScoreSnapshot,
|
|
1107
|
+
at: {
|
|
1108
|
+
beat: number;
|
|
1109
|
+
hitRow: number;
|
|
1110
|
+
lastNoteRow: number;
|
|
1111
|
+
rowsPerBeat: number;
|
|
1112
|
+
width: number;
|
|
1113
|
+
loop: number | undefined;
|
|
1114
|
+
glyphs: HighwayGlyphs;
|
|
1115
|
+
accent: Style;
|
|
1116
|
+
roles: Theme["roles"];
|
|
1117
|
+
},
|
|
1118
|
+
): void {
|
|
1119
|
+
const column = at.width - 1;
|
|
1120
|
+
const levels =
|
|
1121
|
+
at.glyphs === ASCII_GLYPHS ? CLIP_LEVELS_ASCII : CLIP_LEVELS_UNICODE;
|
|
1122
|
+
const seamGlyph = at.glyphs === ASCII_GLYPHS ? "-" : "┄";
|
|
1123
|
+
const rowBeat = (row: number) => at.beat + (at.hitRow - row) / at.rowsPerBeat;
|
|
1124
|
+
const half = 0.5 / at.rowsPerBeat;
|
|
1125
|
+
const occurrences = (clip: ClipSnapshot): number[] => {
|
|
1126
|
+
if (!at.loop) return [clip.startBeat];
|
|
1127
|
+
const base = Math.floor(at.beat / at.loop) * at.loop;
|
|
1128
|
+
return [base - at.loop, base, base + at.loop].map(
|
|
1129
|
+
(offset) => offset + clip.startBeat,
|
|
1130
|
+
);
|
|
1131
|
+
};
|
|
1132
|
+
for (const clip of score.clips ?? []) {
|
|
1133
|
+
const style = clip.muted ? at.roles.mutedNote : at.accent;
|
|
1134
|
+
for (const start of occurrences(clip)) {
|
|
1135
|
+
const end = start + clip.durationBeats;
|
|
1136
|
+
for (let row = 0; row <= at.lastNoteRow; row += 1) {
|
|
1137
|
+
if (row === at.hitRow) continue;
|
|
1138
|
+
const center = rowBeat(row);
|
|
1139
|
+
if (center + half <= start || center - half >= end) continue;
|
|
1140
|
+
const isStart = start >= center - half && start < center + half;
|
|
1141
|
+
if (isStart && clip.seam) {
|
|
1142
|
+
painter.put(column, row, seamGlyph, at.roles.muted);
|
|
1143
|
+
} else {
|
|
1144
|
+
const peaks = clip.peaks;
|
|
1145
|
+
const fraction = Math.max(
|
|
1146
|
+
0,
|
|
1147
|
+
Math.min(
|
|
1148
|
+
0.999,
|
|
1149
|
+
(center - start) / Math.max(1e-9, clip.durationBeats),
|
|
1150
|
+
),
|
|
1151
|
+
);
|
|
1152
|
+
const level =
|
|
1153
|
+
peaks && peaks.length > 0
|
|
1154
|
+
? peaks[Math.floor(fraction * peaks.length)]!
|
|
1155
|
+
: 0.6;
|
|
1156
|
+
const index = clip.muted
|
|
1157
|
+
? 0
|
|
1158
|
+
: Math.min(
|
|
1159
|
+
levels.length - 1,
|
|
1160
|
+
Math.round(level * (levels.length - 1)),
|
|
1161
|
+
);
|
|
1162
|
+
painter.put(column, row, levels[index]!, style);
|
|
1163
|
+
}
|
|
1164
|
+
if (isStart && row < at.hitRow) {
|
|
1165
|
+
const label = [clip.id, clip.take, clip.text ? `· ${clip.text}` : ""]
|
|
1166
|
+
.filter(Boolean)
|
|
1167
|
+
.join(" ")
|
|
1168
|
+
.slice(0, Math.max(0, Math.min(24, at.width - 6)));
|
|
1169
|
+
const x = column - 1 - label.length;
|
|
1170
|
+
// Over a beat rule the whole label draws; over notes it never does.
|
|
1171
|
+
const ruled = rowHasOnlyRule(
|
|
1172
|
+
painter,
|
|
1173
|
+
x,
|
|
1174
|
+
label.length,
|
|
1175
|
+
row,
|
|
1176
|
+
at.glyphs,
|
|
1177
|
+
);
|
|
1178
|
+
if (x > 3)
|
|
1179
|
+
for (let index = 0; index < label.length; index += 1)
|
|
1180
|
+
if (painter.isBlank(x + index, row) || ruled)
|
|
1181
|
+
painter.put(x + index, row, label[index]!, at.roles.muted);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
944
1188
|
/**
|
|
945
1189
|
* A background track: a quiet head and sustain line in the track's accent,
|
|
946
1190
|
* no hit flash, sparks or ghosts, so the focused track stays readable.
|
package/tui/hints.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeded hints: the empty-highway line and the prompt placeholder.
|
|
3
|
+
*
|
|
4
|
+
* Each list is chosen by what the session can do (an agent or commands
|
|
5
|
+
* only) and what the song holds (empty, pitched, drums), and one entry is
|
|
6
|
+
* picked by hashing the session id, so a session keeps the same words
|
|
7
|
+
* while it runs and the next session reads a different, equally short one.
|
|
8
|
+
* Every example is original and every command in it runs as typed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type HintState = Readonly<{
|
|
12
|
+
/** The session id (or any stable string): the seed. */
|
|
13
|
+
seed: string;
|
|
14
|
+
/** An agent provider is ready, so prose requests work. */
|
|
15
|
+
agent: boolean;
|
|
16
|
+
/** The song has notes, clips or layers. */
|
|
17
|
+
filled: boolean;
|
|
18
|
+
/** The focused track is a drum kit. */
|
|
19
|
+
drums?: boolean;
|
|
20
|
+
/** The focused track is a vocal track. */
|
|
21
|
+
vocal?: boolean;
|
|
22
|
+
/** The transport is running. */
|
|
23
|
+
playing?: boolean;
|
|
24
|
+
/** The agent is busy and Enter queues for after it. */
|
|
25
|
+
queue?: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
/** FNV-1a: a small, stable string hash. */
|
|
29
|
+
export function seedHash(seed: string): number {
|
|
30
|
+
let hash = 0x811c9dc5;
|
|
31
|
+
for (let index = 0; index < seed.length; index += 1) {
|
|
32
|
+
hash ^= seed.charCodeAt(index);
|
|
33
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
return hash >>> 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The entry of `list` the seed picks; `salt` decorrelates two lists. */
|
|
39
|
+
export function pick<T>(list: readonly T[], seed: string, salt = ""): T {
|
|
40
|
+
return list[seedHash(`${salt}:${seed}`) % list.length]!;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Prose requests an agent understands, for the placeholder. */
|
|
44
|
+
const ASK_EMPTY = [
|
|
45
|
+
"describe a song — “a slow lofi loop in F with soft drums”",
|
|
46
|
+
"describe a song — “a four-bar house groove at 124 BPM”",
|
|
47
|
+
"describe a song — “a sad waltz for piano and cello”",
|
|
48
|
+
"describe a song — “a bright synth arpeggio in D major”",
|
|
49
|
+
"describe a song — “a dusty boom bap beat with a walking bass”",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
const ASK_FILLED = [
|
|
53
|
+
"describe a change — “add a walking bass in A minor”",
|
|
54
|
+
"describe a change — “make the drums swing a little”",
|
|
55
|
+
"describe a change — “double the melody an octave up”",
|
|
56
|
+
"describe a change — “add reverb to the keys”",
|
|
57
|
+
"describe a change — “slow it to 90 BPM and add a fill”",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
const ASK_DRUMS = [
|
|
61
|
+
"describe a change — “a ghost-note snare on the offbeats”",
|
|
62
|
+
"describe a change — “open hats on every and”",
|
|
63
|
+
"describe a change — “a crash on bar one, then a fill”",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Commands that run without an agent, for the placeholder. `{style}` is the
|
|
68
|
+
* same seeded style the wide empty line suggests, so the two lines never
|
|
69
|
+
* offer competing first steps.
|
|
70
|
+
*/
|
|
71
|
+
const TYPE_EMPTY = [
|
|
72
|
+
"try: style {style} · space play · /help",
|
|
73
|
+
"try: style {style} 8 · then space to hear it",
|
|
74
|
+
"try: add C4 at 0 · add E4 at 1 · space play",
|
|
75
|
+
"try: style {style} · ctrl-z undo",
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
const TYPE_FILLED = [
|
|
79
|
+
"try: tempo 96 · reverb 0.3 · space play",
|
|
80
|
+
"try: style again · ctrl-z undo",
|
|
81
|
+
"try: add G4 at 2 · bars 8 · space play",
|
|
82
|
+
"try: volume 0.6 · pan -0.2 · /help",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
const TYPE_DRUMS = [
|
|
86
|
+
"try: hit kick at 0 · hit snare at 1 · space play",
|
|
87
|
+
"try: euclid hat 7 16 · tempo 100",
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The keys on the empty line (design §8.5): transport, play mode and the
|
|
92
|
+
* menu while paused; while playing, how to stop and, with an agent, that a
|
|
93
|
+
* request still works.
|
|
94
|
+
*/
|
|
95
|
+
const KEYS_PAUSED = "space play · ctrl-p play mode · ctrl-k menu";
|
|
96
|
+
const KEYS_PLAYING_AGENT = "space stop · type a request";
|
|
97
|
+
const KEYS_PLAYING = "space stop · ctrl-p play mode";
|
|
98
|
+
|
|
99
|
+
/** How to start a drum track or a vocal track, before the keys. */
|
|
100
|
+
const LEAD_DRUMS = "hit kick at 0";
|
|
101
|
+
const LEAD_VOCAL = 'lyrics "la la" · sing ooh · ctrl-k › Voice';
|
|
102
|
+
|
|
103
|
+
/** Styles a wide empty line suggests, one per session (all have cards). */
|
|
104
|
+
export const SUGGESTED_STYLES = [
|
|
105
|
+
"deep-house",
|
|
106
|
+
"lofi-hip-hop",
|
|
107
|
+
"bossa-nova",
|
|
108
|
+
"nu-disco",
|
|
109
|
+
"synthwave",
|
|
110
|
+
"golden-age",
|
|
111
|
+
] as const;
|
|
112
|
+
|
|
113
|
+
/** From this width the empty line also suggests a seeded style. */
|
|
114
|
+
export const STYLE_HINT_WIDTH = 100;
|
|
115
|
+
|
|
116
|
+
/** The prompt placeholder for this session and state. */
|
|
117
|
+
export function placeholderHint(state: HintState): string {
|
|
118
|
+
if (state.agent && state.queue) return "queue a request for after this one…";
|
|
119
|
+
const list = state.agent
|
|
120
|
+
? state.drums && state.filled
|
|
121
|
+
? ASK_DRUMS
|
|
122
|
+
: state.filled
|
|
123
|
+
? ASK_FILLED
|
|
124
|
+
: ASK_EMPTY
|
|
125
|
+
: state.drums
|
|
126
|
+
? TYPE_DRUMS
|
|
127
|
+
: state.filled
|
|
128
|
+
? TYPE_FILLED
|
|
129
|
+
: TYPE_EMPTY;
|
|
130
|
+
return pick(list, state.seed, "placeholder").replace(
|
|
131
|
+
"{style}",
|
|
132
|
+
sessionStyle(state.seed),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one style this session suggests, on the empty line and the prompt. */
|
|
137
|
+
export function sessionStyle(seed: string): string {
|
|
138
|
+
return pick(SUGGESTED_STYLES, seed, "style");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The empty-highway line, `<name> · empty · <lead> · <keys>`, shortened to
|
|
143
|
+
* `width`: the lead goes first, then the keys shrink to their first
|
|
144
|
+
* clause, then the name alone. A drum track leads with `hit kick at 0`, a
|
|
145
|
+
* vocal track with lyrics and sing, and from 100 columns a pitched track
|
|
146
|
+
* suggests a seeded style.
|
|
147
|
+
*/
|
|
148
|
+
export function emptyHint(
|
|
149
|
+
state: HintState,
|
|
150
|
+
name: string,
|
|
151
|
+
width: number,
|
|
152
|
+
): string {
|
|
153
|
+
const keys = state.playing
|
|
154
|
+
? state.agent
|
|
155
|
+
? KEYS_PLAYING_AGENT
|
|
156
|
+
: KEYS_PLAYING
|
|
157
|
+
: KEYS_PAUSED;
|
|
158
|
+
const lead = state.drums
|
|
159
|
+
? LEAD_DRUMS
|
|
160
|
+
: state.vocal
|
|
161
|
+
? LEAD_VOCAL
|
|
162
|
+
: width >= STYLE_HINT_WIDTH && !state.playing
|
|
163
|
+
? `try style ${sessionStyle(state.seed)}`
|
|
164
|
+
: undefined;
|
|
165
|
+
const head = `${name} · empty`;
|
|
166
|
+
const candidates = [
|
|
167
|
+
...(lead ? [`${head} · ${lead} · ${keys}`] : []),
|
|
168
|
+
`${head} · ${keys}`,
|
|
169
|
+
`${head} · ${keys.split(" · ")[0]}`,
|
|
170
|
+
];
|
|
171
|
+
for (const candidate of candidates)
|
|
172
|
+
if (candidate.length <= width) return candidate;
|
|
173
|
+
return head.slice(0, Math.max(0, width));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every hint string, for the shipped-text and command checks. */
|
|
177
|
+
export const ALL_HINTS: readonly string[] = [
|
|
178
|
+
...ASK_EMPTY,
|
|
179
|
+
...ASK_FILLED,
|
|
180
|
+
...ASK_DRUMS,
|
|
181
|
+
...TYPE_EMPTY.flatMap((hint) =>
|
|
182
|
+
SUGGESTED_STYLES.map((id) => hint.replace("{style}", id)),
|
|
183
|
+
),
|
|
184
|
+
...TYPE_FILLED,
|
|
185
|
+
...TYPE_DRUMS,
|
|
186
|
+
KEYS_PAUSED,
|
|
187
|
+
KEYS_PLAYING_AGENT,
|
|
188
|
+
KEYS_PLAYING,
|
|
189
|
+
LEAD_DRUMS,
|
|
190
|
+
LEAD_VOCAL,
|
|
191
|
+
...SUGGESTED_STYLES.map((id) => `try style ${id}`),
|
|
192
|
+
];
|
package/tui/input.ts
CHANGED
|
@@ -7,16 +7,42 @@
|
|
|
7
7
|
export type TerminalInputEvent =
|
|
8
8
|
string | Readonly<{ type: "paste"; text: string }>;
|
|
9
9
|
|
|
10
|
+
/** A bracketed paste longer than this is emitted as it stands. */
|
|
11
|
+
export const MAX_PASTE_CHARS = 1 << 20;
|
|
12
|
+
|
|
13
|
+
/** A chunk marker the read loop yields when input went idle mid-sequence. */
|
|
14
|
+
export const INPUT_FLUSH = "\u0000flush";
|
|
15
|
+
|
|
16
|
+
/** Idle time after which a partial escape prefix is a key of its own. */
|
|
17
|
+
export const ESCAPE_FLUSH_MS = 50;
|
|
18
|
+
/** Idle time after which an unterminated bracketed paste is emitted. */
|
|
19
|
+
export const PASTE_FLUSH_MS = 1_000;
|
|
20
|
+
|
|
10
21
|
export class TerminalInputDecoder {
|
|
11
22
|
private buffer = "";
|
|
12
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What the decoder holds back: `escape` for a partial escape sequence,
|
|
26
|
+
* `paste` for an unterminated bracketed paste. The caller flushes after
|
|
27
|
+
* `ESCAPE_FLUSH_MS` or `PASTE_FLUSH_MS` with no further input.
|
|
28
|
+
*/
|
|
29
|
+
pending(): "escape" | "paste" | undefined {
|
|
30
|
+
if (this.buffer.length === 0) return undefined;
|
|
31
|
+
return this.buffer.startsWith("\u001b[200~") ? "paste" : "escape";
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
push(chunk: string): TerminalInputEvent[] {
|
|
14
35
|
this.buffer += chunk;
|
|
15
36
|
const events: TerminalInputEvent[] = [];
|
|
16
37
|
while (this.buffer.length > 0) {
|
|
17
38
|
if (this.buffer.startsWith("\u001b[200~")) {
|
|
18
39
|
const end = this.buffer.indexOf("\u001b[201~", 6);
|
|
19
|
-
if (end < 0)
|
|
40
|
+
if (end < 0) {
|
|
41
|
+
if (this.buffer.length - 6 < MAX_PASTE_CHARS) break;
|
|
42
|
+
events.push({ type: "paste", text: this.buffer.slice(6) });
|
|
43
|
+
this.buffer = "";
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
20
46
|
events.push({ type: "paste", text: this.buffer.slice(6, end) });
|
|
21
47
|
this.buffer = this.buffer.slice(end + 6);
|
|
22
48
|
continue;
|
|
@@ -38,12 +64,27 @@ export class TerminalInputDecoder {
|
|
|
38
64
|
return events;
|
|
39
65
|
}
|
|
40
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Emits whatever is held back, once no more input is coming: a cut-off
|
|
69
|
+
* bracketed paste as a paste, a lone ESC as Esc, and a partial prefix such
|
|
70
|
+
* as `ESC [` (Alt+[) or `ESC O` (Alt+O) as one Alt key, then decodes the
|
|
71
|
+
* rest normally.
|
|
72
|
+
*/
|
|
41
73
|
flush(): TerminalInputEvent[] {
|
|
42
|
-
|
|
74
|
+
const events: TerminalInputEvent[] = [];
|
|
75
|
+
while (this.buffer.length > 0) {
|
|
76
|
+
if (this.buffer.startsWith("\u001b[200~")) {
|
|
77
|
+
events.push({ type: "paste", text: this.buffer.slice(6) });
|
|
78
|
+
this.buffer = "";
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const head =
|
|
82
|
+
this.buffer.length === 1 ? "\u001b" : this.buffer.slice(0, 2);
|
|
83
|
+
const rest = this.buffer.slice(head.length);
|
|
43
84
|
this.buffer = "";
|
|
44
|
-
|
|
85
|
+
events.push(head, ...this.push(rest));
|
|
45
86
|
}
|
|
46
|
-
return
|
|
87
|
+
return events;
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
private readEscapeSequence(): string | undefined {
|
|
@@ -59,12 +100,22 @@ export class TerminalInputDecoder {
|
|
|
59
100
|
return this.buffer.length >= 6 ? this.buffer.slice(0, 6) : undefined;
|
|
60
101
|
const final = this.buffer.match(/^\u001b\[[\x20-?]*[\x40-~]/)?.[0];
|
|
61
102
|
if (final !== undefined) return final;
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
103
|
+
// A byte that cannot continue a CSI ends it: the `ESC [` was Alt+[.
|
|
104
|
+
if (this.buffer.startsWith("\u001b[")) {
|
|
105
|
+
const partial = this.buffer.match(/^\u001b\[[\x20-?]*/)![0];
|
|
106
|
+
if (partial.length < this.buffer.length) return "\u001b[";
|
|
107
|
+
}
|
|
108
|
+
// SS3: arrows in application mode and F1-F4 (`ESC O P` is F1). A bare
|
|
109
|
+
// `ESC O` waits for its final byte, which a PTY may deliver separately;
|
|
110
|
+
// `flush` turns it into Alt+O if nothing follows.
|
|
111
|
+
if (this.buffer.startsWith("\u001bO")) {
|
|
112
|
+
if (this.buffer.length === 2) return undefined;
|
|
113
|
+
const ss3 = this.buffer.match(/^\u001bO[A-DHFPQRS]/)?.[0];
|
|
114
|
+
if (ss3 !== undefined) return ss3;
|
|
115
|
+
return "\u001bO";
|
|
116
|
+
}
|
|
65
117
|
// An unrecognised Alt-prefixed character is still one complete event.
|
|
66
|
-
if (this.buffer
|
|
67
|
-
return this.buffer.slice(0, 2);
|
|
118
|
+
if (this.buffer[1] !== "[") return this.buffer.slice(0, 2);
|
|
68
119
|
return undefined;
|
|
69
120
|
}
|
|
70
121
|
}
|
package/tui/keys.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The prompt is always focused, so printable keys always type. Control
|
|
5
5
|
* chords carry the global actions:
|
|
6
|
-
* Ctrl+C quit · Ctrl+Q toggle
|
|
6
|
+
* Ctrl+C quit · Ctrl+Q toggle NOW/NEXT · Ctrl+Z undo · Ctrl+Y redo
|
|
7
7
|
* Ctrl+O transcript overlay · Esc closes the overlay
|
|
8
8
|
*/
|
|
9
9
|
|