Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "scorebench",
"version": "0.3.1",
"version": "0.3.2",
"identifier": "com.talkincode.scorebench",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down
173 changes: 122 additions & 51 deletions src/lib/components/SpectrumView.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import {
drawWithFallback,
spectrumStyles,
ThreeInstanceCache,
visualStyleById,
visualStyles,
type SpectrumFrame,
type ThreeInstance,
type ThreeStyleModule,
type VisualStyleEntry,
} from "../spectrum";
import { idleSpectrum } from "../spectrum/dynamics";
import { MoodEngine, type MoodState } from "../spectrum/mood";
Expand All @@ -25,20 +28,29 @@
} = $props();

let canvas2d: HTMLCanvasElement | undefined = $state();
let canvasGl: HTMLCanvasElement | undefined = $state();
let glCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
let prefersReducedMotion = $state(false);
let threeFailed = $state<Record<string, boolean>>({});
let instance = $state<ThreeInstance | null>(null);
let instanceStyle = $state<string | null>(null);
let loadedModules = $state<Record<string, ThreeStyleModule | undefined>>({});
let readyStyles = $state<Record<string, boolean>>({});

const fallback2d = spectrumStyles[0];
const failed2d = new Set<string>();
type ThreeStyleEntry = Extract<VisualStyleEntry, { kind: "three" }>;
const threeStyles = visualStyles.filter(
(candidate): candidate is ThreeStyleEntry => candidate.kind === "three",
);
const loadingModules = new Set<string>();
const instanceCache = new ThreeInstanceCache();
const glSizes = new Map<string, { width: number; height: number; dpr: number }>();
const glElapsed = new Map<string, number>();
let freqData: Uint8Array | null = null;
let timeData: Uint8Array | null = null;
const emptyFreq = new Uint8Array(1024);
const emptyTime = new Uint8Array(2048).fill(128);
/** Perception substrate — one engine per view, run only for mood-aware styles. */
let moodEngine: MoodEngine | null = null;
let destroyed = false;

let entry = $derived.by(() => {
const found = visualStyleById(styleId);
Expand All @@ -47,52 +59,100 @@
return found;
});

function registerGlCanvas(node: HTMLCanvasElement, styleId: string) {
glCanvases = { ...glCanvases, [styleId]: node };
return {
destroy() {
if (glCanvases[styleId] !== node) return;
const next = { ...glCanvases };
delete next[styleId];
glCanvases = next;
},
};
}

function loadThreeModule(style: ThreeStyleEntry): void {
if (loadedModules[style.id] || loadingModules.has(style.id) || threeFailed[style.id]) return;
loadingModules.add(style.id);
void style
.load()
.then((module) => {
if (!destroyed) loadedModules = { ...loadedModules, [style.id]: module };
})
.catch((error) => {
if (destroyed) return;
console.error(`spectrum style ${style.id} failed to load`, error);
threeFailed = { ...threeFailed, [style.id]: true };
})
.finally(() => loadingModules.delete(style.id));
}

onMount(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => (prefersReducedMotion = query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);

const host = window as Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (handle: number) => void;
};
const preload = () => threeStyles.forEach(loadThreeModule);
const idleHandle = host.requestIdleCallback?.(preload, { timeout: 1800 });
const timeoutHandle =
idleHandle === undefined ? window.setTimeout(preload, 500) : undefined;

return () => {
query.removeEventListener("change", update);
if (idleHandle !== undefined) host.cancelIdleCallback?.(idleHandle);
if (timeoutHandle !== undefined) window.clearTimeout(timeoutHandle);
};
});

// WebGL scene lifecycle: lazy-load the module, dispose on style change.
onDestroy(() => {
destroyed = true;
instanceCache.disposeAll((styleId, error) =>
console.error(`spectrum style ${styleId} failed to dispose`, error),
);
glSizes.clear();
glElapsed.clear();
});

// WebGL scenes are created on first use, then retained for instant switching.
$effect(() => {
const current = entry;
const target = canvasGl;
if (current.kind !== "three" || !target || !active) return;
let cancelled = false;
current
.load()
.then((module) => {
if (cancelled) return;
instance = module.create(target);
instanceStyle = current.id;
})
.catch((error) => {
console.error(`spectrum style ${current.id} failed to initialize`, error);
threeFailed = { ...threeFailed, [current.id]: true };
});
return () => {
cancelled = true;
instance?.dispose();
instance = null;
instanceStyle = null;
};
if (current.kind !== "three" || !active || threeFailed[current.id]) return;
const target = glCanvases[current.id];
const module = loadedModules[current.id];
if (!target || instanceCache.get(current.id)) return;
if (!module) {
loadThreeModule(current);
return;
}
try {
instanceCache.getOrCreate(current.id, () => module.create(target));
readyStyles = { ...readyStyles, [current.id]: true };
} catch (error) {
console.error(`spectrum style ${current.id} failed to initialize`, error);
threeFailed = { ...threeFailed, [current.id]: true };
}
});

$effect(() => {
const current = entry;
const currentStyleId = current.kind === "three" ? current.id : null;
const currentInstance =
currentStyleId && readyStyles[currentStyleId]
? instanceCache.get(currentStyleId)
: undefined;
const elGl = currentStyleId ? glCanvases[currentStyleId] : undefined;
if (!active) return;
const kind = entry.kind;
const el2d = canvas2d;
const elGl = canvasGl;
if (!el2d || !elGl) return;
if (!el2d) return;
const ctx = el2d.getContext("2d");
if (!ctx) return;

let raf = 0;
let glWidth = 0;
let glHeight = 0;
let glDpr = 0;
let lastTick = performance.now();
const startedAt = lastTick;

Expand All @@ -117,7 +177,7 @@
// Emotion coordinates enter the frame contract here: computed once per
// frame, only while the active style declares itself mood-aware.
let mood: MoodState | undefined;
if (entry.moodAware) {
if (current.moodAware) {
moodEngine ??= new MoodEngine();
const intentMode = options.intentMode;
mood = moodEngine.update(freq, dt, {
Expand All @@ -129,7 +189,7 @@
}

const dpr = window.devicePixelRatio || 1;
if (kind === "2d" || !instance || instanceStyle !== entry.id) {
if (current.kind === "2d" || !currentInstance || !currentStyleId) {
const w = el2d.clientWidth;
const h = el2d.clientHeight;
if (w <= 0 || h <= 0) return;
Expand All @@ -138,7 +198,7 @@
el2d.height = h * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const style = entry.kind === "2d" ? entry.style : fallback2d;
const style = current.kind === "2d" ? current.style : fallback2d;
const frame: SpectrumFrame = {
ctx,
width: w,
Expand All @@ -154,21 +214,28 @@
console.error(`spectrum style ${style.id} failed`, error),
);
} else {
if (!elGl) return;
const w = elGl.clientWidth;
const h = elGl.clientHeight;
if (w <= 0 || h <= 0) return;
if (w !== glWidth || h !== glHeight || dpr !== glDpr) {
glWidth = w;
glHeight = h;
glDpr = dpr;
instance.resize(w, h, dpr);
const previousSize = glSizes.get(currentStyleId);
if (
!previousSize ||
w !== previousSize.width ||
h !== previousSize.height ||
dpr !== previousSize.dpr
) {
glSizes.set(currentStyleId, { width: w, height: h, dpr });
currentInstance.resize(w, h, dpr);
}
instance.render({
const elapsed = (glElapsed.get(currentStyleId) ?? 0) + dt;
glElapsed.set(currentStyleId, elapsed);
currentInstance.render({
freq,
time,
positionFraction: getPosition(),
dt,
elapsed: (now - startedAt) / 1000,
elapsed,
prefersReducedMotion,
options,
mood,
Expand All @@ -179,22 +246,26 @@
return () => cancelAnimationFrame(raf);
});

let showGl = $derived(entry.kind === "three" && instance !== null && instanceStyle === entry.id);
// Each three style renders into its own canvas element: dispose() releases
// the old WebGL context via loseContext, so a canvas can never be reused.
let glKey = $derived(entry.kind === "three" && active ? entry.id : "gl-off");
let activeGlStyleId = $derived(
entry.kind === "three" && readyStyles[entry.id] ? entry.id : null,
);
let showGl = $derived(activeGlStyleId !== null);

/** Canvas currently on screen — the element video export captures. */
export function getActiveCanvas(): HTMLCanvasElement | null {
return (showGl ? canvasGl : canvas2d) ?? null;
return (activeGlStyleId ? glCanvases[activeGlStyleId] : canvas2d) ?? null;
}
</script>

<div class="spectrum-view">
<canvas bind:this={canvas2d} class="layer" class:hidden={showGl}></canvas>
{#key glKey}
<canvas bind:this={canvasGl} class="layer" class:hidden={!showGl}></canvas>
{/key}
{#each threeStyles as threeStyle (threeStyle.id)}
<canvas
use:registerGlCanvas={threeStyle.id}
class="layer"
class:hidden={activeGlStyleId !== threeStyle.id}
></canvas>
{/each}
</div>

<style>
Expand Down
12 changes: 12 additions & 0 deletions src/lib/spectrum/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { visualStyles } from "./index";

describe("visualStyles", () => {
it("uses concise English picker labels", () => {
expect(visualStyles.map(({ id, label }) => ({ id, label }))).toEqual([
{ id: "bars", label: "Bars" },
{ id: "mood", label: "Mood" },
{ id: "voyage", label: "Voyage" },
]);
});
});
6 changes: 3 additions & 3 deletions src/lib/spectrum/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { bars } from "./bars";

export type { SpectrumFrame, SpectrumOptionDefinition, SpectrumStyle } from "./types";
export type { ThreeFrame, ThreeInstance, ThreeStyleModule } from "./three/types";
export { drawWithFallback } from "./runtime";
export { drawWithFallback, ThreeInstanceCache } from "./runtime";
export { AUTO_STYLE_ID, analyzeTraits, pickStyle } from "./auto";
export type { AudioTraits, BufferLike } from "./auto";
export { MoodEngine, neutralMoodState } from "./mood";
Expand Down Expand Up @@ -46,15 +46,15 @@ export const visualStyles: VisualStyleEntry[] = [
{
kind: "three",
id: "mood",
label: "情绪 · Mood",
label: "Mood",
moodAware: true,
options: [{ key: "moodHud", label: "HUD", min: 0, max: 1, step: 1, defaultValue: 1 }],
load: () => import("./three/mood"),
},
{
kind: "three",
id: "voyage",
label: "航线 · Voyage",
label: "Voyage",
moodAware: true,
options: [
{ key: "wireframe", label: "Line layers", min: 0, max: 1, step: 1, defaultValue: 1 },
Expand Down
31 changes: 30 additions & 1 deletion src/lib/spectrum/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { drawWithFallback } from "./runtime";
import { drawWithFallback, ThreeInstanceCache } from "./runtime";
import type { ThreeInstance } from "./three/types";
import type { SpectrumFrame, SpectrumStyle } from "./types";

describe("drawWithFallback", () => {
Expand All @@ -23,3 +24,31 @@ describe("drawWithFallback", () => {
expect(report).toHaveBeenCalledTimes(1);
});
});

describe("ThreeInstanceCache", () => {
it("reuses initialized styles until the view is destroyed", () => {
const cache = new ThreeInstanceCache();
const mood = {
render: vi.fn(),
resize: vi.fn(),
dispose: vi.fn(),
} satisfies ThreeInstance;
const voyage = {
render: vi.fn(),
resize: vi.fn(),
dispose: vi.fn(),
} satisfies ThreeInstance;
const recreateMood = vi.fn(() => mood);

expect(cache.getOrCreate("mood", () => mood)).toBe(mood);
expect(cache.getOrCreate("voyage", () => voyage)).toBe(voyage);
expect(cache.getOrCreate("mood", recreateMood)).toBe(mood);
expect(recreateMood).not.toHaveBeenCalled();
expect(mood.dispose).not.toHaveBeenCalled();
expect(voyage.dispose).not.toHaveBeenCalled();

cache.disposeAll();
expect(mood.dispose).toHaveBeenCalledOnce();
expect(voyage.dispose).toHaveBeenCalledOnce();
});
});
Loading