Skip to content

Instantly share code, notes, and snippets.

@giohappy
Created August 11, 2026 13:17
Show Gist options
  • Select an option

  • Save giohappy/b854ad7c4a9081a890b225a7c5c73f53 to your computer and use it in GitHub Desktop.

Select an option

Save giohappy/b854ad7c4a9081a890b225a7c5c73f53 to your computer and use it in GitHub Desktop.
Physical Modeling Membrane Synthesizer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Advanced Physical Modeling Membrane Synthesizer</title>
<style>
body {
background-color: #0b0f19;
color: #f1f5f9;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
h1 { margin-bottom: 0.2em; font-size: 1.6rem; color: #38bdf8; }
p.subtitle { color: #94a3b8; margin-top: 0; font-size: 0.9rem; text-align: center; max-width: 700px; }
.app-layout {
display: flex;
flex-wrap: wrap;
gap: 25px;
justify-content: center;
align-items: flex-start;
margin-top: 10px;
max-width: 1000px;
}
.visualizer-card {
background: #1e293b;
padding: 15px;
border-radius: 14px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
align-items: center;
}
.card-title {
font-size: 0.85rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
#drumCanvas {
border-radius: 50%;
cursor: crosshair;
background: #0f172a;
box-shadow: inset 0 0 20px rgba(0,0,0,0.8);
}
#spectrumCanvas {
background: #0f172a;
border-radius: 8px;
}
.panel {
background: #1e293b;
padding: 20px;
border-radius: 14px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 15px;
width: 320px;
}
.preset-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
button.preset-btn {
background: #334155;
color: #f8fafc;
border: 1px solid #475569;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
button.preset-btn:hover { background: #475569; }
button.preset-btn.active {
background: #0284c7;
border-color: #38bdf8;
font-weight: bold;
}
.control-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.control-group label {
font-size: 0.8rem;
color: #cbd5e1;
display: flex;
justify-content: space-between;
}
input[type=range] {
accent-color: #38bdf8;
cursor: pointer;
}
.info-bar {
font-family: monospace;
font-size: 0.8rem;
color: #38bdf8;
background: #0f172a;
padding: 8px 12px;
border-radius: 6px;
text-align: center;
width: 100%;
box-sizing: border-box;
}
</style>
</head>
<body>
<h1>Physical Modeling Membrane Synthesizer</h1>
<p class="subtitle">Modal additive synthesis driven by Bessel roots $\alpha_{n,m}$. Click anywhere on the drumhead to excite 2D wave shapes and spatial overtones.</p>
<div class="app-layout">
<!-- Left: Membrane Surface Visualizer -->
<div class="visualizer-card">
<div class="card-title">2D Membrane Surface Displacement</div>
<canvas id="drumCanvas" width="340" height="340"></canvas>
</div>
<!-- Right: Spectrum Visualizer & Controls -->
<div style="display:flex; flex-direction:column; gap:15px;">
<div class="visualizer-card">
<div class="card-title">Real-Time Modal Spectrum Energy</div>
<canvas id="spectrumCanvas" width="320" height="120"></canvas>
</div>
<div class="panel">
<div class="info-bar" id="info">Click drumhead to strike</div>
<div class="control-group">
<label>Presets</label>
<div class="preset-group">
<button class="preset-btn active" onclick="applyPreset('timpani')">Timpani</button>
<button class="preset-btn" onclick="applyPreset('tom')">Snare/Tom</button>
<button class="preset-btn" onclick="applyPreset('tabla')">Tabla/Bongo</button>
<button class="preset-btn" onclick="applyPreset('gong')">Metal Gong</button>
</div>
</div>
<div class="control-group">
<label for="pitch">Fundamental Pitch ($f_{0,1}$): <span id="pitchVal">90</span> Hz</label>
<input type="range" id="pitch" min="50" max="300" value="90">
</div>
<div class="control-group">
<label for="decay">Base Damping ($\tau$): <span id="decayVal">1.8</span>s</label>
<input type="range" id="decay" min="0.2" max="4.0" step="0.1" value="1.8">
</div>
<div class="control-group">
<label for="hardness">Mallet Hardness: <span id="hardnessVal">0.35</span></label>
<input type="range" id="hardness" min="0.05" max="1.0" step="0.05" value="0.35">
</div>
</div>
</div>
</div>
<script>
// ------------------------------------------------------------------
// 1. Bessel Function J_n(x) Computation Engine
// ------------------------------------------------------------------
function Jn(n, x) {
if (x === 0) return n === 0 ? 1 : 0;
let sum = 0;
let term = Math.pow(x / 2, n);
let nFact = 1;
for (let i = 1; i <= n; i++) nFact *= i;
term /= nFact;
sum += term;
for (let k = 1; k < 28; k++) {
term *= -1 * (x * x) / (4 * k * (k + n));
sum += term;
if (Math.abs(term) < 1e-12) break;
}
return sum;
}
// ------------------------------------------------------------------
// 2. Modal Database (12 Modes: n = 0..3, m = 1..3)
// ------------------------------------------------------------------
const RAW_MODES = [
{ n: 0, m: 1, alpha: 2.4048 }, { n: 0, m: 2, alpha: 5.5201 }, { n: 0, m: 3, alpha: 8.6537 },
{ n: 1, m: 1, alpha: 3.8317 }, { n: 1, m: 2, alpha: 7.0156 }, { n: 1, m: 3, alpha: 10.1735 },
{ n: 2, m: 1, alpha: 5.1356 }, { n: 2, m: 2, alpha: 8.4172 }, { n: 2, m: 3, alpha: 11.6198 },
{ n: 3, m: 1, alpha: 6.3802 }, { n: 3, m: 2, alpha: 9.7610 }, { n: 3, m: 3, alpha: 13.0152 }
];
const alpha01 = RAW_MODES[0].alpha;
const MODES = RAW_MODES.map(mode => {
const freqRatio = mode.alpha / alpha01;
return {
...mode,
label: `(${mode.n},${mode.m})`,
freqRatio: freqRatio,
decayFactor: 1 / Math.pow(freqRatio, 0.8)
};
});
// ------------------------------------------------------------------
// 3. Audio Context & Synthesis Engine
// ------------------------------------------------------------------
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function triggerSound(r_strike, theta_strike, f0, baseDecay, hardness) {
initAudio();
const now = audioCtx.currentTime;
// Master output
const masterGain = audioCtx.createGain();
masterGain.gain.setValueAtTime(0.4, now);
masterGain.connect(audioCtx.destination);
// Stereo Panner mapped to strike X position
const panVal = Math.max(-0.9, Math.min(0.9, r_strike * Math.cos(theta_strike)));
let panner = null;
if (audioCtx.createStereoPanner) {
panner = audioCtx.createStereoPanner();
panner.pan.setValueAtTime(panVal, now);
panner.connect(masterGain);
} else {
panner = masterGain; // Fallback
}
// --- Transient Impact Noise ---
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.025, audioCtx.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < noiseBuffer.length; i++) output[i] = Math.random() * 2 - 1;
const noiseSource = audioCtx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const noiseFilter = audioCtx.createBiquadFilter();
noiseFilter.type = 'lowpass';
noiseFilter.frequency.setValueAtTime(300 + hardness * 2500, now);
const noiseGain = audioCtx.createGain();
noiseGain.gain.setValueAtTime(0.1 + hardness * 0.3, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.015);
noiseSource.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(panner);
noiseSource.start(now);
// --- Additive Synthesis over 12 Modes ---
MODES.forEach((mode, idx) => {
// Spatial excitation: A_(n,m) = J_n(alpha * r_s)
const spatialAmp = Math.abs(Jn(mode.n, mode.alpha * r_strike));
// Mallet Hardness spatial filter (Soft mallet attenuates high alpha roots)
const malletFilter = Math.exp(-((1 - hardness) * Math.pow(mode.alpha, 1.4)) / 18);
const totalAmp = spatialAmp * malletFilter;
if (totalAmp < 0.002) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const modeFreq = f0 * mode.freqRatio;
const modeDecayTime = baseDecay * mode.decayFactor;
osc.type = 'sine';
osc.frequency.setValueAtTime(modeFreq, now);
gain.gain.setValueAtTime(totalAmp, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + modeDecayTime);
osc.connect(gain);
gain.connect(panner);
osc.start(now);
osc.stop(now + modeDecayTime);
});
}
// ------------------------------------------------------------------
// 4. Visualizers (2D Surface & Modal Spectrum)
// ------------------------------------------------------------------
const drumCanvas = document.getElementById('drumCanvas');
const drumCtx = drumCanvas.getContext('2d');
const R_pix = drumCanvas.width / 2;
const spectrumCanvas = document.getElementById('spectrumCanvas');
const specCtx = spectrumCanvas.getContext('2d');
// Offscreen grid buffer (100x100)
const GRID_SIZE = 100;
const offCanvas = document.createElement('canvas');
offCanvas.width = GRID_SIZE;
offCanvas.height = GRID_SIZE;
const offCtx = offCanvas.getContext('2d');
const imgData = offCtx.createImageData(GRID_SIZE, GRID_SIZE);
const pixels = imgData.data;
let strikeTime = 0;
let strikeR = 0;
let strikeTheta = 0;
let strikeX = R_pix;
let strikeY = R_pix;
let isVibrating = false;
let activeAmplitudes = new Array(12).fill(0);
function render(timestamp) {
const f0 = parseFloat(document.getElementById('pitch').value);
const baseDecay = parseFloat(document.getElementById('decay').value);
const hardness = parseFloat(document.getElementById('hardness').value);
const t = (timestamp - strikeTime) / 1000;
const halfGrid = GRID_SIZE / 2;
// Update active mode amplitudes for spectrum visualizer
MODES.forEach((mode, idx) => {
if (!isVibrating) {
activeAmplitudes[idx] = 0;
return;
}
const spatialAmp = Math.abs(Jn(mode.n, mode.alpha * strikeR));
const malletFilter = Math.exp(-((1 - hardness) * Math.pow(mode.alpha, 1.4)) / 18);
const initAmp = spatialAmp * malletFilter;
const decay = Math.exp(-t / (baseDecay * mode.decayFactor));
activeAmplitudes[idx] = Math.max(0, initAmp * decay);
});
// --- 2D Surface Rendering ---
for (let gy = 0; gy < GRID_SIZE; gy++) {
for (let gx = 0; gx < GRID_SIZE; gx++) {
const nx = (gx - halfGrid) / halfGrid;
const ny = (gy - halfGrid) / halfGrid;
const rNorm = Math.sqrt(nx * nx + ny * ny);
const pixelIdx = (gy * GRID_SIZE + gx) * 4;
if (rNorm > 0.98) {
pixels[pixelIdx] = 11; pixels[pixelIdx+1] = 15; pixels[pixelIdx+2] = 25; pixels[pixelIdx+3] = 255;
continue;
}
let u = 0;
if (isVibrating && t >= 0 && t < baseDecay * 2.5) {
const theta = Math.atan2(ny, nx);
const dTheta = theta - strikeTheta;
MODES.forEach((mode, idx) => {
const amp = activeAmplitudes[idx];
if (amp < 0.002) return;
const omega = 2 * Math.PI * (f0 * mode.freqRatio);
// Velocity impulse phase: sin(omega * t)
const spatial = Jn(mode.n, mode.alpha * rNorm) * Math.cos(mode.n * dTheta);
u += amp * spatial * Math.sin(omega * t);
});
}
const intensity = Math.max(-1, Math.min(1, u * 0.7));
let r = 15, g = 23, b = 42;
if (intensity > 0) {
r = Math.round(15 + intensity * 240);
g = Math.round(23 + intensity * 60);
b = Math.round(42 + intensity * 80);
} else {
const absI = Math.abs(intensity);
r = Math.round(15 + absI * 30);
g = Math.round(23 + absI * 120);
b = Math.round(42 + absI * 210);
}
pixels[pixelIdx] = r; pixels[pixelIdx+1] = g; pixels[pixelIdx+2] = b; pixels[pixelIdx+3] = 255;
}
}
offCtx.putImageData(imgData, 0, 0);
drumCtx.clearRect(0, 0, drumCanvas.width, drumCanvas.height);
drumCtx.imageSmoothingEnabled = true;
drumCtx.drawImage(offCanvas, 0, 0, drumCanvas.width, drumCanvas.height);
// Outer Rim
drumCtx.beginPath();
drumCtx.arc(R_pix, R_pix, R_pix - 2, 0, Math.PI * 2);
drumCtx.strokeStyle = '#334155';
drumCtx.lineWidth = 3;
drumCtx.stroke();
// Strike point marker
if (isVibrating && t < baseDecay) {
drumCtx.beginPath();
drumCtx.arc(strikeX, strikeY, 5, 0, Math.PI * 2);
drumCtx.fillStyle = '#38bdf8';
drumCtx.fill();
drumCtx.strokeStyle = '#ffffff';
drumCtx.lineWidth = 1.5;
drumCtx.stroke();
}
// --- Modal Spectrum Visualizer (12 Bars) ---
specCtx.clearRect(0, 0, spectrumCanvas.width, spectrumCanvas.height);
const barWidth = (spectrumCanvas.width - 20) / 12;
MODES.forEach((mode, i) => {
const x = 10 + i * barWidth;
const h = Math.min(spectrumCanvas.height - 25, activeAmplitudes[i] * (spectrumCanvas.height - 30));
const y = spectrumCanvas.height - 20 - h;
// Bar
specCtx.fillStyle = mode.n === 0 ? '#0284c7' : '#38bdf8';
specCtx.fillRect(x + 2, y, barWidth - 4, h);
// Label
specCtx.fillStyle = '#64748b';
specCtx.font = '9px monospace';
specCtx.textAlign = 'center';
specCtx.fillText(mode.label, x + barWidth / 2, spectrumCanvas.height - 6);
});
requestAnimationFrame(render);
}
// ------------------------------------------------------------------
// 5. User Interaction & Presets
// ------------------------------------------------------------------
drumCanvas.addEventListener('click', (e) => {
const rect = drumCanvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
const dx = clickX - R_pix;
const dy = clickY - R_pix;
const dist = Math.sqrt(dx * dx + dy * dy);
let rNorm = dist / R_pix;
if (rNorm > 0.96) rNorm = 0.96;
strikeR = rNorm;
strikeTheta = Math.atan2(dy, dx);
strikeX = clickX;
strikeY = clickY;
const f0 = parseFloat(document.getElementById('pitch').value);
const baseDecay = parseFloat(document.getElementById('decay').value);
const hardness = parseFloat(document.getElementById('hardness').value);
strikeTime = performance.now();
isVibrating = true;
const excitedAngular = MODES.filter(m => m.n > 0 && Math.abs(Jn(m.n, m.alpha * strikeR)) > 0.05).length;
document.getElementById('info').innerText = `r = ${(strikeR * 100).toFixed(0)}% | Active Angular Modes: ${excitedAngular}/9`;
triggerSound(strikeR, strikeTheta, f0, baseDecay, hardness);
});
function applyPreset(type) {
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
if (type === 'timpani') {
setVal('pitch', 80); setVal('decay', 2.2); setVal('hardness', 0.25);
} else if (type === 'tom') {
setVal('pitch', 130); setVal('decay', 0.8); setVal('hardness', 0.65);
} else if (type === 'tabla') {
setVal('pitch', 220); setVal('decay', 0.5); setVal('hardness', 0.85);
} else if (type === 'gong') {
setVal('pitch', 160); setVal('decay', 3.2); setVal('hardness', 0.95);
}
}
function setVal(id, val) {
const el = document.getElementById(id);
el.value = val;
document.getElementById(id + 'Val').innerText = val;
}
['pitch', 'decay', 'hardness'].forEach(id => {
document.getElementById(id).addEventListener('input', e => {
document.getElementById(id + 'Val').innerText = e.target.value;
});
});
requestAnimationFrame(render);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment