Created
August 11, 2026 13:17
-
-
Save giohappy/b854ad7c4a9081a890b225a7c5c73f53 to your computer and use it in GitHub Desktop.
Physical Modeling Membrane Synthesizer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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