synth
The synthesiser Hollowbell's sound is made of. Pure arithmetic over a mono f32 canvas: no source files, no service, no network, so it runs identically in a browser, headless, and offline.
sound.synth
The synthesiser Hollowbell's sound is made of. Pure arithmetic over a mono f32 canvas: no source files, no service, no network, so it runs identically in a browser, headless, and offline.
local Synth = require("sound.synth")
local c = Synth.canvas(0.4, 32000) -- 0.4 seconds at 32 kHz
Synth.tone(c, { freq = 220, amp = 0.5, decay = 0.15 })
Synth.noise(c, { amp = 0.3, lp = 4000, lpEnd = 600, decay = 0.04 })
Synth.reverb(c, { mix = 0.25 })
Synth.normalize(c, 0.88)
Every call adds to the canvas, so a sound is written as a stack of layers and the order does not matter.
What it makes
tone— a sinusoid with an attack, an exponential decay, and optional pitch glide, vibrato and FM. The FM is what turns a sine into something metallic or growling.bell— bronze. Eleven inharmonic partials at the ratios a cast bell actually rings at, each with its own decay so the high ones die first, plus the clapper's knock.crackadds a detuned twin on the mid partials whose beating is the buzz you hear in a cracked bell — which is the whole premise of this game, so it is a first-class argument rather than an effect.noise— filtered noise through a one-pole pair that can sweep. This is every impact, whoosh, footstep and breath.reverb— four combs and two allpasses. A crypt is small, hard and wet, so the combs are short and the tail is long.
What makes a loop loop
Audio.looping wraps the whole clip, so the join between the last sample
and the first is heard once a lap. Two calls are built to survive it:
dronesnaps its frequency to a whole number of cycles across the buffer, so a sustained partial arrives back exactly where it started.loopNoisegenerates two laps of noise and cross-fades the second over the first, so the wrap is continuous rather than a step.
Interior loop points are not the answer here: a .soundClip stores
loopStart / loopEnd and no playback surface reads them, so a bed has to
be seamless end to end. audio.loopSeam(zaud) reads whether it is.
Baking without dropping a frame
A bed is hundreds of thousands of samples. Set Synth.cooperative = true
inside a task and each layer hands the frame back once it has spent its
sample budget, so a long bake costs frames rather than one stall. Set it
false to bake in one pass when the caller would rather take the hitch.
Finishing
normalize scales to a target peak, saturate is a gentle soft clip so a
stacked layer never arrives as a hard one, trimEdges takes the first and
last few milliseconds to zero (a one-shot that starts on a non-zero sample
ticks — but never call it on a loop, where an edge fade IS the dip), and
measure reports peak, RMS and how many samples came out non-finite, which
the codec refuses.
Scoped to this part · feeds back into the world's score.