@hraness/dawg 0.0.0-stage → 0.3.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 +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
package/tui/highway.ts
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rhythm-game highway: notes fall from the top toward a fixed hit line.
|
|
3
|
+
*
|
|
4
|
+
* Every animation phase is derived from the transport beat (and the score's
|
|
5
|
+
* tempo), never from frame counts, so a slow terminal drops frames without
|
|
6
|
+
* changing what the music looks like at a given musical time.
|
|
7
|
+
*
|
|
8
|
+
* Horizontal placement is a pluggable `LaneProjection`: melodic tracks use a
|
|
9
|
+
* pitch projection fitted to the notes in view, drum tracks use one explicit
|
|
10
|
+
* lane per voice with a legend.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CellBuffer } from "./screen.ts";
|
|
14
|
+
import { truncate } from "./text.ts";
|
|
15
|
+
import {
|
|
16
|
+
accentStyle,
|
|
17
|
+
onBackground,
|
|
18
|
+
shade,
|
|
19
|
+
velocityStyle,
|
|
20
|
+
type Style,
|
|
21
|
+
type TerminalCapabilities,
|
|
22
|
+
type Theme,
|
|
23
|
+
} from "./theme.ts";
|
|
24
|
+
|
|
25
|
+
export interface NoteSnapshot {
|
|
26
|
+
id?: string | undefined;
|
|
27
|
+
/** Score time in beats. */
|
|
28
|
+
startBeat: number;
|
|
29
|
+
durationBeats?: number | undefined;
|
|
30
|
+
/** MIDI pitch, or a lane number for a drum track. */
|
|
31
|
+
pitch?: number | undefined;
|
|
32
|
+
lane?: number | undefined;
|
|
33
|
+
velocity?: number | undefined;
|
|
34
|
+
selected?: boolean | undefined;
|
|
35
|
+
pending?: boolean | undefined;
|
|
36
|
+
muted?: boolean | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TrackScoreSnapshot {
|
|
40
|
+
notes: readonly NoteSnapshot[];
|
|
41
|
+
trackName?: string | undefined;
|
|
42
|
+
trackId?: string | undefined;
|
|
43
|
+
sessionId?: string | undefined;
|
|
44
|
+
revision?: number | undefined;
|
|
45
|
+
bpm?: number | undefined;
|
|
46
|
+
key?: string | undefined;
|
|
47
|
+
beatsPerBar?: number | undefined;
|
|
48
|
+
loopBeats?: number | undefined;
|
|
49
|
+
laneCount?: number | undefined;
|
|
50
|
+
/** Optional lane legend (drum voices) drawn just below the hit line. */
|
|
51
|
+
laneLabels?: readonly string[] | undefined;
|
|
52
|
+
/** Explicit projection; overrides `laneCount`/`laneLabels` inference. */
|
|
53
|
+
projection?: LaneProjection | undefined;
|
|
54
|
+
/** Static transport position when the score is paused. */
|
|
55
|
+
transportBeat?: number | undefined;
|
|
56
|
+
/** Alias accepted by adapters that call this value currentBeat. */
|
|
57
|
+
currentBeat?: number | undefined;
|
|
58
|
+
transportStartedAtMs?: number | undefined;
|
|
59
|
+
playing?: boolean | undefined;
|
|
60
|
+
activity?: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Other unmuted tracks drawn dimmed under the focused track, each in its own
|
|
63
|
+
* accent. Empty or omitted in focus view.
|
|
64
|
+
*/
|
|
65
|
+
layers?: readonly HighwayLayer[] | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One background track overlaid on the highway. */
|
|
69
|
+
export interface HighwayLayer {
|
|
70
|
+
trackId: string;
|
|
71
|
+
trackName?: string | undefined;
|
|
72
|
+
notes: readonly NoteSnapshot[];
|
|
73
|
+
/** The layer's own lanes (drum voices); mapped onto the main projection. */
|
|
74
|
+
projection?: LaneProjection | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Bound on overlaid notes so a dense arrangement cannot stall a frame. */
|
|
78
|
+
export const MAX_LAYER_NOTES = 2048;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Lane projections
|
|
82
|
+
|
|
83
|
+
export interface LaneProjection {
|
|
84
|
+
readonly kind: string;
|
|
85
|
+
readonly laneCount: number;
|
|
86
|
+
/** Lane for a note, or undefined when the note is not drawable. */
|
|
87
|
+
laneOf(note: NoteSnapshot): number | undefined;
|
|
88
|
+
/** Optional legend text for a lane. */
|
|
89
|
+
label?(lane: number): string | undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One lane per declared voice; notes carry `lane` (drum voice index). */
|
|
93
|
+
export function explicitLaneProjection(
|
|
94
|
+
laneCount: number,
|
|
95
|
+
labels?: readonly string[],
|
|
96
|
+
kind = labels ? "drum" : "lane",
|
|
97
|
+
): LaneProjection {
|
|
98
|
+
const count = Math.max(1, Math.floor(laneCount));
|
|
99
|
+
return {
|
|
100
|
+
kind,
|
|
101
|
+
laneCount: count,
|
|
102
|
+
laneOf(note) {
|
|
103
|
+
const lane = note.lane ?? note.pitch;
|
|
104
|
+
if (lane === undefined || !Number.isFinite(lane)) return undefined;
|
|
105
|
+
return Math.max(0, Math.min(count - 1, Math.round(lane)));
|
|
106
|
+
},
|
|
107
|
+
label: labels ? (lane) => labels[lane] : undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Drum projection: lanes are named voices, left to right. */
|
|
112
|
+
export function drumLaneProjection(voices: readonly string[]): LaneProjection {
|
|
113
|
+
return explicitLaneProjection(voices.length, voices, "drum");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const NOTE_NAMES = [
|
|
117
|
+
"C",
|
|
118
|
+
"C#",
|
|
119
|
+
"D",
|
|
120
|
+
"D#",
|
|
121
|
+
"E",
|
|
122
|
+
"F",
|
|
123
|
+
"F#",
|
|
124
|
+
"G",
|
|
125
|
+
"G#",
|
|
126
|
+
"A",
|
|
127
|
+
"A#",
|
|
128
|
+
"B",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
export function pitchName(pitch: number): string {
|
|
132
|
+
const rounded = Math.round(pitch);
|
|
133
|
+
return `${NOTE_NAMES[((rounded % 12) + 12) % 12]}${Math.floor(rounded / 12) - 1}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Melodic projection fitted to the notes: at least one octave, padded by a
|
|
138
|
+
* semitone, so a bass line and a lead both use the whole highway width.
|
|
139
|
+
*/
|
|
140
|
+
export function pitchProjection(
|
|
141
|
+
notes: readonly NoteSnapshot[],
|
|
142
|
+
minimumSpan = 12,
|
|
143
|
+
): LaneProjection {
|
|
144
|
+
const pitches = notes
|
|
145
|
+
.map((note) => note.pitch)
|
|
146
|
+
.filter((pitch): pitch is number => Number.isFinite(pitch));
|
|
147
|
+
let low = pitches.length ? Math.min(...pitches) : 60;
|
|
148
|
+
let high = pitches.length ? Math.max(...pitches) : 71;
|
|
149
|
+
low = Math.max(0, Math.round(low) - 1);
|
|
150
|
+
high = Math.min(127, Math.round(high) + 1);
|
|
151
|
+
while (high - low + 1 < minimumSpan) {
|
|
152
|
+
if (low > 0) low -= 1;
|
|
153
|
+
if (high - low + 1 < minimumSpan && high < 127) high += 1;
|
|
154
|
+
if (low === 0 && high === 127) break;
|
|
155
|
+
}
|
|
156
|
+
const laneCount = high - low + 1;
|
|
157
|
+
return {
|
|
158
|
+
kind: "pitch",
|
|
159
|
+
laneCount,
|
|
160
|
+
laneOf(note) {
|
|
161
|
+
if (note.pitch === undefined || !Number.isFinite(note.pitch))
|
|
162
|
+
return note.lane;
|
|
163
|
+
return Math.max(0, Math.min(laneCount - 1, Math.round(note.pitch) - low));
|
|
164
|
+
},
|
|
165
|
+
label(lane) {
|
|
166
|
+
const pitch = low + lane;
|
|
167
|
+
return pitch % 12 === 0 ? pitchName(pitch) : undefined;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Choose the projection a snapshot implies. */
|
|
173
|
+
export function projectionFor(score: TrackScoreSnapshot): LaneProjection {
|
|
174
|
+
if (score.projection) return score.projection;
|
|
175
|
+
const explicit =
|
|
176
|
+
score.laneLabels !== undefined ||
|
|
177
|
+
score.notes.some((note) => note.lane !== undefined);
|
|
178
|
+
if (explicit)
|
|
179
|
+
return explicitLaneProjection(
|
|
180
|
+
score.laneCount ?? score.laneLabels?.length ?? 12,
|
|
181
|
+
score.laneLabels,
|
|
182
|
+
);
|
|
183
|
+
// Melodic overlays share the focused track's pitch axis, so the fit covers
|
|
184
|
+
// their pitches too and a bass line and a lead keep their real intervals.
|
|
185
|
+
const melodic = (score.layers ?? [])
|
|
186
|
+
.filter((layer) => isPitchLayer(layer))
|
|
187
|
+
.flatMap((layer) => layer.notes);
|
|
188
|
+
return pitchProjection(
|
|
189
|
+
melodic.length ? [...score.notes, ...melodic] : score.notes,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPitchLayer(layer: HighwayLayer): boolean {
|
|
194
|
+
return (
|
|
195
|
+
layer.projection === undefined &&
|
|
196
|
+
!layer.notes.some((note) => note.lane !== undefined)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Lane mapper for an overlay: pitch layers reuse a pitch main projection
|
|
202
|
+
* directly; anything else is projected on its own lanes and scaled across the
|
|
203
|
+
* main lane count.
|
|
204
|
+
*/
|
|
205
|
+
export function layerLaneOf(
|
|
206
|
+
layer: HighwayLayer,
|
|
207
|
+
main: LaneProjection,
|
|
208
|
+
): (note: NoteSnapshot) => number | undefined {
|
|
209
|
+
if (main.kind === "pitch" && isPitchLayer(layer))
|
|
210
|
+
return (note) => main.laneOf(note);
|
|
211
|
+
const own = layer.projection ?? projectionFor({ notes: layer.notes });
|
|
212
|
+
const scale = (main.laneCount - 1) / Math.max(1, own.laneCount - 1);
|
|
213
|
+
return (note) => {
|
|
214
|
+
const lane = own.laneOf(note);
|
|
215
|
+
if (lane === undefined) return undefined;
|
|
216
|
+
return Math.max(0, Math.min(main.laneCount - 1, Math.round(lane * scale)));
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Glyphs
|
|
222
|
+
|
|
223
|
+
export interface HighwayGlyphs {
|
|
224
|
+
density: readonly [string, string, string, string];
|
|
225
|
+
beam: string;
|
|
226
|
+
beamTip: string;
|
|
227
|
+
ghost: string;
|
|
228
|
+
ghostHead: string;
|
|
229
|
+
beatRule: string;
|
|
230
|
+
barRule: string;
|
|
231
|
+
loopRule: string;
|
|
232
|
+
hitStrong: string;
|
|
233
|
+
hitSoft: string;
|
|
234
|
+
target: string;
|
|
235
|
+
flash: string;
|
|
236
|
+
burst: readonly [string, string, string];
|
|
237
|
+
spark: string;
|
|
238
|
+
sparkFar: string;
|
|
239
|
+
sustainCore: string;
|
|
240
|
+
pending: string;
|
|
241
|
+
muted: string;
|
|
242
|
+
play: string;
|
|
243
|
+
pause: string;
|
|
244
|
+
loop: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const UNICODE_GLYPHS: HighwayGlyphs = {
|
|
248
|
+
density: ["░", "▒", "▓", "█"],
|
|
249
|
+
beam: "┃",
|
|
250
|
+
beamTip: "╻",
|
|
251
|
+
ghost: "╎",
|
|
252
|
+
ghostHead: "▫",
|
|
253
|
+
beatRule: "┈",
|
|
254
|
+
barRule: "─",
|
|
255
|
+
loopRule: "═",
|
|
256
|
+
hitStrong: "━",
|
|
257
|
+
hitSoft: "─",
|
|
258
|
+
target: "▔",
|
|
259
|
+
flash: "█",
|
|
260
|
+
burst: ["✸", "✦", "✧"],
|
|
261
|
+
spark: "✦",
|
|
262
|
+
sparkFar: "·",
|
|
263
|
+
sustainCore: "◆",
|
|
264
|
+
pending: "◇",
|
|
265
|
+
muted: "·",
|
|
266
|
+
play: "▶",
|
|
267
|
+
pause: "⏸",
|
|
268
|
+
loop: "↻",
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const ASCII_GLYPHS: HighwayGlyphs = {
|
|
272
|
+
density: [".", ":", "o", "#"],
|
|
273
|
+
beam: "|",
|
|
274
|
+
beamTip: "'",
|
|
275
|
+
ghost: ":",
|
|
276
|
+
ghostHead: ".",
|
|
277
|
+
beatRule: ".",
|
|
278
|
+
barRule: "-",
|
|
279
|
+
loopRule: "=",
|
|
280
|
+
hitStrong: "=",
|
|
281
|
+
hitSoft: "-",
|
|
282
|
+
target: "^",
|
|
283
|
+
flash: "#",
|
|
284
|
+
burst: ["*", "+", "."],
|
|
285
|
+
spark: "*",
|
|
286
|
+
sparkFar: ".",
|
|
287
|
+
sustainCore: "@",
|
|
288
|
+
pending: "o",
|
|
289
|
+
muted: ".",
|
|
290
|
+
play: ">",
|
|
291
|
+
pause: "|",
|
|
292
|
+
loop: "@",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export function glyphsFor(capabilities: TerminalCapabilities): HighwayGlyphs {
|
|
296
|
+
return capabilities.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function densityIndex(velocity: number | undefined): 0 | 1 | 2 | 3 {
|
|
300
|
+
const v = velocity ?? 0.8;
|
|
301
|
+
if (v < 0.3) return 0;
|
|
302
|
+
if (v < 0.55) return 1;
|
|
303
|
+
if (v < 0.8) return 2;
|
|
304
|
+
return 3;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Transport helpers
|
|
309
|
+
|
|
310
|
+
export function resolveBeat(score: TrackScoreSnapshot, nowMs: number): number {
|
|
311
|
+
const base = score.currentBeat ?? score.transportBeat ?? 0;
|
|
312
|
+
if (!score.playing || score.transportStartedAtMs === undefined) return base;
|
|
313
|
+
const bpm = Math.max(1, score.bpm ?? 120);
|
|
314
|
+
return (
|
|
315
|
+
base + (Math.max(0, nowMs - score.transportStartedAtMs) * bpm) / 60_000
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Milliseconds for a beat span at the score tempo. */
|
|
320
|
+
function beatsToMs(beats: number, bpm: number): number {
|
|
321
|
+
return (beats * 60_000) / Math.max(1, bpm);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Hit animation phases, in milliseconds after the note crosses the line. */
|
|
325
|
+
export const HIT_FLASH_MS = 80;
|
|
326
|
+
export const HIT_BURST_MS = 170;
|
|
327
|
+
export const HIT_FADE_MS = 260;
|
|
328
|
+
export const GHOST_BEATS = 0.5;
|
|
329
|
+
|
|
330
|
+
export type HitPhase =
|
|
331
|
+
"approach" | "flash" | "burst" | "fade" | "sustain" | "ghost" | "past";
|
|
332
|
+
|
|
333
|
+
/** Pure hit state machine for one note occurrence at one transport beat. */
|
|
334
|
+
export function hitPhase(
|
|
335
|
+
startBeat: number,
|
|
336
|
+
endBeat: number,
|
|
337
|
+
beat: number,
|
|
338
|
+
bpm: number,
|
|
339
|
+
): HitPhase {
|
|
340
|
+
if (beat < startBeat) return "approach";
|
|
341
|
+
const ageMs = beatsToMs(beat - startBeat, bpm);
|
|
342
|
+
if (ageMs < HIT_FLASH_MS) return "flash";
|
|
343
|
+
if (ageMs < HIT_BURST_MS) return "burst";
|
|
344
|
+
if (beat < endBeat) return "sustain";
|
|
345
|
+
if (ageMs < HIT_FADE_MS) return "fade";
|
|
346
|
+
if (beat - endBeat < GHOST_BEATS) return "ghost";
|
|
347
|
+
return "past";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Rendering
|
|
352
|
+
|
|
353
|
+
export interface HighwayRegion {
|
|
354
|
+
x: number;
|
|
355
|
+
y: number;
|
|
356
|
+
width: number;
|
|
357
|
+
height: number;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface HighwayOptions {
|
|
361
|
+
theme: Theme;
|
|
362
|
+
capabilities: TerminalCapabilities;
|
|
363
|
+
reducedMotion?: boolean;
|
|
364
|
+
/** Beats visible above the hit line; derived from height when omitted. */
|
|
365
|
+
lookaheadBeats?: number;
|
|
366
|
+
/** Background style painted under the highway. */
|
|
367
|
+
background?: Style;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export interface HighwayLayout {
|
|
371
|
+
hitRow: number;
|
|
372
|
+
gutter: number;
|
|
373
|
+
laneLeft: number;
|
|
374
|
+
laneWidth: number;
|
|
375
|
+
tileWidth: number;
|
|
376
|
+
laneCount: number;
|
|
377
|
+
rowsPerBeat: number;
|
|
378
|
+
legendRow: number | undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function highwayLayout(
|
|
382
|
+
region: HighwayRegion,
|
|
383
|
+
projection: LaneProjection,
|
|
384
|
+
lookaheadBeats?: number,
|
|
385
|
+
): HighwayLayout {
|
|
386
|
+
const legend = projection.label !== undefined && region.height >= 8 ? 1 : 0;
|
|
387
|
+
const below = Math.max(1, Math.min(3, Math.floor(region.height * 0.18)));
|
|
388
|
+
const hitRow = Math.max(1, region.height - below - 1 - legend);
|
|
389
|
+
const gutter = region.width >= 40 ? 3 : 0;
|
|
390
|
+
const area = Math.max(1, region.width - gutter);
|
|
391
|
+
const laneCount = projection.laneCount;
|
|
392
|
+
const laneWidth = laneCount <= area ? Math.floor(area / laneCount) : 0;
|
|
393
|
+
const used = laneWidth > 0 ? laneWidth * laneCount : area;
|
|
394
|
+
const laneLeft = gutter + Math.floor((area - used) / 2);
|
|
395
|
+
const tileWidth =
|
|
396
|
+
laneWidth >= 4 ? laneWidth - 1 : Math.max(1, laneWidth || 1);
|
|
397
|
+
const lookahead =
|
|
398
|
+
lookaheadBeats ?? Math.max(2, Math.min(8, Math.round(hitRow / 2.5)));
|
|
399
|
+
return {
|
|
400
|
+
hitRow,
|
|
401
|
+
gutter,
|
|
402
|
+
laneLeft,
|
|
403
|
+
laneWidth,
|
|
404
|
+
tileWidth,
|
|
405
|
+
laneCount,
|
|
406
|
+
rowsPerBeat: hitRow / Math.max(0.5, lookahead),
|
|
407
|
+
legendRow: legend ? region.height - 1 : undefined,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function laneX(layout: HighwayLayout, lane: number, areaWidth: number): number {
|
|
412
|
+
if (layout.laneWidth > 0)
|
|
413
|
+
return (
|
|
414
|
+
layout.laneLeft +
|
|
415
|
+
lane * layout.laneWidth +
|
|
416
|
+
(layout.laneWidth >= 4 ? 0 : 0)
|
|
417
|
+
);
|
|
418
|
+
return (
|
|
419
|
+
layout.gutter +
|
|
420
|
+
Math.floor((lane * areaWidth) / Math.max(1, layout.laneCount))
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
interface Painter {
|
|
425
|
+
put(x: number, y: number, ch: string, style: Style): void;
|
|
426
|
+
restyle(x: number, y: number, style: Style): void;
|
|
427
|
+
isBlank(x: number, y: number): boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Paint the highway for one transport beat into `buffer` at `region`.
|
|
432
|
+
* Returns the layout for callers that need the hit-line row.
|
|
433
|
+
*/
|
|
434
|
+
export function paintHighway(
|
|
435
|
+
buffer: CellBuffer,
|
|
436
|
+
region: HighwayRegion,
|
|
437
|
+
score: TrackScoreSnapshot,
|
|
438
|
+
beat: number,
|
|
439
|
+
options: HighwayOptions,
|
|
440
|
+
): HighwayLayout {
|
|
441
|
+
const { theme, capabilities } = options;
|
|
442
|
+
const roles = theme.roles;
|
|
443
|
+
const reduced = options.reducedMotion ?? false;
|
|
444
|
+
const glyphs = glyphsFor(capabilities);
|
|
445
|
+
const background = options.background ?? roles.canvas;
|
|
446
|
+
const bg = (style: Style): Style => onBackground(style, background);
|
|
447
|
+
const projection = projectionFor(score);
|
|
448
|
+
const layout = highwayLayout(region, projection, options.lookaheadBeats);
|
|
449
|
+
const { hitRow, rowsPerBeat, gutter } = layout;
|
|
450
|
+
const areaWidth = Math.max(1, region.width - gutter);
|
|
451
|
+
const bpm = Math.max(1, score.bpm ?? 120);
|
|
452
|
+
const beatsPerBar = Math.max(1, Math.round(score.beatsPerBar ?? 4));
|
|
453
|
+
const loop =
|
|
454
|
+
score.loopBeats && Number.isFinite(score.loopBeats) && score.loopBeats > 0
|
|
455
|
+
? score.loopBeats
|
|
456
|
+
: undefined;
|
|
457
|
+
|
|
458
|
+
buffer.fill(region.x, region.y, region.width, region.height, background);
|
|
459
|
+
const painter: Painter = {
|
|
460
|
+
put(x, y, ch, style) {
|
|
461
|
+
if (x < 0 || y < 0 || x >= region.width || y >= region.height) return;
|
|
462
|
+
buffer.set(region.x + x, region.y + y, ch, bg(style));
|
|
463
|
+
},
|
|
464
|
+
restyle(x, y, style) {
|
|
465
|
+
if (x < 0 || y < 0 || x >= region.width || y >= region.height) return;
|
|
466
|
+
buffer.restyle(region.x + x, region.y + y, bg(style));
|
|
467
|
+
},
|
|
468
|
+
isBlank(x, y) {
|
|
469
|
+
return buffer.get(region.x + x, region.y + y)?.ch === " ";
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const rowBeat = (row: number): number => beat + (hitRow - row) / rowsPerBeat;
|
|
474
|
+
const legendRow = layout.legendRow;
|
|
475
|
+
const lastNoteRow = (legendRow ?? region.height) - 1;
|
|
476
|
+
// Nothing to label yet: bar numbers and the lane legend stay off and a
|
|
477
|
+
// centred hint says how to start. The hit line and grid still draw.
|
|
478
|
+
const empty =
|
|
479
|
+
score.notes.length === 0 &&
|
|
480
|
+
!(score.layers ?? []).some((layer) => layer.notes.length > 0);
|
|
481
|
+
|
|
482
|
+
// Beat, bar, and loop rules of increasing strength.
|
|
483
|
+
for (let row = 0; row <= lastNoteRow; row += 1) {
|
|
484
|
+
if (row === hitRow) continue;
|
|
485
|
+
const center = rowBeat(row);
|
|
486
|
+
const half = 0.5 / rowsPerBeat;
|
|
487
|
+
const k = Math.ceil(center - half - 1e-9);
|
|
488
|
+
if (k > center + half - 1e-9 || k < 0) continue;
|
|
489
|
+
const inLoop = loop ? ((k % loop) + loop) % loop : k;
|
|
490
|
+
const isLoop = loop !== undefined && Math.abs(inLoop) < 1e-9;
|
|
491
|
+
const isBar = Math.abs(inLoop % beatsPerBar) < 1e-9;
|
|
492
|
+
const glyph = isLoop
|
|
493
|
+
? glyphs.loopRule
|
|
494
|
+
: isBar
|
|
495
|
+
? glyphs.barRule
|
|
496
|
+
: glyphs.beatRule;
|
|
497
|
+
const style = isLoop
|
|
498
|
+
? roles.loopRule
|
|
499
|
+
: isBar
|
|
500
|
+
? roles.barRule
|
|
501
|
+
: roles.beatRule;
|
|
502
|
+
for (let column = gutter; column < region.width; column += 1) {
|
|
503
|
+
if (!isBar && !isLoop && (column - gutter) % 2 === 1) continue;
|
|
504
|
+
painter.put(column, row, glyph, style);
|
|
505
|
+
}
|
|
506
|
+
if (gutter > 0 && (isBar || isLoop) && !empty) {
|
|
507
|
+
const label = isLoop
|
|
508
|
+
? glyphs.loop
|
|
509
|
+
: String(Math.floor(inLoop / beatsPerBar) + 1);
|
|
510
|
+
const text = label.slice(-(gutter - 1)).padStart(gutter - 1, " ");
|
|
511
|
+
for (let index = 0; index < text.length; index += 1)
|
|
512
|
+
painter.put(
|
|
513
|
+
index,
|
|
514
|
+
row,
|
|
515
|
+
text[index]!,
|
|
516
|
+
isLoop ? roles.loopRule : roles.muted,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Hit line with a beat pulse; motion off leaves it static and heavy.
|
|
522
|
+
const phase = ((beat % 1) + 1) % 1;
|
|
523
|
+
const pulse = reduced || !score.playing ? 0 : (1 - phase) ** 2;
|
|
524
|
+
const strong = reduced || !score.playing || phase < 0.15;
|
|
525
|
+
const hitStyle = shade(roles.hitLine, pulse * 0.5);
|
|
526
|
+
for (let column = gutter; column < region.width; column += 1)
|
|
527
|
+
painter.put(
|
|
528
|
+
column,
|
|
529
|
+
hitRow,
|
|
530
|
+
strong ? glyphs.hitStrong : glyphs.hitSoft,
|
|
531
|
+
strong && pulse > 0 ? { ...hitStyle, bold: true } : hitStyle,
|
|
532
|
+
);
|
|
533
|
+
if (gutter > 0) {
|
|
534
|
+
const marker = score.playing ? glyphs.play : glyphs.pause;
|
|
535
|
+
painter.put(
|
|
536
|
+
gutter - 2,
|
|
537
|
+
hitRow,
|
|
538
|
+
marker,
|
|
539
|
+
score.playing ? roles.transport : roles.paused,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Loop-wrap sweep: a bright band crosses the hit line during the first beat
|
|
544
|
+
// after the transport wraps.
|
|
545
|
+
if (loop && !reduced && score.playing && beat >= loop * 0.5) {
|
|
546
|
+
const loopPhase = ((beat % loop) + loop) % loop;
|
|
547
|
+
if (loopPhase < 1) {
|
|
548
|
+
const head = Math.round(gutter + loopPhase * areaWidth);
|
|
549
|
+
for (let offset = 0; offset < 6; offset += 1) {
|
|
550
|
+
const column = head - offset;
|
|
551
|
+
if (column < gutter || column >= region.width) continue;
|
|
552
|
+
painter.put(
|
|
553
|
+
column,
|
|
554
|
+
hitRow,
|
|
555
|
+
glyphs.loopRule,
|
|
556
|
+
shade(roles.loopRule, 0.5 - offset * 0.1),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (empty) {
|
|
563
|
+
const name = score.trackName ?? score.trackId ?? "track";
|
|
564
|
+
const start = projection.kind === "pitch" ? "add C4 at 0" : "hit kick at 0";
|
|
565
|
+
const text = truncate(`${name} · empty · ${start} to start`, areaWidth);
|
|
566
|
+
const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
|
|
567
|
+
const y = Math.max(0, Math.floor(hitRow / 2));
|
|
568
|
+
for (let index = 0; index < text.length; index += 1)
|
|
569
|
+
painter.put(x + index, y, text[index]!, roles.muted);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Legend for drum voices (or C-octave markers for pitch lanes).
|
|
573
|
+
if (legendRow !== undefined && projection.label && !empty) {
|
|
574
|
+
let nextFree = 0;
|
|
575
|
+
for (let lane = 0; lane < layout.laneCount; lane += 1) {
|
|
576
|
+
const label = projection.label(lane);
|
|
577
|
+
if (!label) continue;
|
|
578
|
+
const x = laneX(layout, lane, areaWidth);
|
|
579
|
+
const room =
|
|
580
|
+
layout.laneWidth > 0
|
|
581
|
+
? Math.max(1, projection.kind === "pitch" ? 4 : layout.laneWidth - 1)
|
|
582
|
+
: 3;
|
|
583
|
+
const text = label.slice(0, room);
|
|
584
|
+
const start = Math.max(nextFree, Math.min(region.width - text.length, x));
|
|
585
|
+
if (start + text.length > region.width) continue;
|
|
586
|
+
for (let index = 0; index < text.length; index += 1)
|
|
587
|
+
painter.put(start + index, legendRow, text[index]!, roles.muted);
|
|
588
|
+
nextFree = start + text.length + 1;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Note occurrences across loop repeats, ordered so active notes draw last.
|
|
593
|
+
const belowBeats = (lastNoteRow - hitRow + 1) / rowsPerBeat;
|
|
594
|
+
const aheadBeats = hitRow / rowsPerBeat + 1 / rowsPerBeat;
|
|
595
|
+
const accent = accentStyle(theme, score.trackId ?? score.trackName);
|
|
596
|
+
interface Occurrence {
|
|
597
|
+
note: NoteSnapshot;
|
|
598
|
+
lane: number;
|
|
599
|
+
start: number;
|
|
600
|
+
end: number;
|
|
601
|
+
phase: HitPhase;
|
|
602
|
+
accent: Style;
|
|
603
|
+
dim: boolean;
|
|
604
|
+
}
|
|
605
|
+
const occurrences: Occurrence[] = [];
|
|
606
|
+
const sources: {
|
|
607
|
+
note: NoteSnapshot;
|
|
608
|
+
laneOf: (note: NoteSnapshot) => number | undefined;
|
|
609
|
+
accent: Style;
|
|
610
|
+
dim: boolean;
|
|
611
|
+
}[] = [];
|
|
612
|
+
let layerBudget = MAX_LAYER_NOTES;
|
|
613
|
+
for (const layer of score.layers ?? []) {
|
|
614
|
+
if (layer.trackId === score.trackId) continue;
|
|
615
|
+
const laneOf = layerLaneOf(layer, projection);
|
|
616
|
+
const layerAccent = accentStyle(theme, layer.trackId);
|
|
617
|
+
for (const note of layer.notes) {
|
|
618
|
+
if (layerBudget <= 0) break;
|
|
619
|
+
layerBudget -= 1;
|
|
620
|
+
sources.push({ note, laneOf, accent: layerAccent, dim: true });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const focusLaneOf = (note: NoteSnapshot) => projection.laneOf(note);
|
|
624
|
+
for (const note of score.notes)
|
|
625
|
+
sources.push({ note, laneOf: focusLaneOf, accent, dim: false });
|
|
626
|
+
for (const source of sources) {
|
|
627
|
+
const { note } = source;
|
|
628
|
+
if (!Number.isFinite(note.startBeat)) continue;
|
|
629
|
+
const lane = source.laneOf(note);
|
|
630
|
+
if (lane === undefined) continue;
|
|
631
|
+
const duration = Math.max(0, note.durationBeats ?? 0);
|
|
632
|
+
const starts: number[] = [];
|
|
633
|
+
if (loop) {
|
|
634
|
+
const first = Math.floor(
|
|
635
|
+
(beat - belowBeats - duration - note.startBeat) / loop,
|
|
636
|
+
);
|
|
637
|
+
const last = Math.ceil((beat + aheadBeats - note.startBeat) / loop);
|
|
638
|
+
for (let k = Math.max(0, first); k <= last; k += 1)
|
|
639
|
+
starts.push(note.startBeat + k * loop);
|
|
640
|
+
} else starts.push(note.startBeat);
|
|
641
|
+
for (const start of starts) {
|
|
642
|
+
const end = start + duration;
|
|
643
|
+
if (end < beat - belowBeats || start > beat + aheadBeats) continue;
|
|
644
|
+
occurrences.push({
|
|
645
|
+
note,
|
|
646
|
+
lane,
|
|
647
|
+
start,
|
|
648
|
+
end,
|
|
649
|
+
phase: hitPhase(start, end, beat, bpm),
|
|
650
|
+
accent: source.accent,
|
|
651
|
+
dim: source.dim,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const order: Record<HitPhase, number> = {
|
|
656
|
+
past: 0,
|
|
657
|
+
ghost: 1,
|
|
658
|
+
approach: 2,
|
|
659
|
+
sustain: 3,
|
|
660
|
+
fade: 4,
|
|
661
|
+
burst: 5,
|
|
662
|
+
flash: 6,
|
|
663
|
+
};
|
|
664
|
+
// Dimmed overlays first so the focused track always draws on top.
|
|
665
|
+
occurrences.sort(
|
|
666
|
+
(a, b) =>
|
|
667
|
+
Number(b.dim) - Number(a.dim) ||
|
|
668
|
+
order[a.phase] - order[b.phase] ||
|
|
669
|
+
b.start - a.start,
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
const rowOf = (time: number): number =>
|
|
673
|
+
Math.round(hitRow - (time - beat) * rowsPerBeat);
|
|
674
|
+
|
|
675
|
+
for (const item of occurrences) {
|
|
676
|
+
const { note } = item;
|
|
677
|
+
const x = laneX(layout, item.lane, areaWidth);
|
|
678
|
+
const width = layout.laneWidth > 0 ? layout.tileWidth : 1;
|
|
679
|
+
const center = x + Math.floor((width - 1) / 2);
|
|
680
|
+
const velocity = Math.max(0, Math.min(1, note.velocity ?? 0.8));
|
|
681
|
+
if (item.dim) {
|
|
682
|
+
paintDimmed(painter, glyphs, roles, item, {
|
|
683
|
+
x,
|
|
684
|
+
width,
|
|
685
|
+
center,
|
|
686
|
+
headRow: rowOf(item.start),
|
|
687
|
+
tailRow: rowOf(item.end),
|
|
688
|
+
hitRow,
|
|
689
|
+
lastNoteRow,
|
|
690
|
+
sustaining: beat < item.end,
|
|
691
|
+
style: shade(velocityStyle(item.accent, velocity), -0.5),
|
|
692
|
+
});
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
let base = velocityStyle(accent, velocity);
|
|
696
|
+
if (note.muted) base = roles.mutedNote;
|
|
697
|
+
else if (note.pending)
|
|
698
|
+
base = { ...base, ...roles.pending, fg: roles.pending.fg ?? base.fg };
|
|
699
|
+
if (note.selected) base = { ...base, reverse: true };
|
|
700
|
+
const density = glyphs.density[densityIndex(note.velocity)];
|
|
701
|
+
const headGlyph = note.muted
|
|
702
|
+
? glyphs.muted
|
|
703
|
+
: note.pending
|
|
704
|
+
? glyphs.pending
|
|
705
|
+
: density;
|
|
706
|
+
const tile = (
|
|
707
|
+
row: number,
|
|
708
|
+
ch: string,
|
|
709
|
+
style: Style,
|
|
710
|
+
centerCh?: string,
|
|
711
|
+
): void => {
|
|
712
|
+
if (row < 0 || row > lastNoteRow) return;
|
|
713
|
+
for (let offset = 0; offset < width; offset += 1) {
|
|
714
|
+
const column = x + offset;
|
|
715
|
+
const glyph =
|
|
716
|
+
centerCh !== undefined && column === center ? centerCh : ch;
|
|
717
|
+
painter.put(column, row, glyph, style);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
const beam = (fromRow: number, toRow: number, tipFade: boolean): void => {
|
|
721
|
+
// Rows strictly between head and tail; upper rows decay toward the tip.
|
|
722
|
+
const top = Math.max(0, Math.min(fromRow, toRow));
|
|
723
|
+
const bottom = Math.min(lastNoteRow, Math.max(fromRow, toRow));
|
|
724
|
+
const span = Math.max(1, bottom - top);
|
|
725
|
+
for (let row = top; row <= bottom; row += 1) {
|
|
726
|
+
const decay = tipFade ? (bottom - row) / span : 0;
|
|
727
|
+
const style = note.muted
|
|
728
|
+
? roles.mutedNote
|
|
729
|
+
: shade(
|
|
730
|
+
{
|
|
731
|
+
...base,
|
|
732
|
+
fg:
|
|
733
|
+
roles.sustain.fg && !note.pending
|
|
734
|
+
? mixAccent(base, roles.sustain)
|
|
735
|
+
: base.fg,
|
|
736
|
+
},
|
|
737
|
+
-0.55 * decay,
|
|
738
|
+
);
|
|
739
|
+
const glyph = row === top && span > 1 ? glyphs.beamTip : glyphs.beam;
|
|
740
|
+
painter.put(center, row, glyph, style);
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const headRow = rowOf(item.start);
|
|
745
|
+
const tailRow = rowOf(item.end);
|
|
746
|
+
switch (item.phase) {
|
|
747
|
+
case "approach": {
|
|
748
|
+
if (tailRow < headRow - 1)
|
|
749
|
+
beam(tailRow, Math.min(headRow - 1, hitRow - 1), true);
|
|
750
|
+
const distance = item.start - beat;
|
|
751
|
+
const glow = reduced ? 0 : Math.max(0, 1 - distance);
|
|
752
|
+
const style = note.muted
|
|
753
|
+
? base
|
|
754
|
+
: glow > 0.6
|
|
755
|
+
? { ...shade(base, 0.45 * glow), bold: true }
|
|
756
|
+
: shade(base, 0.45 * glow);
|
|
757
|
+
if (headRow <= hitRow - 1) tile(headRow, headGlyph, style);
|
|
758
|
+
// The target on the hit line lights up as the note approaches.
|
|
759
|
+
if (!note.muted && glow > 0 && width > 0) {
|
|
760
|
+
for (let offset = 0; offset < width; offset += 1)
|
|
761
|
+
painter.put(x + offset, hitRow, glyphs.hitStrong, {
|
|
762
|
+
...shade(accent, glow * 0.3 - 0.3),
|
|
763
|
+
bold: glow > 0.5,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
case "flash":
|
|
769
|
+
case "burst":
|
|
770
|
+
case "fade":
|
|
771
|
+
case "sustain": {
|
|
772
|
+
if (note.muted) {
|
|
773
|
+
tile(hitRow, glyphs.muted, roles.mutedNote);
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
const sustaining = beat < item.end;
|
|
777
|
+
if (sustaining && tailRow < hitRow) beam(tailRow, hitRow - 1, true);
|
|
778
|
+
const phaseStyle = reduced ? "sustain" : item.phase;
|
|
779
|
+
if (phaseStyle === "flash") {
|
|
780
|
+
tile(hitRow, glyphs.flash, roles.hit);
|
|
781
|
+
tile(
|
|
782
|
+
hitRow - 1,
|
|
783
|
+
glyphs.burst[0],
|
|
784
|
+
{ ...roles.hit, reverse: false },
|
|
785
|
+
glyphs.burst[0],
|
|
786
|
+
);
|
|
787
|
+
painter.put(x - 1, hitRow, glyphs.spark, roles.hit);
|
|
788
|
+
painter.put(x + width, hitRow, glyphs.spark, roles.hit);
|
|
789
|
+
} else if (phaseStyle === "burst") {
|
|
790
|
+
tile(
|
|
791
|
+
hitRow,
|
|
792
|
+
glyphs.density[2],
|
|
793
|
+
shade(roles.hit, -0.1),
|
|
794
|
+
glyphs.burst[0],
|
|
795
|
+
);
|
|
796
|
+
painter.put(x - 2, hitRow, glyphs.sparkFar, shade(roles.hit, -0.3));
|
|
797
|
+
painter.put(
|
|
798
|
+
x + width + 1,
|
|
799
|
+
hitRow,
|
|
800
|
+
glyphs.sparkFar,
|
|
801
|
+
shade(roles.hit, -0.3),
|
|
802
|
+
);
|
|
803
|
+
painter.put(
|
|
804
|
+
center,
|
|
805
|
+
hitRow - 1,
|
|
806
|
+
glyphs.burst[1],
|
|
807
|
+
shade(roles.hit, -0.2),
|
|
808
|
+
);
|
|
809
|
+
} else if (phaseStyle === "fade") {
|
|
810
|
+
tile(hitRow, glyphs.density[1], shade(base, -0.2), glyphs.burst[2]);
|
|
811
|
+
} else if (sustaining) {
|
|
812
|
+
// Held note: the core breathes with the beat and decays over time.
|
|
813
|
+
const progress = Math.max(
|
|
814
|
+
0,
|
|
815
|
+
Math.min(
|
|
816
|
+
1,
|
|
817
|
+
(beat - item.start) / Math.max(1e-6, item.end - item.start),
|
|
818
|
+
),
|
|
819
|
+
);
|
|
820
|
+
const breathe = reduced ? 0 : 0.25 * (1 - phase);
|
|
821
|
+
const core = shade(
|
|
822
|
+
mixStyle(base, roles.sustain),
|
|
823
|
+
breathe - 0.35 * progress,
|
|
824
|
+
);
|
|
825
|
+
tile(
|
|
826
|
+
hitRow,
|
|
827
|
+
glyphs.hitStrong,
|
|
828
|
+
{ ...core, bold: true },
|
|
829
|
+
glyphs.sustainCore,
|
|
830
|
+
);
|
|
831
|
+
} else {
|
|
832
|
+
tile(hitRow, glyphs.density[1], shade(base, -0.3), glyphs.burst[2]);
|
|
833
|
+
}
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
case "ghost":
|
|
837
|
+
case "past": {
|
|
838
|
+
// Post-release ghost at the hit line, then faint passed notes below.
|
|
839
|
+
if (item.phase === "ghost" && !reduced)
|
|
840
|
+
painter.put(center, hitRow, glyphs.ghost, roles.ghost);
|
|
841
|
+
const from = Math.max(hitRow + 1, tailRow);
|
|
842
|
+
const to = Math.min(lastNoteRow, headRow);
|
|
843
|
+
for (let row = from; row <= to; row += 1) {
|
|
844
|
+
const glyph = row === to ? glyphs.ghostHead : glyphs.ghost;
|
|
845
|
+
if (painter.isBlank(center, row) || row === to)
|
|
846
|
+
painter.put(center, row, glyph, roles.ghost);
|
|
847
|
+
}
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return layout;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* A background track: a quiet head and sustain line in the track's accent,
|
|
857
|
+
* no hit flash, sparks or ghosts, so the focused track stays readable.
|
|
858
|
+
*/
|
|
859
|
+
function paintDimmed(
|
|
860
|
+
painter: Painter,
|
|
861
|
+
glyphs: HighwayGlyphs,
|
|
862
|
+
roles: Theme["roles"],
|
|
863
|
+
item: { note: NoteSnapshot; phase: HitPhase },
|
|
864
|
+
geometry: {
|
|
865
|
+
x: number;
|
|
866
|
+
width: number;
|
|
867
|
+
center: number;
|
|
868
|
+
headRow: number;
|
|
869
|
+
tailRow: number;
|
|
870
|
+
hitRow: number;
|
|
871
|
+
lastNoteRow: number;
|
|
872
|
+
sustaining: boolean;
|
|
873
|
+
style: Style;
|
|
874
|
+
},
|
|
875
|
+
): void {
|
|
876
|
+
const { x, width, center, headRow, tailRow, hitRow, lastNoteRow } = geometry;
|
|
877
|
+
const style = item.note.muted ? roles.mutedNote : geometry.style;
|
|
878
|
+
const head = glyphs.density[Math.min(1, densityIndex(item.note.velocity))]!;
|
|
879
|
+
const tile = (row: number, ch: string): void => {
|
|
880
|
+
if (row < 0 || row > lastNoteRow) return;
|
|
881
|
+
for (let offset = 0; offset < width; offset += 1)
|
|
882
|
+
painter.put(x + offset, row, ch, style);
|
|
883
|
+
};
|
|
884
|
+
if (item.phase === "approach") {
|
|
885
|
+
for (
|
|
886
|
+
let row = Math.max(0, tailRow);
|
|
887
|
+
row < Math.min(headRow, hitRow);
|
|
888
|
+
row += 1
|
|
889
|
+
)
|
|
890
|
+
painter.put(center, row, glyphs.beam, style);
|
|
891
|
+
if (headRow <= hitRow - 1) tile(headRow, head);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (item.phase === "past" || item.phase === "ghost") return;
|
|
895
|
+
if (geometry.sustaining && tailRow < hitRow)
|
|
896
|
+
for (let row = Math.max(0, tailRow); row < hitRow; row += 1)
|
|
897
|
+
painter.put(center, row, glyphs.beam, style);
|
|
898
|
+
tile(hitRow, head);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function mixStyle(base: Style, overlay: Style): Style {
|
|
902
|
+
if (!base.fg || !overlay.fg)
|
|
903
|
+
return { ...base, ...overlay, fg: base.fg ?? overlay.fg };
|
|
904
|
+
return { ...base, fg: mixRgb(base.fg, overlay.fg, 0.35) };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function mixAccent(base: Style, overlay: Style): Style["fg"] {
|
|
908
|
+
return mixStyle(base, overlay).fg;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function mixRgb(
|
|
912
|
+
from: NonNullable<Style["fg"]>,
|
|
913
|
+
to: NonNullable<Style["fg"]>,
|
|
914
|
+
amount: number,
|
|
915
|
+
): NonNullable<Style["fg"]> {
|
|
916
|
+
return {
|
|
917
|
+
r: Math.round(from.r + (to.r - from.r) * amount),
|
|
918
|
+
g: Math.round(from.g + (to.g - from.g) * amount),
|
|
919
|
+
b: Math.round(from.b + (to.b - from.b) * amount),
|
|
920
|
+
};
|
|
921
|
+
}
|