import type { Section } from '../../ui/controls'; import { fmtNum } from '../../ui/plot'; import { MembraneView } from '../../ui/membraneview'; import type { Built, InstrumentConfig } from '../../ui/instrumentPage'; import { bendingLimitHz, buildCymbal, buildCymbalExcitation, buildMic, deriveCymbal, hitPoint, micDirection, profileHeight, type CymbalParams, } from './model'; import { CYMBAL_PRESETS } from './presets'; const isExc = (...t: CymbalParams['excitation'][]) => (p: CymbalParams) => t.includes(p.excitation); 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: 2, help: 'Beads from the centre to the rim; bead count grows as about 3 × rings². More rings resolve higher frequencies but cost much more.' }, { key: 'holeRadius', label: 'Centre hole radius', kind: 'number', unit: 'mm', scale: 1000 }, { key: 'bowHeight', label: 'Bow height', kind: 'number', unit: 'mm', scale: 1000, help: 'Rise of the dome from the rim to the centre. 0 = flat.' }, { key: 'bellDiameter', label: 'Bell diameter', kind: 'number', unit: 'cm', scale: 100 }, { key: 'bellHeight', label: 'Bell height', kind: 'number', unit: 'mm', scale: 1000, help: '0 = no bell.' }, ], }, { title: 'Material', params: [ { key: 'thickness', label: 'Thickness', kind: 'number', unit: 'mm', scale: 1000 }, { key: 'youngs', label: "Young's modulus", kind: 'number', unit: 'GPa', scale: 1e-9, help: 'B20 bronze: about 100 GPa.' }, { key: 'density', label: 'Density', kind: 'number', unit: 'kg/m³', help: 'B20 bronze: about 8600 kg/m³.' }, { key: 'soften', label: 'In-plane softening', kind: 'number', unit: '×', help: 'Divide the in-plane stiffness by this for speed (bending stays real). 1 = real bronze. Flat plates are unaffected at small amplitude; curved shapes and big hits are.' }, ], }, { title: 'Damping (0 = off)', params: [ { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's', help: 'Decay time of bending vibration at the low frequency.' }, { 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: [ ['stick', 'Drumstick (hard contact)'], ['mallet', 'Felt mallet'], ['strike', 'Strike (initial velocity)'], ['force', 'Force pulse'], ], }, { key: 'excR', label: 'Position', kind: 'number', unit: '× radius', help: '0 = centre, 1 = rim.' }, { key: 'excAngle', label: 'Azimuth', kind: 'number', unit: '°' }, { key: 'hitAngle', label: 'Hit angle', kind: 'number', unit: '° from normal', help: '0 = straight into the surface; larger angles tilt the stroke toward the rim, like a crash stroke.' }, { key: 'excWidth', label: 'Patch width', kind: 'number', unit: 'mm', scale: 1000, help: 'Gaussian width of the contact patch; 0 = a single bead. A real stick tip is smaller than a bead, but a single light bead makes the stick chatter on this coarse lattice, so the presets use about 1.5 bead spacings.' }, { key: 'stickMass', label: 'Stick mass', kind: 'number', unit: 'g', scale: 1000, show: isExc('stick'), help: 'Effective mass of the stick at the tip.' }, { key: 'stickSpeed', label: 'Stick speed', kind: 'number', unit: 'm/s', show: isExc('stick') }, { key: 'stickK', label: 'Tip stiffness K', kind: 'number', unit: 'N/m^1.5', show: isExc('stick'), help: 'Hertz contact F = K δ^1.5. Wood on bronze with a 5 mm tip: about 5e8 to 1e9.' }, { 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: 'malletK', label: 'Felt K', kind: 'number', unit: 'N/mᵖ', show: isExc('mallet') }, { key: 'malletP', label: 'Felt exponent p', kind: 'number', show: isExc('mallet') }, { key: 'friction', label: 'Friction', kind: 'number', show: isExc('stick', 'mallet'), help: 'Coulomb friction coefficient between tip and cymbal; matters for angled hits.' }, { 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') }, ], }, { title: 'Microphone', params: [ { key: 'micDistance', label: 'Distance', kind: 'number', unit: 'm' }, { key: 'micElevation', label: 'Elevation', kind: 'number', unit: '°', help: 'Angle above the cymbal plane (90 = straight above, negative = below). Straight above, every mode with nodal diameters cancels.' }, { key: 'micAzimuth', label: 'Azimuth', kind: 'number', unit: '°' }, ], }, { 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' }, ], }, ]; const build = (p: CymbalParams): Built | null => { const d = deriveCymbal(p); if (d.n < 3 || !(p.thickness > 0)) return null; const sys = buildCymbal(d); const exc = buildCymbalExcitation(p, d); const mic = buildMic(p, d); return { sys, exc, pickup: mic.weights, pickupDelay: mic.delay, scene: { sys, exc, excKind: p.excitation, pickup: mic.weights, pickupLabel: 'microphone', note: 'free plate: nothing holds it, rigid motion removed', mic: { dir: micDirection(p), from: [0, profileHeight(p, 0), 0], label: `microphone direction (${fmtNum(p.micDistance)} m away, ${fmtNum(p.micElevation)}° up)`, }, }, }; }; export const CYMBAL_CONFIG: InstrumentConfig = { title: 'Cymbal', wavName: 'bead-cymbal.wav', audioQuantity: { label: 'pressure at the microphone', unit: 'Pa' }, presets: CYMBAL_PRESETS, sections: SECTIONS, setup(p) { const d = deriveCymbal(p); const R = p.diameter / 2; const rows: [string, string][] = [ ['Beads', `${d.n}`], ['Support', 'none: a free plate with its rigid-body motion removed'], ['Lattice spacing', `${fmtNum(d.mesh.a * 1000)} mm`], ['Mass', `${fmtNum(d.mass.reduce((s, m) => s + m, 0) * 1000)} g`], ['Bending stiffness D', `${fmtNum(d.D)} N·m`], ['In-plane wave speed', `${fmtNum(d.waveSpeedP)} m/s${p.soften > 1 ? ` (softened ${fmtNum(p.soften)}×)` : ''}`], ['Free flat plate (2,0) mode', `${fmtNum((5.253 / (2 * Math.PI * R * R)) * d.bendingCoef)} Hz (ideal)`], ['Bending resolved to', `~${fmtNum(bendingLimitHz(d))} Hz`], ]; const warnings = [...d.warnings]; const fmax = bendingLimitHz(d); if (fmax < 3000) warnings.push( `Bending waves are only resolved up to about ${fmtNum(fmax)} Hz: above that the lattice is too coarse and the sound is not cymbal-like. More rings help, at a steep cost.`, ); if (Math.abs(p.micElevation) > 80) warnings.push('The microphone is almost straight above: modes with nodal diameters cancel there.'); if (p.hitAngle >= 85) warnings.push('A hit angle near 90° barely touches the surface.'); return { rows, warnings, built: build(p) }; }, summaryRows: (_p, _b, r) => [['Largest strain in the plate', `${fmtNum(r.maxStretch * 100)}%`]], extraCards: '', mountExtras(q, timeline) { const view = new MembraneView(q('membrane'), timeline); return { show(p, _built, r) { const d = deriveCymbal(p); view.set( { pos: d.mesh.pos, radius: p.diameter / 2, spacing: d.mesh.a, strike: hitPoint(p), pickup: null, pickupNote: 'Sound: far-field microphone (Rayleigh integral: each bead’s normal acceleration × area, delayed by its distance; assumes a baffled plate, so sound bending around the rim is ignored).', sectionAngle: p.excR > 0.01 ? p.excAngle : 0, }, r.frames, ); }, }; }, };