Skip to content

Commit 65320d9

Browse files
authored
Add 1D convolution audio visualizer
Initial implementation of a 1D convolution audio visualizer with interactive UI.
1 parent 50bfd4c commit 65320d9

File tree

1 file changed

+389
-0
lines changed

1 file changed

+389
-0
lines changed

1d-with-sound/app.js

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
let pad = 1;
2+
3+
// integer kernel
4+
let kernel = [0, 0, 0]; // [a,b,c]
5+
6+
// audio
7+
let audioCtx = null;
8+
let masterGain = null;
9+
let scheduled = []; // oscillators we can stop
10+
11+
// ---------- helpers ----------
12+
13+
function modN(x, n) {
14+
return ((x % n) + n) % n;
15+
}
16+
17+
function getIterations() {
18+
return parseInt(document.getElementById("iterations").value, 10);
19+
}
20+
21+
function getPixelSize() {
22+
return parseInt(document.getElementById("pixelSize").value, 10);
23+
}
24+
25+
function getSqueezeMod() {
26+
return parseInt(document.getElementById("squeezeMod").value, 10);
27+
}
28+
29+
function getCoreRange() {
30+
return parseInt(document.getElementById("coreRange").value, 10);
31+
}
32+
33+
// ---------- 1D math ----------
34+
35+
function padding1D(arr, k) {
36+
if (arr.length === 1) return [1, 0];
37+
38+
const n = arr.length;
39+
const out = new Array(n * 2);
40+
41+
for (let i = 0; i < n; i++) {
42+
out[i * 2] = arr[i];
43+
out[i * 2 + 1] = k * arr[i];
44+
}
45+
return out;
46+
}
47+
48+
function convolution1D(arr, k) {
49+
const n = arr.length;
50+
const out = new Array(n);
51+
52+
for (let i = 0; i < n; i++) {
53+
const im = (i - 1 + n) % n;
54+
const ip = (i + 1) % n;
55+
56+
out[i] =
57+
arr[im] * k[0] +
58+
arr[i ] * k[1] +
59+
arr[ip] * k[2];
60+
}
61+
return out;
62+
}
63+
64+
function squeezeField1D(arr, mod) {
65+
if (!mod || mod <= 0) return arr;
66+
67+
const n = arr.length;
68+
const out = new Array(n);
69+
70+
for (let i = 0; i < n; i++) {
71+
const s = Math.round(arr[i]);
72+
out[i] = modN(s, mod);
73+
}
74+
return out;
75+
}
76+
77+
function evolve1D(iterCount) {
78+
let arr = [1];
79+
const sq = getSqueezeMod();
80+
81+
for (let i = 0; i < iterCount; i++) {
82+
arr = padding1D(arr, pad);
83+
arr = convolution1D(arr, kernel);
84+
arr = squeezeField1D(arr, sq);
85+
}
86+
return arr;
87+
}
88+
89+
// ---------- drawing ----------
90+
91+
function draw(arr) {
92+
const canvas = document.getElementById("canvas");
93+
const px = getPixelSize();
94+
95+
const w = arr.length * px;
96+
const h = 260;
97+
98+
canvas.width = w;
99+
canvas.height = h;
100+
101+
const ctx = canvas.getContext("2d");
102+
ctx.fillStyle = "#000";
103+
ctx.fillRect(0, 0, w, h);
104+
105+
// min/max
106+
let min = Infinity, max = -Infinity;
107+
for (const v of arr) {
108+
if (v < min) min = v;
109+
if (v > max) max = v;
110+
}
111+
const span = (max - min) || 1;
112+
113+
// waveform curve
114+
ctx.strokeStyle = "#fff";
115+
ctx.beginPath();
116+
for (let i = 0; i < arr.length; i++) {
117+
const x = i * px + px / 2;
118+
const y = h * 0.40 - ((arr[i] - min) / span) * (h * 0.35);
119+
120+
if (i === 0) ctx.moveTo(x, y);
121+
else ctx.lineTo(x, y);
122+
}
123+
ctx.stroke();
124+
125+
// symbolic dots (mod-4 partition, canonical)
126+
for (let i = 0; i < arr.length; i++) {
127+
const s = Math.round(arr[i]*Math.sqrt(2));
128+
const r = modN(s, 4);
129+
if (r === 0 || r === 1) {
130+
ctx.fillStyle = "#fff";
131+
ctx.fillRect(i * px, h - 18, px, 10);
132+
}
133+
}
134+
}
135+
136+
function dumpKernel(arr) {
137+
const iters = getIterations();
138+
const sq = getSqueezeMod();
139+
140+
let min = Infinity, max = -Infinity;
141+
for (const v of arr) {
142+
if (v < min) min = v;
143+
if (v > max) max = v;
144+
}
145+
146+
document.getElementById("kernelDump").textContent =
147+
`iter=${iters} size=${arr.length} pad=${pad} ` +
148+
`squeeze=${sq} kernel=[${kernel.join(", ")}] ` +
149+
`min=${min.toFixed(3)} max=${max.toFixed(3)}`;
150+
}
151+
152+
// live redraw only
153+
function regenerateVisual() {
154+
const arr = evolve1D(getIterations());
155+
draw(arr);
156+
dumpKernel(arr);
157+
}
158+
159+
// ---------- sound (notes) ----------
160+
161+
// C D E F G A B semitone offsets (no bemols)
162+
const majorOffsets = [0, 2, 4, 5, 7, 9, 11];
163+
164+
function midiToFreq(m) {
165+
return 440 * Math.pow(2, (m - 69) / 12);
166+
}
167+
168+
function soundInit() {
169+
if (!audioCtx)
170+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
171+
172+
if (!masterGain) {
173+
masterGain = audioCtx.createGain();
174+
masterGain.connect(audioCtx.destination);
175+
}
176+
177+
// if the browser suspended it, resume on this user gesture
178+
if (audioCtx.state === "suspended")
179+
audioCtx.resume();
180+
}
181+
182+
function stopSound() {
183+
// stop everything we scheduled
184+
for (const o of scheduled) {
185+
try { o.stop(0); } catch (e) {}
186+
try { o.disconnect(); } catch (e) {}
187+
}
188+
scheduled = [];
189+
190+
// hard silence as well
191+
if (masterGain) {
192+
try { masterGain.gain.setValueAtTime(0.0, audioCtx.currentTime); } catch (e) {}
193+
}
194+
}
195+
196+
function playNotesFromArray(arr) {
197+
soundInit();
198+
199+
// stop previous playback to avoid overlap mess
200+
stopSound();
201+
202+
// restore volume
203+
const vol = parseFloat(document.getElementById("volume").value);
204+
masterGain.gain.setValueAtTime(vol, audioCtx.currentTime);
205+
206+
const now = audioCtx.currentTime;
207+
const noteMs = parseInt(document.getElementById("noteMs").value, 10);
208+
const dt = Math.max(0.005, noteMs / 1000);
209+
210+
const mod = parseInt(document.getElementById("noteMod").value, 10);
211+
const step = parseInt(document.getElementById("noteStep").value, 10);
212+
213+
// base octave (C3..C6 range depending on mod)
214+
const baseOctave = 3;
215+
216+
let t = now;
217+
218+
for (let i = 0; i < arr.length; i += step) {
219+
const s = Math.round(arr[i]);
220+
const r = modN(s, mod);
221+
222+
const degree = r % 7; // 0..6
223+
const oct = Math.floor(r / 7); // 0..(mod/7 - 1)
224+
225+
const midi = 12 + (baseOctave + oct) * 12 + majorOffsets[degree];
226+
const freq = midiToFreq(midi);
227+
228+
const o = audioCtx.createOscillator();
229+
const g = audioCtx.createGain();
230+
231+
o.type = "square";
232+
o.frequency.value = freq;
233+
234+
// envelope (like goody)
235+
g.gain.setValueAtTime(vol, t);
236+
g.gain.linearRampToValueAtTime(0.0, t + dt * 0.95);
237+
238+
o.connect(g);
239+
g.connect(masterGain);
240+
241+
o.start(t);
242+
o.stop(t + dt);
243+
244+
scheduled.push(o);
245+
246+
t += dt;
247+
}
248+
}
249+
250+
// ---------- UI wiring ----------
251+
252+
function setKernelSliderConfig() {
253+
const R = getCoreRange();
254+
255+
const ids = ["ka", "kb", "kc"];
256+
for (const id of ids) {
257+
const s = document.getElementById(id);
258+
s.min = -R;
259+
s.max = R;
260+
s.step = 1;
261+
}
262+
}
263+
264+
function syncKernelFromSliders() {
265+
kernel[0] = parseInt(document.getElementById("ka").value, 10);
266+
kernel[1] = parseInt(document.getElementById("kb").value, 10);
267+
kernel[2] = parseInt(document.getElementById("kc").value, 10);
268+
269+
document.getElementById("kaLabel").textContent = kernel[0];
270+
document.getElementById("kbLabel").textContent = kernel[1];
271+
document.getElementById("kcLabel").textContent = kernel[2];
272+
}
273+
274+
function randomKernel() {
275+
const R = getCoreRange();
276+
function ri() { return Math.floor(Math.random() * (2 * R + 1)) - R; }
277+
278+
document.getElementById("ka").value = ri();
279+
document.getElementById("kb").value = ri();
280+
document.getElementById("kc").value = ri();
281+
282+
syncKernelFromSliders();
283+
regenerateVisual();
284+
commitSoundRestart();
285+
}
286+
287+
function initUI() {
288+
setKernelSliderConfig();
289+
290+
// init labels
291+
document.getElementById("squeezeLabel").textContent = getSqueezeMod();
292+
293+
// kernel sliders (visual only)
294+
["ka","kb","kc"].forEach(id => {
295+
const s = document.getElementById(id);
296+
297+
// live preview (canvas only)
298+
s.oninput = () => {
299+
syncKernelFromSliders();
300+
regenerateVisual();
301+
};
302+
303+
// commit on release (sound restart)
304+
s.onchange = () => {
305+
commitSoundRestart();
306+
};
307+
});
308+
309+
// core range changes slider bounds (visual only)
310+
document.getElementById("coreRange").oninput = () => {
311+
setKernelSliderConfig();
312+
// keep current values clamped by browser automatically
313+
syncKernelFromSliders();
314+
regenerateVisual();
315+
};
316+
317+
// iterations/pixel/squeeze are visual only
318+
document.getElementById("iterations").oninput = regenerateVisual;
319+
document.getElementById("pixelSize").oninput = regenerateVisual;
320+
321+
const squeeze = document.getElementById("squeezeMod");
322+
323+
squeeze.oninput = () => {
324+
document.getElementById("squeezeLabel").textContent = getSqueezeMod();
325+
regenerateVisual();
326+
};
327+
328+
squeeze.onchange = () => {
329+
commitSoundRestart();
330+
};
331+
332+
// pad toggle
333+
document.querySelectorAll('input[name="padmode"]').forEach(r => {
334+
r.addEventListener("change", e => {
335+
pad = Number(e.target.value);
336+
regenerateVisual();
337+
commitSoundRestart();
338+
});
339+
});
340+
341+
// random kernel
342+
document.getElementById("randomBtn").onclick = randomKernel;
343+
344+
// play/stop
345+
document.getElementById("playBtn").onclick = () => {
346+
const arr = evolve1D(getIterations());
347+
playNotesFromArray(arr);
348+
};
349+
350+
document.getElementById("stopBtn").onclick = stopSound;
351+
352+
const noteMod = document.getElementById("noteMod");
353+
noteMod.onchange = () => {
354+
commitSoundRestart();
355+
};
356+
357+
const noteStep = document.getElementById("noteStep");
358+
noteStep.onchange = () => {
359+
commitSoundRestart();
360+
};
361+
362+
const noteMs = document.getElementById("noteMs");
363+
noteMs.onchange = () => {
364+
commitSoundRestart();
365+
};
366+
367+
const volume = document.getElementById("volume");
368+
369+
volume.onchange = () => {
370+
commitSoundRestart();
371+
};
372+
373+
}
374+
375+
function commitSoundRestart() {
376+
// nothing to restart if sound never played
377+
if (!audioCtx) return;
378+
379+
stopSound();
380+
381+
const arr = evolve1D(getIterations());
382+
playNotesFromArray(arr);
383+
}
384+
385+
// init
386+
document.addEventListener("DOMContentLoaded", () => {
387+
initUI();
388+
randomKernel();
389+
});

0 commit comments

Comments
 (0)