# cymplay A browser instrument for designing a [cymsim](../cym2) cymbal, playing it in real time, and recording the hits for a full render. Everything runs in the browser: the precomputation of the linear modes (a Web Worker), the synthesis (an AudioWorklet), the keyboard and mouse interface, and the export of the recorded hits as a cymsim YAML configuration. The real-time sound is an approximation: the *linear* response of the same shell model that cymsim solves, with the same profile, thickness taper, seeded imperfections, finite elements, contact law, exciters and listener model. What it lacks is the geometric nonlinearity, that is, the upward cascade that turns a stroke into a crash. The recorded hits, rendered with `cymsim simulate`, restore it. ## Running npm install npm run dev # development server npm run build # static site in dist/ The page walks you through it. The bar at the top always says what the next step is: it computes the cymbal's modes the first time (about twenty seconds at the default mesh, kept in the browser afterwards), starts the sound, and then gets out of the way. The four panels below it follow the same order: design the cymbal, compute its modes, set up the playing, record a performance. ## Playing Three rows of keys strike three concentric arcs across the near half of the cymbal, drawn with their letters. The layout matches the keyboard: the bottom row is the edge closest to you, the top row the inner bow, and a row reads from the left of the cymbal, round the near side, to the right. Pressing Q, A and Z in turn walks outwards along one ray. | keys | where | |---|---| | Q W E R T Y U I O P | inner bow, 0.42 R | | A S D F G H J K L ; | mid bow, 0.64 R | | Z X C V B N M , . / | near the rim, 0.86 R | | space | the bell | One exciter, chosen under *exciter*, applies to every key and to mouse clicks, so position is the only thing the keys change. **Dynamics.** Each position carries its own strike velocity, printed under its letter, and the marker grows and shrinks with it. Scroll the wheel over a position to make it harder or softer, in steps of about 2 dB between a quarter and four times the master level; **Level the positions** puts them all back. The weights are remembered between sessions, so a kit of accents and ghost notes stays where you put it. The master level scales every position together: digits 1 to 9 set it and `[` and `]` nudge it by 0.5 m/s. There are no modifier keys to hold; a key always strikes its position at the velocity shown. Clicking anywhere on the cymbal strikes there at the master level. Be aware that in linear theory velocity is *exactly* a change of gain and nothing else: the measured pressure is proportional to velocity to the last digit. Real cymbals get brighter when struck harder, and the rendered file will show that, but the preview will not. **Stick and mallet are not interchangeable here.** Measured on the default 18 inch crash, at the same position and velocity, the soft mallet is 17 to 23 dB *quieter* than the stick, although it delivers ten times the energy; at matched energy the gap is about 50 dB. A 4 ms mallet contact injects almost nothing above 120 Hz, and an unbaffled cymbal barely radiates there because the front and back faces cancel. In the full model that low-frequency energy climbs into the audible band, which is the crash; linear theory cannot preview it. The *lift the mallet preview level* box therefore raises the mallet by 18 dB so that it is audible. It affects the output gain only, never the recorded hits and never the captured WAV. ## Designing the cymbal The design panel exposes every parameter of the shell, in two tiers. **Live** parameters take effect while the slider moves, because they enter the modal bank analytically rather than through the eigenproblem: - the two loss coefficients, which set the sustain. `sigma0` gives every mode the same decay, so the 60 dB time of the low modes is about `6.9 / sigma0`; `sigma1` adds a decay proportional to the squared wavenumber, so raising it darkens the tail by killing the high modes first. The panel reports the resulting T60 at the bottom and top of the band. - Young's modulus and the density, which scale every frequency by `sqrt((E/E0)/(rho/rho0))` exactly, leaving the mode shapes alone. - the listener position, the back-side cancellation, the speed of sound and the air density, which enter only through the per-mode gains. Those are recomputed in the worker a quarter of a second after the slider stops. Checked against a full recompute at a different material and damping, the live path reproduces the frequencies to 7e-12 and the decay rates to 2e-9 relative. One caveat: a softer or denser material lowers every frequency, so the top of the band empties out and the panel says so; a recompute fills it again. **Structural** parameters change the eigenproblem itself, so the panel marks the modes stale and you press Compute modes: the radius, the three thicknesses, the bow rise and shape, the bell geometry, Poisson's ratio, and the hammering and asymmetry (seed, number and size of hammer marks, rim warp, shoulder ovality, bell offset). Keep the mark width at or above the playback element size or the mesh cannot see the marks. **Download design YAML** saves the cymbal alone; **Download YAML** under *Recorded hits* saves it together with the hits. ## Recording and exporting Playing always makes sound; recording is a separate thing you start when a take is worth keeping. **Record hits** logs each strike with its time, position, velocity and exciter, and, once the exciter has rebounded, its peak force and contact time. Starting a recording clears the previous one. Afterwards: - **Play the recording** schedules the same hits through the same engine, sample accurate, so you hear the take again. - **Download WAV** renders the take offline, faster than real time, through that same engine in an `OfflineAudioContext`. It is not a capture of the speakers: it is exact, reproducible, and it includes the whole ring-out rather than whatever you happened to leave running. It is still the approximate linear sound. - **Download YAML** writes the designed cymbal together with the hits, times shifted so the first falls at the lead-in and the duration set to the last hit plus the tail. Render it with cymsim simulate cymplay.yaml which is where the nonlinear cascade, and the real crash, come back. ## The precompute The linearised shell has five degrees of freedom per node (two in-plane displacements, the transverse displacement and two rotations). The in-plane and rotation DOFs are eliminated by static condensation, with a banded Cholesky factorisation after a reverse Cuthill-McKee ordering and three in-plane DOFs pinned against rigid motion, leaving a dense stiffness on the transverse DOFs. Its mass-normalised eigenmodes, by Householder tridiagonalisation and implicit QL in TypeScript, give per mode the frequency, the quadratic form of the `sigma1` loss, the complex listener gains (the Rayleigh front sum and the diffracted back sum, each element's delay folded into a phase at the mode frequency) and the mode shape on the nodes, quantised to int16. The eigensolve is dense and costs about the cube of the node count, so the playback mesh is coarser than the render mesh: 10 mm by default (1736 nodes, about 1500 modes below 20 kHz) takes about 20 s; 8 mm about 1.5 min; 6 mm, the render resolution, about 15 min. ### How far to trust it Against the Python package on the same mesh and geometry, the mode frequencies agree to 1e-5 relative in the lowest hundred modes and to about 1e-3 at 10 kHz. The residual comes from a tie-break in the ring stitching of the mesher, not from the elements: the DKT element stiffness matches the Python one to 1e-16. Comparing the two as instruments, through the point-force-to-listener transfer function, which does not care how degenerate pairs are split between the two eigenbases, the mean difference over 80 Hz to 15 kHz is about 8 %, concentrated above 9 kHz. Against a linear cymsim FEM run on the same 10 mm mesh, the ringing spectrum agrees within 0.6 dB up to 3 kHz. Above that the modal engine is quieter, because the FEM run still carries content the modal band does not. The stick contact on a quiet cymbal matches in contact time (0.82 ms against 0.82 ms) and rebound (0.65 against 0.75 m/s), with the peak force about 30 % low at 44.1 kHz; sub-sampling the contact closes that gap and costs more than it is worth for a preview. Two deliberate differences from the Python package: the condensed model has no in-plane and no rotational inertia, where the Python package inflates the latter as a time-step device that slows its high modes by a few percent; and the imperfections are drawn from a different generator (xoshiro128** rather than numpy's PCG64), so a seed gives a different dent pattern with the same statistics. A configuration exported from here and rendered in Python is the same cymbal in every respect but the particular dents. **Download modes** saves the precomputed file (a JSON header plus raw arrays) and **load modes** restores it. `tools/make_modes_reference.py` produces the same file from Python, and `tools/modal_reference.py` is an independent numpy implementation of the synthesis; the worklet agrees with it to 1e-6 relative in rms. ## The synthesis Each mode is a damped resonator with a complex state, advanced with its exact pole once per audio sample. An exciter is a rigid mass on power-law springs at its patch points, a line for a stick and a centre plus hexagon for a mallet, released just above the moving surface at its hit time. The contact force of a sample is found implicitly: the surface under the patch responds to that sample's forces through a small precomputed compliance matrix, so the end-of-sample penetration is affine in the forces and the contact law is solved by a few Newton iterations. That is unconditionally stable for the stiff stick contact, which an explicit coupling could only survive by sub-stepping. The number of springs used in real time is selectable; the exported configuration always keeps the full patches. Each hit carries the frame at which its exciter is released, and a hit whose frame has not arrived costs nothing until the block it falls in. One processor therefore serves live play (release at once), sample-accurate playback of a recording, and the offline render, where the whole performance is handed over in `processorOptions` before rendering begins, since a port message posted before `startRendering` is not guaranteed to arrive in time. The listener pressure is the sum over modes of the gain times the modal acceleration, plus the direct term of the instantaneous force. Between hits the bank costs about 13 % of a core for 1500 modes at 44.1 kHz, and a contact block two to four times that. The first hit after the audio starts can still overrun its block once while the JavaScript engine optimises the contact path, despite a warm-up pass at start-up. ## Layout | path | contents | |---|---| | `src/main.ts` | the page: modes, design panel, audio, keyboard, hit log, export | | `src/design.ts` | the parameter schema and the panel that edits it | | `src/derived.ts` | the live material and damping relations | | `src/worklet.ts` | the AudioWorklet processor (modal bank, exciters, implicit contact) | | `src/precompute/` | assembly and condensation, banded Cholesky, eigensolver, listener gains, worker | | `src/model/`, `src/config/` | mesh, profile, DKT element, shell, schema and YAML (shared with cymsimweb) | | `src/modes.ts`, `src/patch.ts` | the modes file format; contact patches on it | | `src/render.ts` | scheduling a recording, and rendering it offline to a WAV | | `tools/`, `tests/` | Python cross-checks and node harnesses |