Sonora: local 3D audio visualizer (initial import)

Three.js + Web Audio FFT visualizer with Constellation, Texture, Frequency
and Modulation views. Vite build, node --test suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JABXBRLs9T6nqHKUVwoTHJ
This commit is contained in:
marcuspaico
2026-09-08 15:57:51 +10:00
commit ab748bfb9d
18 changed files with 2282 additions and 0 deletions

30
tests/analysis.test.js Normal file
View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { analyzeSpectrum } from '../src/analysis.js';
test('silence produces finite zero features and no peaks', () => {
const f = analyzeSpectrum(new Float32Array(32).fill(-Infinity), new Float32Array(64), 48000);
for (const key of ['rms', 'centroid', 'spread', 'entropy', 'flatness']) assert.equal(f[key], 0);
assert.deepEqual(f.peaks, []);
});
test('a single spectral tone has its exact frequency and zero spread and entropy', () => {
const db = new Float32Array(32).fill(-Infinity); db[4] = -6;
const f = analyzeSpectrum(db, new Float32Array([0.5, -0.5]), 48000);
assert.equal(f.centroid, 3000); assert.equal(f.spread, 0); assert.equal(f.entropy, 0);
assert.equal(f.rms, 0.5); assert.equal(f.peaks[0].frequency, 3000);
});
test('uniform spectral power has maximum normalized entropy and flatness', () => {
const f = analyzeSpectrum(new Float32Array(32).fill(-20), new Float32Array(64), 48000);
assert.ok(Math.abs(f.entropy - 1) < 1e-10);
assert.ok(Math.abs(f.flatness - 1) < 1e-10);
assert.ok(Math.abs(f.centroid - 11625) < 1e-8);
});
test('centroid weights spectral power, not decibel values', () => {
const db = new Float32Array(4).fill(-Infinity); db[1] = 0; db[3] = -10;
const f = analyzeSpectrum(db, new Float32Array(8), 8000);
assert.ok(Math.abs(f.centroid - 1300 / 1.1) < 1e-7);
});
test('nonfinite inputs cannot contaminate 3D coordinates', () => {
const f = analyzeSpectrum(new Float32Array([NaN, Infinity, -Infinity, -30]), new Float32Array([NaN, 0]), 48000);
for (const key of ['rms', 'centroid', 'spread', 'entropy', 'flatness']) assert.ok(Number.isFinite(f[key]));
});

28
tests/color.test.js Normal file
View File

@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { colorStyle } from '../src/color.js';
test('each spatial axis changes the color at equal loudness', () => {
const base = colorStyle(0, 3.5, 0, 0.5, 'spectrum', 'position');
for (const position of [[3, 3.5, 0], [0, 6, 0], [0, 3.5, 4]]) {
const next = colorStyle(...position, 0.5, 'spectrum', 'position');
assert.notEqual(next.h, base.h);
}
});
test('loudness changes brightness without moving spatial hue', () => {
const quiet = colorStyle(0, 3.5, 0, 0.1, 'spectrum', 'position');
const loud = colorStyle(0, 3.5, 0, 0.9, 'spectrum', 'position');
assert.equal(quiet.h, loud.h);
assert.ok(loud.energy > quiet.energy);
});
test('loudness mapping is independent of spatial position', () => {
assert.deepEqual(colorStyle(-6, 0, -6, 0.4, 'ember', 'amplitude'), colorStyle(6, 7, 6, 0.4, 'ember', 'amplitude'));
});
test('all palette values remain finite and bounded at plot extremes', () => {
for (const palette of ['ember', 'spectrum', 'glacier', 'orchid']) {
for (const x of [-6, 0, 6]) for (const y of [0, 7]) for (const z of [-6, 6]) {
const c = colorStyle(x, y, z, 1, palette, 'position');
for (const key of ['h', 's', 'l']) assert.ok(c[key] >= 0 && c[key] <= 1);
}
}
});

View File

@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ConstellationLayer } from '../src/constellation.js';
test('rendered links keep correct endpoints after expiry, with labels fixed and fades independent of uploads', () => {
// Exercise real Three.js buffers without requiring a browser or GPU.
const originalDocument = globalThis.document;
globalThis.document = { createElement: () => ({ getContext: () => ({ clearRect() {}, fillText() {} }) }) };
let layer;
try {
layer = new ConstellationLayer();
const settings = { trail: 3, sensitivity: 1, palette: 'spectrum', colorMode: 'position', glow: 1.35, connections: true, labels: true };
const frame = time => ({ time, rms: .2, entropy: .4, spread: 900, peaks: [{ frequency: 1500 + time * 10, amplitude: .2 }] });
const history = Array.from({ length: 60 }, (_, i) => frame(i / 30));
layer.update(history, 2, settings, 600);
const label = layer.labelPool.find(label => label.node);
assert.ok(label);
const labelNode = label.node;
const labelPosition = label.sprite.position.clone();
const uploadVersion = layer.geometry.attributes.position.version;
layer.animate(2.01);
assert.equal(layer.uniforms.now.value, 2.01);
assert.equal(layer.lines.material.uniforms.now.value, 2.01);
assert.equal(layer.geometry.attributes.position.version, uploadVersion);
layer.update(history, 2.01, settings, 600);
assert.equal(layer.geometry.attributes.position.version, uploadVersion);
history.push(frame(2.05));
layer.update(history, 2.05, settings, 600);
assert.equal(label.node, labelNode);
assert.deepEqual(label.sprite.position, labelPosition);
// Several old points expire; line indices must still reference the surviving points.
history.push(frame(3.1));
layer.update(history, 3.1, settings, 600);
assert.ok(layer.model.nodes.every(node => node.time > .1));
assert.equal(layer.lineGeometry.instanceCount, layer.model.links.length);
assert.equal(layer.lineGeometry.index.count, 6);
assert.ok(layer.model.links.some(([, , temporal]) => temporal));
for (const [i, [a, b]] of layer.model.links.entries()) {
assert.deepEqual(Array.from(layer.linePositions.slice(i * 6, i * 6 + 6)), Array.from(new Float32Array([...layer.model.nodes[a].position, ...layer.model.nodes[b].position])));
}
settings.connections = false;
layer.update(history, 3.1, settings, 600);
assert.equal(layer.lines.visible, false);
layer.update([], 0, settings, 600);
assert.equal(layer.geometry.drawRange.count, 0);
assert.equal(layer.lineGeometry.instanceCount, 0);
assert.ok(layer.labelPool.every(label => !label.node && !label.sprite.visible));
} finally {
layer?.traverse(object => { object.geometry?.dispose(); object.material?.map?.dispose(); object.material?.dispose(); });
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('old annotations fade gradually to zero without another audio sample', () => {
const originalDocument=globalThis.document;
globalThis.document={createElement:()=>({getContext:()=>({clearRect(){},fillText(){}})})};
let layer;
try {
layer=new ConstellationLayer();
layer.update([{time:0,rms:.2,entropy:.4,spread:900,peaks:[{frequency:1500,amplitude:.2}]}],.2,{trail:6,sensitivity:1,palette:'spectrum',colorMode:'position',glow:1,connections:true,labels:true},600);
const label=layer.labelPool.find(label=>label.node);
const fresh=label.sprite.material.opacity;
layer.animate(3);
const halfway=label.sprite.material.opacity;
assert.ok(halfway<fresh*.5);
layer.animate(5.9);
assert.ok(label.sprite.material.opacity<.01);
layer.animate(6);
assert.equal(label.sprite.material.opacity,0);
} finally {
layer?.traverse(o=>{o.geometry?.dispose();o.material?.map?.dispose();o.material?.dispose();});
if(originalDocument===undefined)delete globalThis.document;else globalThis.document=originalDocument;
}
});

View File

@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { makeNodes, connectNodes, ConstellationModel } from '../src/constellation-data.js';
const frame = (time, frequency = 1500) => ({time, rms: 0.2, entropy: 0.4, spread: 900, flatness: 0.03, peaks: [{frequency, amplitude: 0.2}]});
const settings = {trail:12, sensitivity:1};
test('constellation uses measured peak data and excludes expired or silent frames', () => {
const nodes = makeNodes([frame(-20), {...frame(0), rms:0}, frame(1)], 2, settings);
assert.equal(nodes.length, 1);
assert.equal(nodes[0].frequency, 1500); assert.equal(nodes[0].amplitude, 0.2); assert.equal(nodes[0].time, 1);
});
test('node positions are stable as they age and respond to changed frequency', () => {
const node = makeNodes([frame(1)], 2, settings)[0];
assert.deepEqual(node.position, makeNodes([frame(1)], 3, settings)[0].position);
assert.notDeepEqual(node.position, makeNodes([frame(1, 4000)], 2, settings)[0].position);
});
test('dense tracks have a bounded point count and finite positions', () => {
const frames = Array.from({length:3000}, (_, i) => ({...frame(i / 100), peaks:Array.from({length:12}, (_, j) => ({frequency:200+j*400, amplitude:0.1}))}));
const nodes = makeNodes(frames, 30, {...settings, trail:30});
assert.ok(nodes.length <= 2400); assert.ok(nodes.length > 100);
for (const node of nodes) assert.ok(node.position.every(Number.isFinite));
});
test('network connections are local, bounded and never self-connect', () => {
const nodes = [0, 1, 2, 50].map(x => ({position:[x,0,0]}));
const links = connectNodes(nodes);
assert.ok(links.length > 0);
for (const [a,b] of links) { assert.notEqual(a,b); assert.ok(Math.abs(nodes[a].position[0]-nodes[b].position[0]) < 3); }
assert.ok(links.length <= nodes.length * 3);
});
test('rolling history retains the identity, position and parents of surviving points', () => {
const model = new ConstellationModel();
const history = Array.from({length:60}, (_,i) => frame(i/30,1500+i*2));
const first = model.update(history,2,{...settings,trail:3});
const survivor = first.nodes.find(n=>n.time>1);
const saved = {id:survivor.id,position:[...survivor.position],parents:[...survivor.parents]};
history.splice(0,20); history.push(frame(2.1,1630));
const next = model.update(history,2.1,{...settings,trail:3});
const same = next.nodes.find(n=>n.id===saved.id);
assert.deepEqual(same.position,saved.position); assert.deepEqual(same.parents,saved.parents);
});
test('frequency tracks survive peak loudness reordering and create continuous links', () => {
const model = new ConstellationModel(), history=[];
history.push({...frame(0),peaks:[{frequency:1000,amplitude:.3},{frequency:2000,amplitude:.2}]});
const first = model.update(history,0,settings);
const tone = first.nodes.find(n=>n.frequency===1000);
history.push({...frame(.05),peaks:[{frequency:2000,amplitude:.4},{frequency:1005,amplitude:.1}]});
const next = model.update(history,.05,settings);
const continuation = next.nodes.find(n=>n.frequency===1005);
assert.equal(continuation.trackId,tone.trackId);
assert.ok(continuation.parents.includes(tone.id));
});
test('unchanged audio does not rebuild geometry or connections', () => {
const model = new ConstellationModel(), history=[frame(1)];
const first=model.update(history,1,settings);
const next=model.update(history,1.01,settings);
assert.equal(next.revision,first.revision);
assert.equal(next.nodes,first.nodes); assert.equal(next.links,first.links);
});
test('a new source resets tracks and never links back to the previous song', () => {
const model=new ConstellationModel(); model.update([frame(1)],1,settings);
const next=model.update([frame(2)],2,settings);
assert.equal(next.nodes.length,1); assert.deepEqual(next.nodes[0].parents,[]);
});
test('new samples are smoothly connected through sudden amplitude changes', () => {
const model=new ConstellationModel(), history=[{...frame(0),peaks:[{frequency:1500,amplitude:.01}]}];
const start=model.update(history,0,settings).nodes[0];
history.push({...frame(.05),peaks:[{frequency:1500,amplitude:.8}]});
const next=model.update(history,.05,settings).nodes.at(-1);
const raw=makeNodes([history[1]],.05,settings)[0];
const distance = (a,b) => Math.hypot(...a.position.map((v,i)=>v-b.position[i]));
assert.ok(distance(start,next)<distance(start,raw)*.5);
});
test('rapid pitch changes still connect consecutive sound samples', () => {
const model=new ConstellationModel(), history=[frame(0,700)];
const first=model.update(history,0,settings).nodes[0];
history.push(frame(.1,6000));
const next=model.update(history,.1,settings);
assert.ok(next.nodes.at(-1).parents.includes(first.id));
assert.ok(next.links.some(([, , trail])=>trail));
});
test('sound paths break across silence and all points and links expire', () => {
const model=new ConstellationModel(), history=[frame(0,700),frame(1,6000)];
const result=model.update(history,1,settings);
assert.equal(result.nodes.at(-1).trailParent,null);
const expired=model.update(history,14,settings);
assert.equal(expired.nodes.length,0);
assert.equal(expired.links.length,0);
});