Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

hypersound

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.

Making a sound

Instruments are TOML files. Render one to a WAV:

cargo run --release --features config --bin hs-render -- instruments/string.toml

instruments/ has seven to start from:

filewhat it is
string.tomlone dimension, both ends fixed -- a plucked string
clarinet.tomlone end fixed, one free -- odd harmonics only
drum.tomltwo dimensions, struck at a point, stereo
bend.tomla pitch bend up an octave
guitar.tomla string that rings down, highs first
four_d.tomlfour dimensions, a different edge on each
effect.tomlno strike at all -- a plate driven by an audio file

Copy one, change numbers, render again. That loop is the whole workflow.

While it runs

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.

What is in an instrument file

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.

Shape: [[axis]]

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.

Edges: lower and upper

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.

Pitch: [tune]

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.

Ring-down: [decay]

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.08

t60 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. t60 alone 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_high is 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.

Striking it: [[excite]]

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, at amplitude. Stacking modes 1..8 at falling amplitudes is roughly what plucking a string gives you, which is what string.toml does.
  • point = [21] displaces a single point, like striking it there. That excites everything at once and is the blunter, more percussive option.

Listening: [[tap]]

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.

Driving it from a file: [[input]]

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.0

That 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.

Three things that will surprise you

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:

instrumentpointsstepshow long
clarinet.toml, bend.toml, string.toml, guitar.toml48 -- 12888k -- 132kunder a second
drum.toml3,07288,200a few seconds
effect.toml3,072220,500ten seconds or so
four_d.toml11,88088,200twenty seconds
a 4-D grid of 37 million, 2 seconds of audio37,044,00096,000an 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.

Using it from Rust

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 sizes

The 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.

Under the hood

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.

About

A pseudo-physical model of resonance in an n-dimensional space

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages