import type { Section } from '../../ui/controls'; import { fmtNum } from '../../ui/plot'; import { MembraneView } from '../../ui/membraneview'; import { mountInstrumentPage, type Built } from '../../ui/instrumentPage'; import { buildDrum, buildDrumExcitation, buildDrumPickup, deriveDrum, excitationPoint, nearestBead, type DrumParams, } from './model'; import { DRUM_PRESETS } from './presets'; const isExc = (t: DrumParams['excitation']) => (p: DrumParams) => p.excitation === t; const SECTIONS: Section[] = [ { title: 'Geometry', params: [ { key: 'diameter', label: 'Diameter', kind: 'number', unit: 'cm', scale: 100 }, { key: 'rings', label: 'Rings of beads', kind: 'int', min: 1, help: 'Beads from the centre to the rim. The lattice spacing is radius / (rings + 1); bead count grows as about 3 × rings².' }, ], }, { title: 'Membrane', params: [ { key: 'density', label: 'Surface density', kind: 'number', unit: 'kg/m²', help: 'Mass per area. Mylar drumheads: about 0.26 kg/m².' }, { key: 'tension', label: 'Tension', kind: 'number', unit: 'N/m', help: 'Force per unit length in the head (same in every direction). Sets the pitch.' }, { key: 'inPlane', label: 'In-plane stiffness E·t', kind: 'number', unit: 'N/m', help: "Young's modulus times thickness. Mylar 0.19 mm: about 8.6e5 N/m. Lower values run faster but exaggerate nonlinearity." }, ], }, { title: 'Damping (0 = off)', params: [ { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's' }, { key: 'fLow', label: 'at', kind: 'number', unit: 'Hz' }, { key: 't60High', label: 'T60 high', kind: 'number', unit: 's' }, { key: 'fHigh', label: 'at', kind: 'number', unit: 'Hz' }, ], }, { title: 'Excitation', params: [ { key: 'excitation', label: 'Type', kind: 'select', options: [ ['mallet', 'Mallet (felt contact)'], ['strike', 'Strike (initial velocity)'], ['force', 'Force pulse'], ['press', 'Press (initial dent, released)'], ], }, { key: 'excR', label: 'Position', kind: 'number', unit: '× radius', help: '0 = centre, 1 = rim.' }, { key: 'excAngle', label: 'Angle', kind: 'number', unit: '°' }, { key: 'excWidth', label: 'Patch width', kind: 'number', unit: 'mm', scale: 1000, help: 'Gaussian width of the contact patch; 0 = a single bead.' }, { key: 'malletMass', label: 'Mallet mass', kind: 'number', unit: 'g', scale: 1000, show: isExc('mallet') }, { key: 'malletSpeed', label: 'Mallet speed', kind: 'number', unit: 'm/s', show: isExc('mallet') }, { key: 'feltK', label: 'Felt stiffness K', kind: 'number', unit: 'N/mᵖ', show: isExc('mallet'), help: 'Contact force K·δ^p, δ = felt compression. Harder felt = larger K: shorter contact, brighter sound.' }, { key: 'feltP', label: 'Felt exponent p', kind: 'number', show: isExc('mallet'), help: 'Typical felt: 2 to 3.5.' }, { key: 'strikeVel', label: 'Velocity', kind: 'number', unit: 'm/s', show: isExc('strike') }, { key: 'forcePeak', label: 'Peak force', kind: 'number', unit: 'N', show: isExc('force') }, { key: 'forceMs', label: 'Duration', kind: 'number', unit: 'ms', show: isExc('force') }, { key: 'pressAmp', label: 'Depth', kind: 'number', unit: 'mm', scale: 1000, show: isExc('press') }, ], }, { title: 'Pickup (acceleration)', params: [ { key: 'pickupMode', label: 'Pickup', kind: 'select', options: [['sum', 'Sum over all beads'], ['bead', 'One bead']], help: 'The sum of accelerations is the monopole term of the radiated sound.' }, { key: 'pickupR', label: 'Position', kind: 'number', unit: '× radius', show: (p) => p.pickupMode === 'bead' }, { key: 'pickupAngle', label: 'Angle', kind: 'number', unit: '°', show: (p) => p.pickupMode === 'bead' }, { key: 'pickupAxis', label: 'Component', kind: 'select', options: [['y', 'y (transverse)'], ['x', 'x (in-plane)'], ['z', 'z (in-plane)']] }, ], }, { title: 'Simulation', params: [ { key: 'duration', label: 'Duration', kind: 'number', unit: 's' }, { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] }, { key: 'cfl', label: 'Step safety', kind: 'number', help: 'Fraction of the leapfrog stability limit dt = 2/ω_max.' }, { key: 'frameRate', label: 'Replay frames', kind: 'number', unit: 'per s', help: 'How often displacements of all beads are saved for replay.' }, ], }, ]; export function mountBeadDrum(root: HTMLElement) { const build = (p: DrumParams): Built | null => { const d = deriveDrum(p); if (!(d.restLength > 0) || d.n < 1) return null; const sys = buildDrum(d); const exc = buildDrumExcitation(p, d); const pickup = buildDrumPickup(p, d); return { sys, exc, pickup, scene: { sys, exc, excKind: p.excitation, pickup, pickupLabel: `${p.pickupMode === 'sum' ? 'sum over all beads' : 'one bead'}, ${p.pickupAxis} acceleration`, hoop: { radius: p.diameter / 2 }, }, }; }; mountInstrumentPage(root, { title: 'Bead drum', wavName: 'bead-drum.wav', presets: DRUM_PRESETS, sections: SECTIONS, setup(p) { const d = deriveDrum(p); const rows: [string, string][] = [ ['Beads', `${d.n} (rim points ${d.lattice.rim.length})`], ['Lattice spacing', `${fmtNum(d.lattice.a * 1000)} mm`], ['Bead mass', `${fmtNum(d.beadMass * 1000)} g`], ['Spring stiffness, tension', `${fmtNum(d.springK)} N/m, ${fmtNum(d.springTension)} N`], ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`], ['Wave speed (transverse, in-plane)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedP)} m/s`], ['Ideal membrane fundamental', `${fmtNum(d.idealF01)} Hz`], ]; const warnings = [...d.warnings]; if (p.excR >= 0.97) warnings.push('The excitation point is at the rim, where the head barely moves.'); if (d.n > 3000) warnings.push(`${d.n} beads: expect a slow run.`); return { rows, warnings, built: build(p) }; }, summaryRows: (p, _b, r) => [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(deriveDrum(p).prestretch * 100)}%)`]], extraCards: ` `, mountExtras(q, timeline) { const view = new MembraneView(q('membrane'), timeline); return { show(p, _built, r) { const d = deriveDrum(p); view.set( { pos: d.lattice.pos, radius: p.diameter / 2, spacing: d.lattice.a, strike: excitationPoint(p), pickup: p.pickupMode === 'bead' ? nearestBead(d, (p.diameter / 2) * p.pickupR * Math.cos((p.pickupAngle * Math.PI) / 180), (p.diameter / 2) * p.pickupR * Math.sin((p.pickupAngle * Math.PI) / 180)) : null, sectionAngle: p.excR > 0.01 ? p.excAngle : 0, }, r.frames, ); }, }; }, }); }