Add SECURITY.md, update CONTRIBUTING, issue templates, Lighthouse fixes

This commit is contained in:
Fabricio Giglio
2026-05-06 22:38:17 +02:00
parent 451054c559
commit 4bb718ae08
14 changed files with 1115 additions and 517 deletions
@@ -1,7 +1,13 @@
import * as PIXI from "pixi.js";
import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared";
import type { NoteEvent } from "@bach-to-basics/shared";
import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, ImpactStyle } from "../../store/useAppStore";
import type {
NoteFilter,
ColorTheme,
CustomColors,
NoteLabelMode,
ImpactStyle,
} from "../../store/useAppStore";
import type { SustainRange } from "@bach-to-basics/shared";
import { lighten, hexToNum } from "../../utils/colorUtils";
@@ -10,7 +16,9 @@ const MIDI_MIN = 21;
const MIDI_MAX = 108;
const POOL_SIZE = 1200;
function isWhite(midi: number) { return !isBlackKey(midi); }
function isWhite(midi: number) {
return !isBlackKey(midi);
}
const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0);
let wi = 0;
@@ -33,24 +41,44 @@ interface ThemePalette {
const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = {
violet: {
left: 0x7c3aed, right: 0x8b5cf6, unknown: 0x8b5cf6,
leftBlack: 0xa78bfa, rightBlack: 0xc4b5fd, unknownBlack: 0xc4b5fd,
left: 0x7c3aed,
right: 0x8b5cf6,
unknown: 0x8b5cf6,
leftBlack: 0xa78bfa,
rightBlack: 0xc4b5fd,
unknownBlack: 0xc4b5fd,
},
classic: {
left: 0x2563eb, right: 0xdc2626, unknown: 0x7c3aed,
leftBlack: 0x60a5fa, rightBlack: 0xf87171, unknownBlack: 0xa78bfa,
left: 0x2563eb,
right: 0xdc2626,
unknown: 0x7c3aed,
leftBlack: 0x60a5fa,
rightBlack: 0xf87171,
unknownBlack: 0xa78bfa,
},
ocean: {
left: 0x0284c7, right: 0x06b6d4, unknown: 0x0ea5e9,
leftBlack: 0x38bdf8, rightBlack: 0x67e8f9, unknownBlack: 0x7dd3fc,
left: 0x0284c7,
right: 0x06b6d4,
unknown: 0x0ea5e9,
leftBlack: 0x38bdf8,
rightBlack: 0x67e8f9,
unknownBlack: 0x7dd3fc,
},
forest: {
left: 0x15803d, right: 0x16a34a, unknown: 0x22c55e,
leftBlack: 0x4ade80, rightBlack: 0x86efac, unknownBlack: 0xbbf7d0,
left: 0x15803d,
right: 0x16a34a,
unknown: 0x22c55e,
leftBlack: 0x4ade80,
rightBlack: 0x86efac,
unknownBlack: 0xbbf7d0,
},
cascade: {
left: 0x9333ea, right: 0x22d3ee, unknown: 0xa855f7,
leftBlack: 0xb06af5, rightBlack: 0x4dd5ec, unknownBlack: 0xb87af8,
left: 0x9333ea,
right: 0x22d3ee,
unknown: 0xa855f7,
leftBlack: 0xb06af5,
rightBlack: 0x4dd5ec,
unknownBlack: 0xb87af8,
},
};
@@ -60,11 +88,11 @@ function paletteFromCustom(c: CustomColors): ThemePalette {
const r = hexToNum(c.rightHand);
const u = hexToNum(c.unknown);
return {
left: l,
right: r,
unknown: u,
leftBlack: lighten(l, 1.55),
rightBlack: lighten(r, 1.55),
left: l,
right: r,
unknown: u,
leftBlack: lighten(l, 1.55),
rightBlack: lighten(r, 1.55),
unknownBlack: lighten(u, 1.55),
};
}
@@ -73,16 +101,17 @@ function noteColor(
note: NoteEvent,
showHandColors: boolean,
theme: ColorTheme,
customColors?: CustomColors,
customColors?: CustomColors
): number {
const pal = theme === "custom" && customColors
? paletteFromCustom(customColors)
: THEMES[theme as Exclude<ColorTheme, "custom">];
const pal =
theme === "custom" && customColors
? paletteFromCustom(customColors)
: THEMES[theme as Exclude<ColorTheme, "custom">];
const black = isBlackKey(note.midi);
if (!showHandColors || note.hand === "unknown") {
return black ? pal.unknownBlack : pal.unknown;
}
if (note.hand === "left") return black ? pal.leftBlack : pal.left;
if (note.hand === "left") return black ? pal.leftBlack : pal.left;
if (note.hand === "right") return black ? pal.rightBlack : pal.right;
return black ? pal.unknownBlack : pal.unknown;
}
@@ -93,13 +122,16 @@ function noteColor(
* Unlike multiplicative lighten(), this NEVER blows light colours to pure white.
*/
function mixColor(c1: number, c2: number, t: number): number {
const r1 = (c1 >> 16) & 0xff, r2 = (c2 >> 16) & 0xff;
const g1 = (c1 >> 8) & 0xff, g2 = (c2 >> 8) & 0xff;
const b1 = c1 & 0xff, b2 = c2 & 0xff;
const r1 = (c1 >> 16) & 0xff,
r2 = (c2 >> 16) & 0xff;
const g1 = (c1 >> 8) & 0xff,
g2 = (c2 >> 8) & 0xff;
const b1 = c1 & 0xff,
b2 = c2 & 0xff;
return (
(Math.round(r1 + (r2 - r1) * t) << 16) |
(Math.round(g1 + (g2 - g1) * t) << 8) |
Math.round(b1 + (b2 - b1) * t)
(Math.round(g1 + (g2 - g1) * t) << 8) |
Math.round(b1 + (b2 - b1) * t)
);
}
@@ -107,13 +139,13 @@ function makeGradient(base: number): PIXI.FillGradient {
return new PIXI.FillGradient({
type: "linear",
start: { x: 0, y: 0 },
end: { x: 0, y: 1 },
end: { x: 0, y: 1 },
textureSpace: "local",
colorStops: [
{ offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white
{ offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white
{ offset: 0.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright
{ offset: 0.62, color: base }, // solid base
{ offset: 1, color: mixColor(base, 0x000000, 0.30) }, // deeper bottom
{ offset: 0.62, color: base }, // solid base
{ offset: 1, color: mixColor(base, 0x000000, 0.3) }, // deeper bottom
],
});
}
@@ -122,30 +154,30 @@ function makeGradient(base: number): PIXI.FillGradient {
// Soft bloom effect: two expanding filled halos + a brief central sparkle.
// No hard strokes, no particles - purely diffuse light that dissolves gently.
class ImpactFlash {
container: PIXI.Container;
container: PIXI.Container;
private outerBloom: PIXI.Graphics; // large, very soft, low-opacity halo
private innerBloom: PIXI.Graphics; // smaller, slightly more visible glow
private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact
private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact
active = false;
private t = 0;
private noteW = 0;
private baseColor = 0xffffff;
constructor() {
this.container = new PIXI.Container();
this.container = new PIXI.Container();
this.outerBloom = new PIXI.Graphics();
this.innerBloom = new PIXI.Graphics();
this.sparkle = new PIXI.Graphics();
this.sparkle = new PIXI.Graphics();
// Render back-to-front: outer halo, inner glow, sparkle on top
this.container.addChild(this.outerBloom, this.innerBloom, this.sparkle);
this.container.visible = false;
}
trigger(cx: number, cy: number, w: number, color: number) {
this.noteW = w;
this.noteW = w;
this.baseColor = color;
this.t = 0;
this.active = true;
this.t = 0;
this.active = true;
this.container.visible = true;
this.container.x = cx;
this.container.y = cy;
@@ -155,7 +187,10 @@ class ImpactFlash {
/** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */
advance(dtFrames: number): boolean {
this.t += dtFrames * 0.033; // ~500ms total at 60fps
if (this.t >= 1) { this.reset(); return true; }
if (this.t >= 1) {
this.reset();
return true;
}
this._draw();
return false;
}
@@ -172,12 +207,12 @@ class ImpactFlash {
this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 });
// ── Outer bloom - large diffuse halo, 80ms delayed start ─────────────────
const t2 = Math.max(0, t - 0.10);
const t2 = Math.max(0, t - 0.1);
const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2);
const r2 = noteW * 0.4 + et2 * noteW * 3.0;
const r2 = noteW * 0.4 + et2 * noteW * 3.0;
this.outerBloom.clear();
this.outerBloom.circle(0, 0, r2);
this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.10 });
this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.1 });
// ── Sparkle - bright note-colored flash that exists only for the first ~100ms ──
// Using baseColor (the note's own color) instead of white so it's visible in
@@ -188,7 +223,7 @@ class ImpactFlash {
const sr = noteW * 0.35 * (1 - et);
if (sr > 0.5) {
this.sparkle.circle(0, 0, sr);
this.sparkle.fill({ color: baseColor, alpha: sa * 0.80 });
this.sparkle.fill({ color: baseColor, alpha: sa * 0.8 });
}
}
}
@@ -234,13 +269,15 @@ class NoteBar {
configure(
note: NoteEvent,
x: number, barWidth: number,
startY: number, barHeight: number,
x: number,
barWidth: number,
startY: number,
barHeight: number,
gradient: PIXI.FillGradient,
baseColor: number,
labelText: string,
cornerRadius: number,
showOutline: boolean,
showOutline: boolean
) {
this.note = note;
this.active = true;
@@ -290,16 +327,22 @@ class NoteBar {
/** Keep label clamped within the visible portion of the bar (for very tall notes). */
updateLabelY(containerY: number, barHeight: number, viewportHeight: number) {
if (!this.labelWanted) { this.label.visible = false; return; }
if (!this.labelWanted) {
this.label.visible = false;
return;
}
// Visible slice of the bar in local coords
const localTop = Math.max(0, -containerY);
const localTop = Math.max(0, -containerY);
const localBottom = Math.min(barHeight, viewportHeight - containerY);
if (localBottom <= localTop) { this.label.visible = false; return; }
if (localBottom <= localTop) {
this.label.visible = false;
return;
}
this.label.visible = true;
const midLocal = (localTop + localBottom) / 2;
// Only reposition if the natural center is outside the visible slice
const natural = barHeight / 2;
this.label.y = (natural >= localTop && natural <= localBottom) ? natural : midLocal;
this.label.y = natural >= localTop && natural <= localBottom ? natural : midLocal;
}
reset() {
@@ -331,7 +374,7 @@ export interface FallingNotesOptions {
/** Which visual effect plays when a note reaches the hit line */
impactStyle?: ImpactStyle;
/** Minimum pixel height for note bars - prevents staccato slivers */
minNoteHeight?: number; // default 8
minNoteHeight?: number; // default 8
/** Border radius for note bars (0 = sharp, 12 = pill) */
noteCornerRadius?: number; // default 4
/** Draw horizontal beat/measure lines across the canvas */
@@ -370,17 +413,26 @@ export class FallingNotesRenderer {
private measureLayer!: PIXI.Container;
private particleLayer!: PIXI.Graphics;
private particles: Array<{
x: number; y: number; vx: number; vy: number;
size: number; life: number; decay: number;
gravity: number; color: number;
x: number;
y: number;
vx: number;
vy: number;
size: number;
life: number;
decay: number;
gravity: number;
color: number;
}> = [];
private gridLayer!: PIXI.Graphics;
private beatLayer!: PIXI.Graphics;
private sustainLineLayer!: PIXI.Graphics; // thin connecting line
private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool
private sustainLineLayer!: PIXI.Graphics; // thin connecting line
private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool
/** Ghost indicators for notes that ended while sustain was held */
private sustainedNoteLayer!: PIXI.Graphics;
private sustainedNotes = new Map<string, { midi: number; baseColor: number; endSeconds: number }>();
private sustainedNotes = new Map<
string,
{ midi: number; baseColor: number; endSeconds: number }
>();
constructor(opts: FallingNotesOptions) {
this.opts = opts;
@@ -452,7 +504,12 @@ export class FallingNotesRenderer {
for (let i = 0; i < 60; i++) {
const t = new PIXI.Text({
text: "",
style: { fontSize: 11, fill: 0x8896b8, fontFamily: "Space Grotesk, system-ui, sans-serif", fontWeight: "600" },
style: {
fontSize: 11,
fill: 0x8896b8,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontWeight: "600",
},
});
t.x = 3;
t.visible = false;
@@ -460,10 +517,17 @@ export class FallingNotesRenderer {
}
this.app.stage.addChild(
this.gridLayer, this.sustainLineLayer, this.sustainTextLayer,
this.beatLayer, noteLayer, this.particleLayer,
this.gridLayer,
this.sustainLineLayer,
this.sustainTextLayer,
this.beatLayer,
noteLayer,
this.particleLayer,
this.sustainedNoteLayer,
this.measureLayer, this.flashLayer, this.hitLine, this.ghostBar,
this.measureLayer,
this.flashLayer,
this.hitLine,
this.ghostBar
);
this._initialized = true;
}
@@ -501,7 +565,10 @@ export class FallingNotesRenderer {
private getGradient(base: number): PIXI.FillGradient {
let g = this.gradientCache.get(base);
if (!g) { g = makeGradient(base); this.gradientCache.set(base, g); }
if (!g) {
g = makeGradient(base);
this.gradientCache.set(base, g);
}
return g;
}
@@ -521,7 +588,7 @@ export class FallingNotesRenderer {
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
}
if (mode === "white" && !white) return "";
if (mode === "black" && white) return "";
if (mode === "black" && white) return "";
// "white", "black", "all": note name class only (no octave)
return midiToNoteName(midi, this.opts.useFlats);
}
@@ -529,10 +596,14 @@ export class FallingNotesRenderer {
// Returns false if the note should be hidden by the current filter
private passesFilter(note: NoteEvent): boolean {
switch (this.opts.noteFilter) {
case "white": return isWhite(note.midi);
case "black": return isBlackKey(note.midi);
case "c_only": return note.midi % 12 === 0;
default: return true;
case "white":
return isWhite(note.midi);
case "black":
return isBlackKey(note.midi);
case "c_only":
return note.midi % 12 === 0;
default:
return true;
}
}
@@ -542,10 +613,10 @@ export class FallingNotesRenderer {
for (let i = 0; i < count; i++) {
const side = Math.random() < 0.5 ? -1 : 1;
this.particles.push({
x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3),
y: cy,
vx: side * (1.8 + Math.random() * 3.8),
vy: -(0.5 + Math.random() * 2.8),
x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3),
y: cy,
vx: side * (1.8 + Math.random() * 3.8),
vy: -(0.5 + Math.random() * 2.8),
size: 1.4 + Math.random() * 2.4,
life: 1.0,
decay: 0.024 + Math.random() * 0.018,
@@ -560,10 +631,10 @@ export class FallingNotesRenderer {
if (Math.random() > 0.38) return;
const side = Math.random() < 0.5 ? -1 : 1;
this.particles.push({
x: cx + side * (noteW * 0.5),
y: cy - Math.random() * 22,
vx: side * (0.5 + Math.random() * 1.4),
vy: -(0.2 + Math.random() * 1.0),
x: cx + side * (noteW * 0.5),
y: cy - Math.random() * 22,
vx: side * (0.5 + Math.random() * 1.4),
vy: -(0.2 + Math.random() * 1.0),
size: 1.0 + Math.random() * 1.6,
life: 1.0,
decay: 0.04 + Math.random() * 0.03,
@@ -573,7 +644,7 @@ export class FallingNotesRenderer {
}
private triggerFlash(cx: number, cy: number, w: number, color: number) {
const f = this.flashes.find(f => !f.active);
const f = this.flashes.find((f) => !f.active);
if (f) f.trigger(cx, cy, w, color);
}
@@ -595,7 +666,7 @@ export class FallingNotesRenderer {
if (this.active.has(note.id)) return;
if (!this.passesFilter(note)) return;
// Skip notes whose bottom edge has already scrolled 60 px below the hit line —
// Skip notes whose bottom edge has already scrolled 60 px below the hit line -
// they would be recycled on the very next tick anyway, so don't waste a pool slot.
// Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60.
// barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight
@@ -620,10 +691,26 @@ export class FallingNotesRenderer {
const minH = this.opts.minNoteHeight ?? 8;
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
const timeUntilHit = note.startSeconds - currentSeconds;
const startY = (height - 4) - barHeight - timeUntilHit * pxPerSec;
const startY = height - 4 - barHeight - timeUntilHit * pxPerSec;
const base = noteColor(note, this.opts.showHandColors, this.opts.colorTheme, this.opts.customColors);
bar.configure(note, x, barWidth, startY, barHeight, this.getGradient(base), base, this.noteLabel(note), this.opts.noteCornerRadius ?? 4, this.opts.showNoteOutline ?? false);
const base = noteColor(
note,
this.opts.showHandColors,
this.opts.colorTheme,
this.opts.customColors
);
bar.configure(
note,
x,
barWidth,
startY,
barHeight,
this.getGradient(base),
base,
this.noteLabel(note),
this.opts.noteCornerRadius ?? 4,
this.opts.showNoteOutline ?? false
);
this.active.set(note.id, bar);
}
@@ -633,17 +720,17 @@ export class FallingNotesRenderer {
const pxPerSec = height / viewportSeconds;
const hitY = height - 4;
const minH = this.opts.minNoteHeight ?? 8;
const minH = this.opts.minNoteHeight ?? 8;
const cornerRadius = this.opts.noteCornerRadius ?? 4;
const impactStyle = this.opts.impactStyle ?? "bloom";
const doSustained = this.opts.showSustainedNotes ?? false;
const impactStyle = this.opts.impactStyle ?? "bloom";
const doSustained = this.opts.showSustainedNotes ?? false;
const sustainActive = doSustained && this.isSustainActive(currentSeconds);
for (const [id, bar] of this.active) {
if (!bar.note) continue;
const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec);
const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec);
const timeUntilHit = bar.note.startSeconds - currentSeconds;
bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec;
bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec;
bar.updateLabelY(bar.container.y, barHeight, height);
// ── Impact effect ───────────────────────────────────────────────────────
@@ -652,10 +739,8 @@ export class FallingNotesRenderer {
bar.impactFired = true;
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
const cx = bar.container.x + bw * 0.5;
if (impactStyle === "bloom")
this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor);
else if (impactStyle === "side")
this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
if (impactStyle === "bloom") this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor);
else if (impactStyle === "side") this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
// "trail" is handled continuously below
}
}
@@ -665,7 +750,12 @@ export class FallingNotesRenderer {
const barTopY = bar.container.y;
if (barTopY + barHeight < hitY && barTopY < hitY) {
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
this.spawnTrail(bar.container.x + bw * 0.5, barTopY + barHeight * 0.35, bw, bar.baseColor);
this.spawnTrail(
bar.container.x + bw * 0.5,
barTopY + barHeight * 0.35,
bw,
bar.baseColor
);
}
}
@@ -676,8 +766,8 @@ export class FallingNotesRenderer {
const timeAfterEnd = currentSeconds - bar.note.endSeconds;
if (timeAfterEnd < 3.0) {
this.sustainedNotes.set(id, {
midi: bar.note.midi,
baseColor: bar.baseColor,
midi: bar.note.midi,
baseColor: bar.baseColor,
endSeconds: bar.note.endSeconds,
});
}
@@ -699,9 +789,9 @@ export class FallingNotesRenderer {
this.sustainedNotes.delete(sid);
continue;
}
const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2);
const x = this.noteX(sn.midi) + 1;
const r = Math.min(cornerRadius, bw * 0.25);
const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2);
const x = this.noteX(sn.midi) + 1;
const r = Math.min(cornerRadius, bw * 0.25);
// Fade out over the last second of the 3s window
const alpha = Math.min(0.32, 0.32 * (1 - Math.max(0, timeAfterEnd - 2.0)));
this.sustainedNoteLayer.roundRect(x, hitY - ghostH, bw, ghostH, r);
@@ -719,9 +809,9 @@ export class FallingNotesRenderer {
// ── Particles ──────────────────────────────────────────────────────────
this.particleLayer.clear();
if (this.particles.length > 0) {
this.particles = this.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
this.particles = this.particles.filter((p) => {
p.x += p.vx;
p.y += p.vy;
p.vy += p.gravity;
p.life -= p.decay;
return p.life > 0;
@@ -758,7 +848,7 @@ export class FallingNotesRenderer {
if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) {
this.beatLayer.clear();
// Build a Set of measure-start times (×100 for int comparison) for styling
const mSet = new Set((this.opts.measureStarts ?? []).map(s => Math.round(s * 100)));
const mSet = new Set((this.opts.measureStarts ?? []).map((s) => Math.round(s * 100)));
for (const sec of this.opts.beatStarts) {
const timeUntil = sec - currentSeconds;
if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue;
@@ -768,7 +858,10 @@ export class FallingNotesRenderer {
const dark = this.opts.darkMode !== false;
this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1);
// Dark mode: light blue-gray at low opacity. Light mode: darker blue-gray at higher opacity.
this.beatLayer.fill({ color: dark ? 0x8896b8 : 0x4a5568, alpha: isMeasure ? (dark ? 0.2 : 0.35) : (dark ? 0.08 : 0.15) });
this.beatLayer.fill({
color: dark ? 0x8896b8 : 0x4a5568,
alpha: isMeasure ? (dark ? 0.2 : 0.35) : dark ? 0.08 : 0.15,
});
}
} else if (!(this.opts.showBeatLines ?? false)) {
this.beatLayer.clear();
@@ -785,17 +878,17 @@ export class FallingNotesRenderer {
for (const t of pool) t.visible = false;
let ti = 0;
const LABEL_H = 16; // approximate text height + gap
let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above
const LABEL_H = 16; // approximate text height + gap
let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above
for (const range of this.opts.sustainRanges) {
const startTimeUntil = range.startSeconds - currentSeconds;
const endTimeUntil = range.endSeconds - currentSeconds;
const endTimeUntil = range.endSeconds - currentSeconds;
if (endTimeUntil < -0.5 || startTimeUntil > viewportSeconds + 0.5) continue;
// topY = release (end) - higher on canvas (later in time)
// botY = press (start) - lower on canvas (sooner, approaches hit line first)
const topY = Math.max(0, hitY - endTimeUntil * pxPerSec);
const topY = Math.max(0, hitY - endTimeUntil * pxPerSec);
const botY = Math.min(height + 14, hitY - startTimeUntil * pxPerSec);
if (botY <= topY) continue;
@@ -832,7 +925,10 @@ export class FallingNotesRenderer {
}
showGhost(note: NoteEvent | null) {
if (!note) { this.ghostBar.visible = false; return; }
if (!note) {
this.ghostBar.visible = false;
return;
}
const white = isWhite(note.midi);
const w = (white ? this.whiteW : this.blackW) - 2;
const x = this.noteX(note.midi) + 1;
@@ -843,7 +939,10 @@ export class FallingNotesRenderer {
}
reset() {
for (const [, bar] of this.active) { bar.reset(); this.pool.push(bar); }
for (const [, bar] of this.active) {
bar.reset();
this.pool.push(bar);
}
this.active.clear();
this.particles = [];
this.particleLayer?.clear();
@@ -877,12 +976,13 @@ export class FallingNotesRenderer {
// because "x in opts" is always true when callers always pass the full options
// object - triggering spurious resets that wipe all visible note bars.
const needsReset =
("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) ||
("customColors" in opts && opts.customColors !== this.opts.customColors) ||
("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) ||
("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) ||
("customColors" in opts && opts.customColors !== this.opts.customColors) ||
("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) ||
("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
("fallingNotesLabelMode" in opts && opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
("fallingNotesLabelMode" in opts &&
opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
("showFingering" in opts && opts.showFingering !== this.opts.showFingering) ||
("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline);