@apendua/sound
The noises the demos make, published as @apendua/sound.
The soundtracks are not here: they are files, and they belong to the apps that stream them. This package holds the sounds that are cheaper to compute than to fetch, and the three.js plumbing that puts them somewhere in the room rather than in the middle of your head.
rush build --to @apendua/soundJavaScript with JSDoc types, checked by tsc and shipped as source, like signal, justdom-core and justdom-three; the .d.ts files under lib-types are generated by build:types and are what a consumer sees.
There are two entry points. @apendua/sound is the waveforms, the voices and the output picker, and needs nothing but three.js. @apendua/sound/xr is the soundtrack player, which is tied to the life of an XR session and so needs justdom-three — an optional peer, kept behind its own subpath so that a consumer of the first half never has to have it.
What is in it
createClickWaveform(sampleRate) — fifty milliseconds of knock with a little ring left in it, the way two magnets sound when they meet: a scrape of contact noise on the leading edge, two partials ringing under it, faded at both ends so it cannot pop. It returns a Float32Array ready for AudioBuffer.copyToChannel and touches no Web Audio API at all, which is why it is tested in Node. The noise comes from a seeded generator, so the click is the same sound in every session and can be asserted on.
createClicks(listener, options?) — the same click, in the room. It hands back an object to add to the scene and a play(position) that sounds a click at a point in that object's coordinates:
import { createClicks } from "@apendua/sound";
const clicks = createClicks(listener);
// Inside the board, so a click sounds from the cell it belongs to.
board.add(clicks.object);
// ...on landing a brick:
clicks.play(cellCentre);Add it to the board rather than to the scene: a board is usually scaled and parked at arm's length, and the positions the game already has are in its coordinates, not the room's.
options covers the three things an app is likely to disagree about — voices, refDistance, volume — and the defaults suit a board about a metre square within arm's reach, which is every app here so far.
createVoices(listener, samples, options?) is the same, for any waveform. createClicks is createVoices with the click in it. The options are the same, and play(position, loudness?) takes a second argument from 0 to 1 that scales that one note, for a sound that is louder the harder its event was.
createSweepWaveform(sampleRate, sweep) is one frequency slide (from and to in Hz, over duration seconds), dying away at decay, with edge of second harmonic on it and noise of contact on its onset. A slide up is a pluck and a slide down is a thump. Like the click it returns samples peaking at exactly one and touches no Web Audio API.
checkSampleRate, createNoise and normalize are what the click and the sweep are both made of: the rate check, the seeded noise, and the pass that makes a waveform's loudest sample one. They are exported for an app writing a noise of its own.
audioOutputSelection() — which speakers all of this comes out of. A desktop headset is rarely the only thing plugged in, and the browser's own choice is not always the one the player is wearing:
import { audioOutputSelection } from "@apendua/sound";
const output = audioOutputSelection();
// output.outputs — everything sound could be sent to, as a signal
// output.deviceId — the chosen one, empty until the player picks
// output.select(id) — send the sound somewhere else
// output.updates — hold it open (use) to keep the list currentsetAudioOutput(listener.context, deviceId) applies the choice to an AudioListener's context, and audioOutputs() is the list on its own for anything that wants it without the selection around it.
What is in /xr
createMusicControls({ track, volume? }) — the soundtrack's state, in one object: the volume as a signal, an audioOutputSelection() of its own, and a step that moves the volume by whole tenths and stops at silence and at full. The track is a URL, which in every app here is an asset import of an mp3 the app keeps in src/assets/. Volume starts at DEFAULT_MUSIC_VOLUME (0.5) unless the app says otherwise.
Music(ctx, listener, controls) — plays it. The track starts when an XR session begins and stops when it ends, which keeps it out of the way of autoplay policy and leaves the browser tab silent until the headset goes on. Volume and output are followed live, so turning the music down takes the gain of the track that is playing rather than starting it over from the top.
import * as THREE from "three";
import { createMusicControls, Music } from "@apendua/sound/xr";
const listener = new THREE.AudioListener();
const music = createMusicControls({ track: glass_desk_habits, volume: 0.35 });
// ...in the scene, with the listener on the camera:
Music(ctx, listener, music);
// ...and on a panel somewhere: music.volume.get(), music.step(-1), music.outputHeld apart like this because the two ends are rarely near each other: the music starts wherever the scene is built, while the panel that turns it down is often several routes away, so both reach it through the controls object — passed down, as castle does, or put in a context.
Three things worth knowing
Several voices. One PositionalAudio node cannot play two clicks at once, and placing bricks quickly is the whole game. Four voices are used in turn, so a click only ever cuts off one that is already several placements old.
One entry per physical output. Chromium names the same speakers up to three times — as default, as communications, and under their own name — so the list folds those into the device they point at. That is what lets a scene decide whether to offer a switch by asking whether there is more than one entry, and show nothing on a headset with one way out for sound. Where the platform cannot route audio at all — AudioContext.setSinkId is Chromium-only — the list is empty rather than a switch that does nothing.
The panner is placed after play(). three.js only moves a positional sound's panner while the sound is playing, and reads it as the note starts. Setting the position and waiting for the next frame would put the click wherever the last one landed.
Used by
castle plays the click on a brick coming to rest, and plays its soundtrack through the player and the output picker, behind a Music section on a panel it already had. knights has its own player, and should, being a flat page with an <audio> element rather than a session to hang the track on.
This page is the package's README.