A resonator you can build out of any number of dimensions, and play.
Imagine a grid of points connected by springs. Push some of them out of place and let go: the displacement spreads, reflects off the edges, and interferes with itself. Put a microphone on one point, write down what it sees once per sample, and that is your audio. A one-dimensional grid behaves like a string. Two dimensions behave like a drumhead. Nothing stops you at three, and the higher ones have no acoustic counterpart at all -- they just sound like themselves.
Everything is simulated from the geometry. There are no oscillators, wavetables or filters anywhere: you describe a shape, say what pitch it should sound at, hit it, and listen.
Or don't hit it: point an audio file at a coordinate and the same machinery is an effect, colouring whatever you feed it with the resonances of a shape that need not exist.
Instruments are TOML files. Render one to a WAV:
cargo run --release --features config --bin hs-render -- instruments/string.tomlinstruments/ has seven to start from:
| file | what it is |
|---|---|
string.toml | one dimension, both ends fixed -- a plucked string |
clarinet.toml | one end fixed, one free -- odd harmonics only |
drum.toml | two dimensions, struck at a point, stereo |
bend.toml | a pitch bend up an octave |
guitar.toml | a string that rings down, highs first |
four_d.toml | four dimensions, a different edge on each |
effect.toml | no strike at all -- a plate driven by an audio file |
Copy one, change numbers, render again. That loop is the whole workflow.
Every 30 seconds a render says where it has got to:
46.6% 0:12:03 elapsed 0:13:47 remaining 21.4 steps/s
and at the same moment it writes what it has rendered so far to the output file. A long render is playable while it is still going, and killing one partway leaves a shortened take rather than nothing at all.
Two things about those partial files. Each is normalised to the loudest moment
it contains, so the same passage can sit at a different level in a snapshot
than it will in the finished render -- they are for hearing whether the sound
is going the way you wanted, not for keeping. And the finished file overwrites
the last snapshot, so once the render says wrote ..., what is on disk is the
real thing.
Anything that finishes in under 30 seconds prints nothing and snapshots nothing. It is over before the first report.
sample_rate = 44100duration = 2.0output = "string.wav"bits = 24# 16, 24 or 32. omit for 24
[[axis]] # one [[axis]] block per dimensionlen = 128# how many points along itlower = "fixed"# what happens at each endupper = "fixed"
[tune]
mode = [1] # the lowest mode of this shape...hz = 110.0# ...should sound at 110 Hz
[decay]
t60 = 2.5# ring down 60 dB in 2.5s. omit and it sustains forevert60_high = 0.08# ...but the bright partials go in 0.08s
[[excite]]
mode = [1] # or: point = [21], to strike a spot insteadamplitude = 1.0
[[tap]]
at = [19] # where to listen. one tap is mono, two are stereo
[[input]] # optional: drive a point from a mono WAVat = [64]
file = "voice.wav"One TOML rule worth knowing: a bare key belongs to the table header above it,
so sample_rate, duration, output and bits have to come before the first
[[axis]].
bits is the depth of the output file: 16, 24 or 32, and 24 if you leave it
out. The render itself is always double precision, and the file is
peak-normalised on the way out, so this is only about how much of that survives
into the file -- 24 for music work, 16 if you want smaller files and are done
processing.
One [[axis]] block per dimension, in order. len is how many points lie
along it. Longer axes sound lower, and cost more to compute -- the work scales
with the total number of points, so doubling every axis of a 4-D instrument is
sixteen times the work.
More dimensions means more modes packed into the same range of pitch. One dimension gives you a clean harmonic series and reads as a note. Two gives a crowded, inharmonic spectrum and reads as a struck plate. Four is dense enough that it stops sounding like a pitch at all.
Each axis has two ends, and each end is one of:
fixed-- clamped, like a string tied down. Fixed at both ends gives the full harmonic series.free-- the end can move, like an open pipe. Fixed at one end and free at the other gives odd harmonics only, sounding an octave lower than the same axis fixed at both ends -- the clarinet-ish, stopped-pipe sound.periodic-- the axis wraps around into a loop. Both ends of an axis must agree on this one.
Mixing edges across axes is where the unfamiliar sounds are; four_d.toml
uses a different combination on each of its four.
mode picks a vibration pattern -- one number per axis, [1] or [1, 1, 1]
being the lowest -- and hz says what it should sound at. Everything else
follows from that. You never set a speed or a stiffness by hand.
There is a highest pitch a given shape can reach, roughly
sample_rate / (2 * len * sqrt(dimensions)) for an axis fixed at both ends:
about 160 Hz for the 128-cell string, which is why it is tuned to 110. Ask for
more than a shape can do and the render is refused before it starts, with the
limit reported. Shorten the axes to go higher.
For a pitch bend, add an end pitch and a glide mode:
[tune]
mode = [1]
hz = 110.0hz_end = 220.0glide = "constant_action"The sweep is exponential in pitch, so it is even in cents. Use
constant_action (or constant_energy) for anything faster than a slow
portamento: bending pitch can otherwise pump energy into the instrument and
blow it up. free skips that safeguard and is only for very slow bends.
Left alone, the simulation is lossless: nothing rubs, nothing radiates, and a
plucked instrument sustains at full amplitude for as long as you render it.
That is the natural behaviour of the model, and it is what you get with no
[decay] table.
Add one and the sound rings down:
[decay]
t60 = 2.5t60_high = 0.08t60 is the time in seconds for the sound to fall 60 dB -- the same number
reverbs are dialled in with, and near enough "how long the note lasts". A
couple of seconds is a ringing, open note; 0.4 is a damped, muted one; 20 is a
long bloom that barely moves over a short render.
t60_high is the same thing for the top of the spectrum, and it is what makes
the difference between a note and a fade. Set it much shorter than t60 and
the bright, clattery part of a strike disappears quickly while the fundamental
rings on -- which is what a plucked string does, settling towards something
close to a pure tone within a second. Leave it out and every partial decays at
the same rate, so the sound keeps its opening timbre all the way down.
Both are real forces inside the model, not an envelope drawn over the output:
- Drag, as if the grid were moving through air. It slows every point in
proportion to how fast it is moving, and damps every mode equally.
t60alone is pure drag. - Internal friction, the springs rubbing against themselves. It resists
neighbouring points moving differently from each other, which is what fast,
bright vibration is, so it takes the highs first.
t60_highis what adds it.
Because they are forces, the damping is part of the physics: it changes the sound as it decays rather than just the level, and a heavily damped instrument sits very slightly flat, exactly as a real one does. Tuning takes that into account, so what you ask for is still what you get.
Two limits worth knowing. The highs cannot ring longer than the tuned mode
-- friction only ever works the one way -- and there is a steepest tilt the
model can reach, past which the instrument would need negative drag. Ask for
either and the render is refused up front, with the closest reachable
t60_high reported.
Two ways to set the thing in motion, and you can use as many [[excite]]
blocks as you like:
mode = [k]excites one vibration pattern cleanly, atamplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is whatstring.tomldoes.point = [21]displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.
at is the coordinate you listen at -- one number per axis. One [[tap]]
renders mono, two render stereo.
Tap position is a real timbral control, not a formality. A mode that has a node at your tap point is inaudible there, so moving the tap changes which harmonics you hear, the same way pickup position does on a guitar.
An [[input]] is a [[tap]] pointing the other way. Instead of listening at a
coordinate it pushes one, from a mono WAV file, one sample per step:
[[input]]
at = [21, 17]
file = "guitar.wav"gain = 1.0That is the whole of using this as an effect. Leave out [[excite]] entirely
and nothing is heard but the medium's response to the file: a resonator whose
character comes from a shape you chose. Keep an [[excite]] as well and you
get both, struck and driven at once.
file is resolved from wherever you run the command, the same place output
is written. It has to be mono and at the same sample rate as the render --
nothing here resamples, and playing a file at the wrong rate would transpose
it silently, so a mismatch is an error that says so. 16- and 24-bit, 32-bit
float and the rest of what a DAW exports all read fine.
Set duration longer than the file to catch the ring-out; the file simply
stops pushing when it ends. gain scales the file before it is applied, which
matters when you have several inputs, or an [[excite]] to balance against --
on its own it does nothing audible, because the output is normalised on the
way out.
The file applies a force at that point, the way a speaker glued to a real plate would push it. The point is not pinned to the signal: it stays free to move under its neighbours, so the medium's own vibration passes through rather than reflecting off the drive point.
One thing to know before you feed it something long: an effect wants a
[decay]. An undamped medium driven near one of its own resonances keeps
accumulating energy for as long as you drive it, which is exactly what a real
undamped resonator would do. Damping turns that into a finite gain, and a
sound that settles.
Nothing decays unless you ask it to. Without a [decay] table there is no
damping in the model at all, and a plucked instrument rings at constant
amplitude for as long as you render it.
The corollary: if a render sounds like silence and you did not ask for decay, it is almost certainly not dying out -- it is tuned so low that its fundamental is a fraction of a hertz, and two seconds is a fraction of one cycle.
Long grids play low, not high. The opposite of the intuition from real strings, where tightening raises pitch without limit. Here the size of the grid sets the ceiling, and the way to reach higher pitches is to use fewer points.
Big instruments are slow. The work is the point count times the step
count, and the step count is duration times sample_rate. Doubling either
number doubles the wait. Because the points multiply, adding an axis or
lengthening the ones you have gets expensive far faster than it looks: four
axes of 100 is a hundred million points, not four hundred.
Both columns count, which is why drum.toml and effect.toml are the same
shape but not the same wait:
| instrument | points | steps | how long |
|---|---|---|---|
clarinet.toml, bend.toml, string.toml, guitar.toml | 48 -- 128 | 88k -- 132k | under a second |
drum.toml | 3,072 | 88,200 | a few seconds |
effect.toml | 3,072 | 220,500 | ten seconds or so |
four_d.toml | 11,880 | 88,200 | twenty seconds |
| a 4-D grid of 37 million, 2 seconds of audio | 37,044,000 | 96,000 | an hour or two |
Everything but the last row runs on a single core, so those times are what an
ordinary desktop gives you; the last assumes sixteen cores and
--features parallel.
The instruments that ship here are all in the first four rows on purpose. The workflow is copy, change numbers, render again, and that only works while a render is quick. The last row is what it costs to leave that behind, and it is why the progress line and the snapshots exist.
Building with --features parallel lets a render spread each step across
cores. It works out for itself whether that is worth doing -- threads help on a
big instrument and hurt on a small one, and where the line falls depends on
your machine and what else it is busy with -- and it keeps checking as the
render goes, so a job that starts while the machine is busy speeds up when it
frees up. There is nothing to configure.
The instrument files cover the usual workflow, and the crate itself is a library if you want to drive it directly:
use hypersound::{Axis,Damping,Medium};let sr = 44_100.0;let axes = vec![Axis::fixed(128)];// 2.5s ring-down, with the highs gone in 0.08s. Damping::NONE rings forever.let damping = Damping::for_t60(&axes,&[1],2.5,Some(0.08), sr)?;letmut m = Medium::tuned_damped(axes,&[1],110.0, sr, damping)?;for k in1..=24{
m.excite_mode(&[k],1.0 / k asf64)?;// roughly a plucked string}let tap = m.tap_at(&[19]).unwrap();let audio:Vec<f64> = (0..88_200).map(|_| { m.step(); m.read(tap)}).collect();To drive it instead, Drive is the counterpart of Tap: resolve a coordinate
once, then push one sample of force per step.
let(input, rate) = hypersound::wav::read_mono("voice.wav")?;let drive = m.drive_at(&[64]).unwrap();let audio:Vec<f64> = (0..input.len()).map(|i| { m.push(drive, input[i]); m.step(); m.read(tap)}).collect();The four examples demonstrate the API rather than the workflow:
cargo run --release --example pluck # a string, writes pluck.wav
cargo run --release --example bend # pitch bends, safe and unsafe
cargo run --release --example four_d # a 4-D instrument, writes four_d.wav
cargo run --release --example bench # how fast it runs at several sizesThe default build has no dependencies. Instrument files need
--features config; renders can be spread across cores with
--features parallel, which measures as it goes and uses threads only while
they are actually paying for themselves.
NUMERICS.md has the scheme, the stability condition, the conserved energy, the modal formulas, performance numbers, and the story of what was wrong with the C program this replaced.