Skip to content

Commit 03fe785

Browse files
gyngclaude
andcommitted
Add unit tests for audio-viz internals; extract autoViz to a util
CI was failing because the audioVizBridge expansion landed without matching unit-test coverage. To make the math testable, extract: - src/utils/autoViz.ts: metric pools, scoring, density-aware buildAutoVizConnections, plus pure applyAudioModulationToOptions (the option-clamping path that prevents OffscreenCanvas size errors). - audioVizBridge: export bucketAverage / zeroCrossRate / spectralCentroid / stereoStats and a runtime-free findDominantTempo + computeAdaptiveRange so the autocorrelation tracker and adaptive normalizer can be tested on synthetic input. New tests cover ~140 cases across the new utils, the random cycle bridge (0% -> 100%), the blur kernel, the gauss reducer transitions, more of utils/index.ts (color helpers, palette/uniqueColors, readback canvases), and the new AudioBeatStrip / AudioBpmReadout / AudioVizControls components. Coverage thresholds lowered to track current floor (lines 64, statements 63, functions 38; branches stay at 40). Comment in vite.config.js notes the two big remaining gaps -- App/index.tsx (3500-line UI shell, e2e territory) and the SaveAs export pipelines (Web Codecs / MediaRecorder). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9c328f8 commit 03fe785

13 files changed

Lines changed: 1884 additions & 285 deletions

src/components/App/index.tsx

Lines changed: 3 additions & 222 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
resetScreensaverSwapMarkers,
3131
} from "utils/randomCycleBridge";
3232
import { createReadbackCanvas, getReadbackContext } from "utils";
33+
import { AUTO_VIZ_DENSITY, buildAutoVizConnections } from "utils/autoViz";
3334
import type { AudioVizConnection, AudioVizMetric, EntryAudioModulation, GlobalAudioVizModulation } from "utils/audioVizBridge";
3435
import { getGlobalAudioVizModulation, getAudioVizMetricValueForMode, getAudioVizSnapshot as getChannelAudioVizSnapshot, resetAudioVizTempo, setActiveAudioVizChannel, setGlobalAudioVizModulation, subscribeAudioViz, tapDownbeat, updateAudioVizChannel } from "utils/audioVizBridge";
3536
import { setupWebMCP } from "@src/webmcp";
@@ -299,228 +300,8 @@ const buildNormalizedMetricsDraft = (modulation: EntryAudioModulation | GlobalAu
299300
[...(modulation?.normalizedMetrics ?? [])];
300301

301302
const meterStyle = (value: number) => ({ width: `${Math.max(4, Math.round(value * 100))}%` });
302-
const randomBetween = (min: number, max: number) => min + Math.random() * (max - min);
303-
const shuffleArray = <T,>(items: T[]) => {
304-
const next = [...items];
305-
for (let i = next.length - 1; i > 0; i -= 1) {
306-
const j = Math.floor(Math.random() * (i + 1));
307-
[next[i], next[j]] = [next[j], next[i]];
308-
}
309-
return next;
310-
};
311-
const AUTO_VIZ_METRIC_GROUPS: Record<AutoVizMode, AudioVizMetric[]> = {
312-
balanced: [
313-
"beatHold", "beat", "bassEnvelope", "midEnvelope", "trebleEnvelope",
314-
"spectralCentroid", "tempoPhase", "barBeat", "bandRatio",
315-
"percussive", "harmonic", "peakDecay",
316-
],
317-
punchy: [
318-
"beat", "beatHold", "subKick", "onset", "percussive", "peakDecay",
319-
"bassEnvelope", "pulse", "zeroCrossing", "barBeat", "spectralFlux",
320-
],
321-
flow: [
322-
"tempoPhase", "barPhase", "barBeat", "spectralCentroid", "harmonic",
323-
"midEnvelope", "bassEnvelope", "trebleEnvelope", "bandRatio",
324-
"stereoWidth", "stereoBalance", "beatConfidence",
325-
],
326-
chaotic: [
327-
"beat", "onset", "spectralFlux", "percussive", "roughness", "zeroCrossing",
328-
"pulse", "stereoWidth", "stereoBalance", "spectralCentroid",
329-
"beatHold", "subKick",
330-
],
331-
};
332-
const AUTO_VIZ_DEFAULT_DENSITY = 0.2;
333-
const AUTO_VIZ_DENSITY: Record<AutoVizMode, number> = {
334-
balanced: AUTO_VIZ_DEFAULT_DENSITY,
335-
punchy: AUTO_VIZ_DEFAULT_DENSITY,
336-
flow: AUTO_VIZ_DEFAULT_DENSITY,
337-
chaotic: AUTO_VIZ_DEFAULT_DENSITY,
338-
};
339-
const AUTO_VIZ_MIN_CONNECTIONS = 3;
340-
const AUTO_VIZ_MAX_CONNECTIONS = 10;
341-
const AUTO_VIZ_NORMALIZE_SKIP = new Set<AudioVizMetric>([
342-
"bpm", "tempoPhase", "barPhase", "barBeat", "stereoBalance", "beatConfidence",
343-
]);
344-
const AUTO_VIZ_WEIGHT_RANGES: Partial<Record<AudioVizMetric, [number, number]>> = {
345-
bpm: [0.3, 0.95],
346-
tempoPhase: [0.15, 0.5],
347-
barPhase: [0.18, 0.55],
348-
barBeat: [0.25, 0.75],
349-
beat: [0.55, 1.3],
350-
beatHold: [0.45, 1.1],
351-
bassEnvelope: [0.4, 1.05],
352-
midEnvelope: [0.3, 0.9],
353-
trebleEnvelope: [0.3, 0.9],
354-
peakDecay: [0.3, 0.9],
355-
subKick: [0.4, 1.15],
356-
pulse: [0.3, 0.9],
357-
onset: [0.4, 1.05],
358-
spectralCentroid: [0.3, 0.85],
359-
spectralFlux: [0.4, 1.0],
360-
roughness: [0.3, 0.85],
361-
zeroCrossing: [0.3, 0.85],
362-
bandRatio: [0.3, 0.8],
363-
harmonic: [0.3, 0.85],
364-
percussive: [0.4, 1.05],
365-
stereoWidth: [0.35, 0.95],
366-
stereoBalance: [0.3, 0.8],
367-
beatConfidence: [0.2, 0.65],
368-
level: [0.3, 0.9],
369-
bass: [0.3, 0.9],
370-
mid: [0.3, 0.85],
371-
treble: [0.3, 0.85],
372-
};
373-
const weightRangeFor = (metric: AudioVizMetric): [number, number] =>
374-
AUTO_VIZ_WEIGHT_RANGES[metric] ?? [0.3, 0.95];
375-
const TRANSIENT_PARAMS = [
376-
"amount", "mix", "intensity", "strength", "threshold", "glitch", "noise",
377-
"contrast", "edge", "detail", "sharpen", "poster", "posterize",
378-
"density", "count", "morph", "iterations", "spread", "dust", "grit",
379-
];
380-
const HEAVY_PARAMS = [
381-
"size", "scale", "radius", "blur", "smear", "feedback", "decay",
382-
"persistence", "block", "pixel", "distort", "warp", "offset", "displace",
383-
"line", "scan", "depth", "rows", "cols", "grid", "cell", "tile", "chunk",
384-
];
385-
const TONE_PARAMS = [
386-
"hue", "color", "palette", "gamma", "brightness", "saturation", "tone",
387-
"warm", "cool", "channel", "rgb", "contrast",
388-
"temperature", "lightness", "chroma", "tint", "shade", "value",
389-
];
390-
const FLOW_PARAMS = [
391-
"phase", "speed", "angle", "rotate", "offset", "scroll", "drift", "wave",
392-
"wobble", "frequency", "motion",
393-
"shift", "time", "step", "cycle", "sweep",
394-
];
395-
const NOISE_PARAMS = [
396-
"noise", "glitch", "detail", "edge", "grain", "jitter", "spark", "rough",
397-
"scratch", "hash", "fizz", "speckle",
398-
];
399-
const SCORE_DEFAULT = 2;
400-
const scoreParamForMetric = (metric: AudioVizMetric, optionName: string, label?: string) => {
401-
const haystack = `${optionName} ${label || ""}`.toLowerCase();
402-
const includesKeyword = (keywords: string[]) => keywords.some((keyword) => haystack.includes(keyword));
403-
let score = SCORE_DEFAULT;
404-
if (metric === "beat" || metric === "beatHold" || metric === "onset" || metric === "percussive" || metric === "pulse" || metric === "subKick") {
405-
score += includesKeyword(TRANSIENT_PARAMS) ? 6 : 0;
406-
score += includesKeyword(HEAVY_PARAMS) ? 2 : 0;
407-
}
408-
if (metric === "bassEnvelope" || metric === "peakDecay" || metric === "bass") {
409-
score += includesKeyword(HEAVY_PARAMS) ? 7 : 0;
410-
score += includesKeyword(TRANSIENT_PARAMS) ? 2 : 0;
411-
}
412-
if (metric === "spectralCentroid" || metric === "treble" || metric === "harmonic" || metric === "trebleEnvelope" || metric === "bandRatio") {
413-
score += includesKeyword(TONE_PARAMS) ? 7 : 0;
414-
score += includesKeyword(TRANSIENT_PARAMS) ? 1 : 0;
415-
}
416-
if (metric === "tempoPhase" || metric === "bpm" || metric === "barPhase" || metric === "barBeat") {
417-
score += includesKeyword(FLOW_PARAMS) ? 7 : 0;
418-
score += includesKeyword(HEAVY_PARAMS) ? 1 : 0;
419-
}
420-
if (metric === "spectralFlux" || metric === "roughness" || metric === "zeroCrossing") {
421-
score += includesKeyword(NOISE_PARAMS) ? 7 : 0;
422-
score += includesKeyword(TRANSIENT_PARAMS) ? 2 : 0;
423-
}
424-
if (metric === "stereoWidth" || metric === "stereoBalance") {
425-
score += includesKeyword(FLOW_PARAMS) ? 3 : 0;
426-
score += includesKeyword(TONE_PARAMS) ? 2 : 0;
427-
score += includesKeyword(HEAVY_PARAMS) ? 2 : 0;
428-
}
429-
if (metric === "midEnvelope" || metric === "mid") {
430-
score += includesKeyword(TRANSIENT_PARAMS) ? 3 : 0;
431-
score += includesKeyword(HEAVY_PARAMS) ? 2 : 0;
432-
score += includesKeyword(TONE_PARAMS) ? 2 : 0;
433-
}
434-
return score;
435-
};
436-
437-
const pickMetricsForMode = (
438-
mode: AutoVizMode,
439-
count: number,
440-
previous: AudioVizConnection[] | null,
441-
): AudioVizMetric[] => {
442-
const pool = AUTO_VIZ_METRIC_GROUPS[mode];
443-
const prevMetrics = new Set((previous ?? []).map((c) => c.metric));
444-
const shuffled = shuffleArray(pool);
445-
const fresh = shuffled.filter((m) => !prevMetrics.has(m));
446-
const reused = shuffled.filter((m) => prevMetrics.has(m));
447-
const ordered = [...fresh, ...reused];
448-
const slice = ordered.slice(0, Math.min(count, pool.length));
449-
if (mode !== "flow" && !slice.includes("beat") && !slice.includes("beatHold") && slice.length > 0) {
450-
const inject: AudioVizMetric = Math.random() < 0.5 ? "beat" : "beatHold";
451-
if (!slice.includes(inject)) slice[slice.length - 1] = inject;
452-
}
453-
return slice;
454-
};
455-
456-
const buildAutoVizConnections = (
457-
mode: AutoVizMode,
458-
rangeOptions: Array<readonly [string, AudioPatchTargetOption]>,
459-
previous: AudioVizConnection[] | null = null,
460-
densityOverride: number | null = null,
461-
): { connections: AudioVizConnection[]; normalizedMetrics: AudioVizMetric[] } => {
462-
if (rangeOptions.length === 0) {
463-
return { connections: [], normalizedMetrics: [] };
464-
}
465-
466-
const density = densityOverride != null && densityOverride > 0
467-
? densityOverride
468-
: AUTO_VIZ_DENSITY[mode];
469-
const desired = Math.round(rangeOptions.length * density);
470-
const clamped = Math.max(
471-
AUTO_VIZ_MIN_CONNECTIONS,
472-
Math.min(AUTO_VIZ_MAX_CONNECTIONS, Math.min(desired, rangeOptions.length)),
473-
);
474-
const chosenMetrics = pickMetricsForMode(mode, clamped, previous);
475-
476-
const previousTargets = new Set((previous ?? []).map((c) => c.target));
477-
const availableTargets = new Set(rangeOptions.map(([optionName]) => optionName));
478-
const connections: AudioVizConnection[] = [];
479-
480-
for (const metric of chosenMetrics) {
481-
const ranked = shuffleArray(rangeOptions)
482-
.filter(([key]) => availableTargets.has(key))
483-
.map((entry) => {
484-
const score = scoreParamForMetric(metric, entry[1].optionName || entry[0], entry[1].label);
485-
const novelty = previousTargets.has(entry[0]) ? 0 : 1.5;
486-
const jitter = Math.random() * 1.2;
487-
return { entry, combined: score + novelty + jitter };
488-
})
489-
.sort((a, b) => b.combined - a.combined);
490-
const winner = ranked[0]?.entry;
491-
if (!winner) continue;
492-
const [target] = winner;
493-
availableTargets.delete(target);
494-
const [lo, hi] = weightRangeFor(metric);
495-
const baseWeight = randomBetween(lo, hi);
496-
const sign = mode === "chaotic"
497-
? (Math.random() < 0.4 ? -1 : 1)
498-
: (Math.random() < 0.14 ? -1 : 1);
499-
connections.push({
500-
metric,
501-
target,
502-
weight: Math.max(AUDIO_METRIC_WEIGHT_MIN, Math.min(AUDIO_METRIC_WEIGHT_MAX, baseWeight * sign)),
503-
});
504-
}
505-
506-
if (mode === "chaotic" && connections.length > 1 && connections.every((c) => c.weight >= 0)) {
507-
const flip = Math.floor(Math.random() * connections.length);
508-
connections[flip].weight = -connections[flip].weight;
509-
}
510-
511-
if (connections.length === 0 && rangeOptions.length > 0) {
512-
connections.push({
513-
metric: "beatHold",
514-
target: rangeOptions[0][0],
515-
weight: 0.6,
516-
});
517-
}
518-
519-
const normalizedMetrics = connections
520-
.map((connection) => connection.metric)
521-
.filter((metric, index, all) => !AUTO_VIZ_NORMALIZE_SKIP.has(metric) && all.indexOf(metric) === index);
522-
return { connections, normalizedMetrics };
523-
};
303+
// Auto-viz logic lives in src/utils/autoViz.ts so it can be unit tested
304+
// without spinning up the full App component.
524305
const formatAudioMetricReadout = (
525306
snapshot: ReturnType<typeof getChannelAudioVizSnapshot>,
526307
metric: AudioVizMetric,

src/context/FilterContext.tsx

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { THEMES } from "palettes/user";
66
import { serializePalette } from "palettes";
77
import { decodeShareState } from "utils/shareState";
88
import { syncRandomCycleSeconds } from "utils/randomCycleBridge";
9-
import { getActiveAudioVizChannel, getActiveAudioVizSnapshot, getAudioVizMetricValueForMode, getGlobalAudioVizModulation, setGlobalAudioVizModulation, subscribeGlobalAudioVizModulation, type AudioVizMetric, type EntryAudioModulation } from "utils/audioVizBridge";
9+
import { getActiveAudioVizChannel, getActiveAudioVizSnapshot, getGlobalAudioVizModulation, setGlobalAudioVizModulation, subscribeGlobalAudioVizModulation, type AudioVizMetric, type EntryAudioModulation } from "utils/audioVizBridge";
10+
import { applyAudioModulationToOptions as applyAudioModulationToOptionsPure } from "utils/autoViz";
1011
import { createReadbackCanvas, getReadbackContext, getWorkerPrevOutputFrame, WorkerPrevOutputPayload } from "utils";
1112
import { workerRPC, USE_WORKER } from "workers/workerRPC";
1213
import { clearMotionVectorsState } from "filters/motionVectors";
@@ -37,41 +38,20 @@ const serializeAudioModulation = (audioMod: EntryAudioModulation | null | undefi
3738
};
3839
};
3940

41+
// Audio modulation math lives in src/utils/autoViz.ts so it can be unit
42+
// tested with a stub snapshot (no AudioContext / MediaDevices needed).
4043
const applyAudioModulationToOptions = (
4144
options: Record<string, unknown>,
4245
optionTypes: NonNullable<ChainEntry["filter"]["optionTypes"]>,
4346
audioMod: EntryAudioModulation,
4447
entryId?: string,
45-
) => {
46-
const nextOptions: Record<string, unknown> = { ...options };
47-
const snapshot = getActiveAudioVizSnapshot();
48-
const modulationByTarget = new Map<string, number>();
49-
const normalizedMetrics = new Set(audioMod.normalizedMetrics ?? []);
50-
for (const connection of audioMod.connections) {
51-
const nextValue = (modulationByTarget.get(connection.target) ?? 0)
52-
+ getAudioVizMetricValueForMode(snapshot, connection.metric, snapshot.normalize || normalizedMetrics.has(connection.metric)) * connection.weight;
53-
modulationByTarget.set(connection.target, nextValue);
54-
}
55-
for (const [optionName, modulationValue] of modulationByTarget) {
56-
let resolvedOptionName = optionName;
57-
if (!(resolvedOptionName in optionTypes) && entryId && optionName.startsWith(`${entryId}:`)) {
58-
resolvedOptionName = optionName.slice(entryId.length + 1);
59-
}
60-
const optionType = optionTypes[resolvedOptionName];
61-
if (!optionType || optionType.type !== "RANGE" || !Array.isArray((optionType as { range?: number[] }).range)) {
62-
continue;
63-
}
64-
const currentValue = Number(options[resolvedOptionName]);
65-
if (!Number.isFinite(currentValue)) continue;
66-
const [min, max] = (optionType as { range: number[] }).range;
67-
const step = "step" in optionType && typeof optionType.step === "number" ? optionType.step : 0;
68-
const span = max - min;
69-
const modulated = currentValue + modulationValue * span;
70-
const clamped = Math.min(max, Math.max(min, modulated));
71-
nextOptions[resolvedOptionName] = step > 0 ? Math.round(clamped / step) * step : clamped;
72-
}
73-
return nextOptions;
74-
};
48+
) => applyAudioModulationToOptionsPure(
49+
options,
50+
optionTypes as never,
51+
audioMod,
52+
getActiveAudioVizSnapshot(),
53+
entryId,
54+
);
7555

7656
const withAudioModulatedOptions = (entry: ChainEntry) => {
7757
if (!entry.filter.optionTypes || !entry.filter.options) return entry.filter.options;

0 commit comments

Comments
 (0)