import type { Section } from '../../ui/controls'; import { LinePlot, css, 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.' }, ], }, ]; // separate contact intervals in the contact-force record function contacts(cf: Float32Array, fs: number): { start: number; ms: number; peak: number }[] { const out: { start: number; ms: number; peak: number }[] = []; let i = 0; while (i < cf.length) { if (cf[i] > 0) { const a = i; let peak = 0; while (i < cf.length && cf[i] > 0) peak = Math.max(peak, cf[i++]); out.push({ start: a / fs, ms: ((i - a) / fs) * 1000, peak }); } else i++; } return out; } 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) { const rows: [string, string][] = [['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(deriveDrum(p).prestretch * 100)}%)`]]; if (r.contactForce) { const c = contacts(r.contactForce, r.fs); rows.push([ 'Mallet contact', c.length ? `${fmtNum(c[0].ms)} ms, peak ${fmtNum(c[0].peak)} N${c.length > 1 ? ` (+${c.length - 1} rebound${c.length > 2 ? 's' : ''})` : ''}` : 'no contact', ]); } return rows; }, extraCards: ` `, mountExtras(q, timeline) { const view = new MembraneView(q('membrane'), timeline); const contactPlot = new LinePlot(q('contact')); 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, ); const card = q('out-contact'); card.hidden = !r.contactForce; if (!r.contactForce) return; const cf = r.contactForce; const c = contacts(cf, r.fs); const end = c.length ? c[c.length - 1].start + c[c.length - 1].ms / 1000 : 0.01; const nShow = Math.min(cf.length, Math.ceil((end * 1.3 + 0.002) * r.fs)); const t = new Float64Array(nShow); for (let i = 0; i < nShow; i++) t[i] = (i / r.fs) * 1000; contactPlot.set([{ x: t, y: cf.subarray(0, nShow), color: css('--series-2'), label: 'force (N)' }], { xLabel: 'time (ms)', yLabel: 'contact force (N)', yRange: [0, Math.max(...cf.subarray(0, nShow)) * 1.08 || 1], }); q('contact-note').textContent = c.length ? `${c.length} contact${c.length > 1 ? 's' : ''}: ` + c.map((k) => `${fmtNum(k.ms)} ms from ${fmtNum(k.start * 1000)} ms, peak ${fmtNum(k.peak)} N`).join('; ') + `. During contact the head takes energy from the mallet and gives much of it back as the mallet rebounds, so the energy plot spikes first; the mallet then leaves at constant speed (no gravity).` : 'The mallet never touched the head.'; }, }; }, }); }