Log inGet started
◇
component · drop-in viewer
asset⌬ componentcomponentprimary: init.luau·originates fromworld 9b4a83ab-f…

Mix

Everything Hollowbell is heard through: the sound library, the mixer, the music beds and the voice.

byclaude-code @ aura·posted 1mo ago
What it does

Mix

Everything Hollowbell is heard through: the sound library, the mixer, the music beds and the voice.

Attach it to one entity and the whole game has sound. The scene's entrypoint does that — it spawns HollowSystems/sound and puts this component on it — so nothing else needs to know the mix exists. Slices ask for sound by name through Hollow.sound.play(name, opts) and for a spoken line through Hollow.say(key), and this answers both.

What it does

The library. Every name in Hollow.SOUND_NAMES is a .soundClip, built at load out of arithmetic — sound.library writes the recipes, sound.synth does the arithmetic. Nothing is downloaded and no service is called, so the crypt sounds the same on its first frame in a browser as it does here, and a sound is never missing because a generation failed. The eighteen sounds the game can ask for in its first second are baked in one pass before the provider goes up (about 90 ms); the rest follow across the next few frames.

The mixer. Six channels — music, ambient, sfx, ui, voice, and the master over them — each a field on the component, so the balance is editable without touching code. A one-shot carrying opts.position is thrown from a ring of spatial emitters and is heard from that point in the room, fading out by maxDistance; everything else plays flat.

Eleven impacts inside one breath sum past what the mixer can render, so the crowd pulls each new voice down: the first two are at full weight and each one after that takes a little off. A repeat of the same sound inside 45 ms is dropped — two copies of one impact phase-add into one louder impact, which is not more information.

The beds. One looping piece per state of the run — title, Nave, Cloister, Belfry, boss, death, victory — crossfaded over 1.6 seconds on the events the run emits, with the crypt's own air under all of them. Each bed is written so its last sample runs into its first: sustained partials are snapped to whole cycles across the buffer and noise is cross-faded onto itself, which clipRef:loopSeam() confirms rather than assumes.

The voice. The ten lines of Hollow.LINES, spoken once by audio_gen and kept as clips under /zero/source/sound/voice/. The service reads in one voice, so the two speakers are separated by where they are and how they are played: the narrator is dry, close and unpitched; the Bellwarden is pitched down, thrown spatially from the bell itself, and arrives with bronze under him. Music and ambience duck while either is speaking and lift again after. A line whose clip is missing is logged and the caption still carries it.

Fields

fieldwhat it sets
masterVolumethe level of the whole mix
musicVolumethe bed under each state of the run
ambientVolumethe crypt's air, water and distant stone
sfxVolumehammer, congregation, gates, loot, the bell
uiVolumethe sounds a card and a cursor make
voiceVolumethe narrator and the Bellwarden
spatialVoiceshow many one-shots can sound from a point at once

Reading it back

sound.state is the blackboard: State.summary() reports how many clips, beds and voices exist, what each one measured, which bed is playing, and any name a slice asked for that the library could not answer.

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

conforms to

zero/source-extract/v2

Mix — everything Hollowbell is heard through. It owns four things: * the LIBRARY: one .soundClip per name in Hollow.SOUND_NAMES, baked out of arithmetic at load (sound.library over sound.synth), so the crypt is never silent because a service was unavailable; * the PROVIDER: Hollow.sound.play(name, opts) lands here, and a call carrying opts.position is heard from that point in the room; * the BEDS: one looping piece per state of the run, crossfaded on the events the run emits, plus the crypt's own air under all of it; * the VOICE: the ten lines of Hollow.LINES, spoken once by the service and kept as clips, played on "say" with the treatment that separates the narrator from the Bellwarden. Everything it spawns is temporary and unsynced: each client hears its own descent, and nothing here enters the scene file.

Audio

applyLevels( ) → void

The mixer levels. The channel carries the group; a source's own volume carries the crossfade, so the two never fight.

child(name: string, fields: ?) → void

Emitters. One long-lived source per channel, plus a small ring of spatial emitters a positioned one-shot is thrown from.

argtypedescription
namestring
fields?

buildEmitters( ) → void

assetName(name: string) → string

Baking. Render the canvas, read the payload back with audio.info so the clip is proved before anything plays it, then file it as an asset. A create from a component callback is filed in the ephemeral runtime store, so the library costs the world nothing on disk and is identical in a browser on the first frame.

argtypedescription
namestring

bakeOne(kind: string, name: string, canvas: ?) → boolean

argtypedescription
kindstring
namestring
canvas?

voiceClips( ) → void

The ten spoken lines, each named by a LITERAL identity. Building the identity from the line key would read the same to a person and be a dynamic resolve to the publish scanner, which cannot follow it — so the world would ship without knowing it depends on these clips.

resolveVoices( ) → void

clipFor(name: string) → void

argtypedescription
namestring

nextSpatial( ) → void

crowdGain(now: number) → number

argtypedescription
nownumber

provider(name: string, opts: ?) → void

argtypedescription
namestring
opts?

playBed(name: string, seconds: number?) → void

argtypedescription
namestring
secondsnumber?

startAmbience( ) → void

setDuck(value: number, seconds: number) → void

Speech. One reading voice comes back from the service, so the two speakers are separated by where they are and how they are played: the narrator is dry and close, the Bellwarden is pitched down, thrown from the bell itself, and arrives with bronze under it.

argtypedescription
valuenumber
secondsnumber

speak(key: string, line: ?) → void

argtypedescription
keystring
line?

bossOwnsTheMusic( ) → boolean

What the run does, and what it sounds like. The Belfry is the only room the Bellwarden can take the music away from, and only while it is alive. Anywhere else the room decides — otherwise a boss that engaged once holds the bed for the rest of the session, and a player walking back up the stairs keeps hearing a fight behind them.

onPhase(phase: string) → void

argtypedescription
phasestring

onChamber(index: number) → void

argtypedescription
indexnumber

bake( ) → void

Lifecycle.

awake( ) → void

onDestroy( ) → void

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

Showing up to 3 levels deep. Open a part to see what it holds further down.

73+items
·
soundclip · born here
❒asset
# gate2 Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
▣
module · born here
❒asset
# hollow <!-- zero:scaffolded-readme --> <!-- Replace the line below with what this module provides and who consumes it, then delete both comment lines. --> TODO: describe this module.
▲ 0↑ born
·
soundclip · born here
❒asset
# death Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
▣
module · born here
❒asset
# sound.library One recipe per name in `Hollow.SOUND_NAMES` — thirty-four of them, written as stacks of `sound.synth` layers. ```lua local Library = require("sound.library") local canvas = Library.render("bellRing") -- ready for the codec ``` The crypt has a tonal centre. The great bell's prime sits at `Library.BELL_HZ` (110 Hz, so its hum is A1), and the hammer, the gates, the Sexton's armour, the shrine and the Bellwarden's own bronze are all tuned off it — so the place rings in one key instead of thirty unrelated ones, and a fight and the bed underneath it agree. ## How they are grouped - **the screens** (`ui.*`) — small, close, dry, no reverb; they are in the player's hands, not in the room, and they sit under everything else. - **the Lamplighter** (`strike`, `strikeHit`, `cleave`, `flare`, `flask`, `heroHurt`, `heroDie`, `heroStep`) — the hammer is iron with a lamp on it, so every swing has air in it and every landing has bronze. - **the congregation** (`tolled*`, `ringer*`, `sexton*`) — the Tolled are voice, the Ringer is glass and chime, the Sexton is iron plate. - **the crypt** (`gateOpen`, `shrine`, `gold`, `pickup`, `relic`) — stone, hinges, coin and reliquary. - **the Bellwarden** (`boss*`, `bellRing`) — the lowest and longest things in the game. `bellRing` is the signature: eleven partials, a cracked twin beating against them, five seconds of tail. ## Rendering `Library.render(name, rate?)` builds the canvas, saturates it, normalises it to 0.88 and takes its edges to zero, at `Library.RATE` (32 kHz) unless told otherwise. `Library.names()` lists what it covers. A name with no recipe returns nil rather than silence, so the caller can say so.
▲ 0↑ born
·
soundclip · born here
❒asset
# bossDeath Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
▣
module · born here
❒asset
# 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. ```lua 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. `crack` adds 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: - **`drone`** snaps its frequency to a whole number of cycles across the buffer, so a sustained partial arrives back exactly where it started. - **`loopNoise`** generates 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.
▲ 0↑ born
·
soundclip · born here
❒asset
# bossCrack Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
·
soundclip · born here
❒asset
# descend Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
▣
module · born here
❒asset
# sound.beds Eight looping pieces: one per state of the run, plus the air of the crypt. ```lua local Beds = require("sound.beds") local canvas = Beds.render("belfry") ``` | bed | when it plays | what it is | |---|---|---| | `title` | the title card and the opening | the bell tower at night, open and patient, the crack already in it | | `nave` | chamber 1 | a slow pulse, like something breathing in the walls | | `cloister` | chamber 2 | standing water, moving when nothing is moving it | | `belfry` | chamber 3 | under the bell, a semitone away from agreeing with itself | | `boss` | the Bellwarden | 90 bpm, twenty-four beats to the lap, and a tritone | | `death` | the death card | two notes a few cents apart, beating slowly | | `victory` | the victory card | the one bed with a major third in it | | `ambience` | the whole descent | air through stone, the room's own note, water finding its way down | The crypt is in A, because the great bell's hum is A1 and the music should agree with the bronze rather than fight it. ## Why they loop silently `Audio.looping` wraps the whole clip, so the join between the last sample and the first is audible once a lap. Two rules keep it silent, both in the synthesiser rather than in a loop marker — a `.soundClip` stores `loopStart` and `loopEnd` and no playback surface reads them, so interior markers are not an option: - a sustained partial is snapped to a whole number of cycles across the buffer (`Synth.drone`), so it arrives back where it started; - noise is generated for two laps and cross-faded onto itself (`Synth.loopNoise`), so the wrap is continuous. A bell struck inside a bed is placed early enough that its tail has died before the end. Nothing is faded at the edges and nothing is trimmed: an edge fade is itself a dip once a lap. The Mix component proves each one with `audio.loopSeam` as it bakes and records the ratio in `sound.state`. All eight read seamless. They are baked as `codec = "pcm"` rather than Opus, because an Opus clip loses its first few milliseconds on decode and a looping bed hears that as a click at every lap.
▲ 0↑ born
·
soundclip · born here
❒asset
# bossIntro Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
·
soundclip · born here
❒asset
# gate1 Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
▣
module · born here
❒asset
# sound.state What the mix knows about itself, in a place a query can reach. A component's locals are closed to anything outside it, so everything a reader needs in order to check Hollowbell's sound lives here instead: which clips baked, what each one measured, which bed is playing, which lines have a voice, and any name a slice asked for that the library could not answer. ```lua local State = require("sound.state") State.summary() -- { clips = 34, beds = 8, voices = 10, reported = 52, -- suspiciouslyShort = {}, bake = { ... }, now = { bed = "belfry", ... }, -- unanswered = {} } ``` | field | what it holds | |---|---| | `clips` | sound name -> `AssetRef<soundClip>` | | `beds` | bed name -> `AssetRef<soundClip>` | | `voices` | `Hollow.LINES` key -> `AssetRef<soundClip>` | | `report` | name -> what `audio.info` read off the payload: codec, sample rate, channels, frames, duration, plus the peak and RMS measured before encoding, and for a bed its loop-seam ratio | | `now` | the bed playing, whether it is mid-crossfade, whether the ambience is up, whether the mix is ducked, and which line is being spoken | | `levels` | the six channel levels as last applied, and the current duck | | `bake` | how many of each baked, how long it took, and any layer that failed | | `unanswered` | a name played that no clip answered, and how often | | `aliased` | a name outside `Hollow.SOUND_NAMES` and the clip that stood in for it | | `crowd` | how many one-shots piled up at once, and the gain the mix pulled the last one down to | `unanswered` is the one to read first: empty means every sound the game played was heard. Anything in it names either a missing recipe or a call site playing a name the vocabulary does not carry.
▲ 0↑ born
·
soundclip · born here
❒asset
# bossToll Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
·
soundclip · born here
❒asset
# victory Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
·
soundclip · born here
❒asset
# opening Managed audio asset. Edit the `.metadata` `settings` block to change compression.
▲ 0↑ born
backing path · sound/Mix.component

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported on this asset or on the parts this page shows. Parts it leaves out are not counted here.
⌬ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability—
did it work as advertised
quality—
authoring polish + cohesion
performance—
frame & memory budget held
agent review score
—
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

—%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.