Sound and music made in code for a plain-JS phone game
Notes compiled 2026-11-05 for Elsewhere and the Tycho engine report. Target: plain JavaScript in a phone browser, nothing loaded, every quest a small world with its own look, and possibly its own sound. The question: how far procedural sound effects, ambience and music can go in Web Audio on a phone, and what a per-world “sound palette” (the eight-ink rule’s twin) would be.
Note on sourcing: the sfxr, jsfxr and ZzFX sources were read from their repositories; the NES APU from the NESdev wiki; iMUSE, MOD, Proteus, Koan and Super Mario World from Wikipedia or wikis; No Man’s Sky from A Sound Effect’s interview with Paul Weir; mobile browser behavior from MDN, Chromium, WebKit’s bug tracker and Paul Adenot’s Web Audio performance notes. Items marked “(search summary)” come from search-result summaries only and are less certain. Reasoning that is mine, not a source’s, is marked (inference).
What exists already
Tycho.audio (src/shell/audio.ts, docs/audio.md)
- Lodestar’s sound engine, moved into Tycho bit for bit and held to Lodestar’s own by
test/audio.test.mjsandtest/audio.js(every Web Audio call traced and compared). Three parts:Engine(two buses, sfx and music, each sending to a “space” of fed-back delays; a high pass, a low pass and a limiter; cues by name with a least gap and a voice cap; distance falloff and pan from a listener; nothing until the first tap; asleep while hidden),Bank(rich sounds rendered ahead in anOfflineAudioContext, a few variants each, one at a time between frames, through tanh saturation and a convolution reverb from a generated impulse; a play is then a buffer at a slightly different speed), andMusic(drones built once and kept running silent until wanted, noisebands for a place’s wind or water, a slower tick than the frame’s,dueevents now and then,duck, andpresence(s, at, w): a smoothstep for a layer coming in as a state rises). - Voices:
tone(oscillator, glide, low pass, vibrato, detune),noise(looped noise through a filter, sweeping),fm(carrier and modulator, falling index),stack(n detuned voices through a sweeping low pass);env(param, t, a, g, d, hold)is an exponential-ish envelope (setTargetAtTimedecay). - Variation comes from the sound’s own
Rand(xorshift32), never the game’s stream, “so sound never moves the world”. - What it does not have (inference, from reading audio.ts): no sample-loop synth (every live voice is a node graph), no note sequencer or tempo clock (Music’s tick is a state follower, not a beat clock), no scale or mode helpers beyond
hz(semi, root), no generator of cue recipes from a seed.
Elsewhere’s js/snd.js
- A thin shell over
Tycho.audio.Engine: four shell cues (tap,page,quest,souvenir) andpad, one note of a score’s chord as a slowstack. - A quest brings
sounds(cues merged in by name) andscore:{ bpm, beats, root, chords: [[semitones]...], w, lp, g }, played a bar at a time, scheduled 0.3 s ahead ofctx.currentTime. The slice’s four scores: the Roadbpm 48, root 146.83 (D3), sawtooth, lp 1100; Mrs. Pell’s street; the frogsbpm 104, root 261.63 (C4), triangle, lp 1500, four chords I vi IV V; the lighthousebpm 42, root 110 (A2), triangle, lp 700, a minor progression with rests that turns major once the lamp is lit. - So each quest already has a tiny sound palette (tempo, root, wave, filter, chords). What it lacks is melody, rhythm, layers that follow play, and per-world cue recipes.
Sound effects from a few numbers
sfxr (DrPetter, 2007)
- Made for the 10th Ludum Dare, December 2007, “a simple means of getting basic sound effects into a game” for entrants with 48 hours and no time to hunt for sounds; “a lot of the entrants used it.” — DrPetter: sfxr
- The jsfxr port (Eric Fredricksen, maintained by Chris McCormick) keeps sfxr’s parameter set, every value normalized to 0..1 (ramps signed):
wave_type(square, sawtooth, sine, noise),p_env_attack,p_env_sustain(0.3),p_env_punch,p_env_decay(0.4),p_base_freq(0.3),p_freq_limit,p_freq_ramp,p_freq_dramp,p_vib_strength,p_vib_speed,p_arp_mod,p_arp_speed,p_duty,p_duty_ramp,p_repeat_speed,p_pha_offset,p_pha_ramp(a flanger),p_lpf_freq(1),p_lpf_ramp,p_lpf_resonance,p_hpf_freq,p_hpf_ramp, plus volume, sample rate 44100 and sample size 8 or 16 bits. About 22 parameters. — chr15m/jsfxr sfxr.js - Generators: pickupCoin, laserShoot, explosion, powerUp, hitHurt, jump, blipSelect (jsfxr adds synth, tone, click, random). Each is a few lines of seeded choices within ranges: pickupCoin is a sawtooth (in jsfxr) at base 0.4–0.9 with a short sustain, a punchy decay and a 50% chance of an arpeggio jump; jump is a square with random duty and a rising ramp 0.1–0.3, short envelope, optional filters; hitHurt picks square, saw or noise with a strong falling slide; explosion is noise at a squared random base with a mostly negative slide, high punch, and optional repeat, phaser, vibrato or arpeggio. — same source
- Mutate: each parameter, with probability 1/2, moves by a random amount in ±0.05. — same source
- The sample loop runs at 8× oversampling (8 sub-samples averaged per output sample), with a one-pole low pass (with resonance) and high pass, the flanger and the envelope per sub-sample. Noise is a table of 32 random values refilled every period, so noise has a pitch. — same source
- bfxr (increpare) added five waveforms (triangle, breaker, tan, whistle, pink noise), a “1b noise” from the SN76489 (BBC Micro) algorithm, and three filters: compression, harmonics, bitcrusher. Apache 2.0. — rekkerd.org on bfxr; madeso’s C++ port of bfxr
- ChipTone (Tom Vian, SFBGames): first in Flash in 2015, later HTML5 and C++; descends from bfxr, which was built on Vian’s as3sfxr, a port of sfxr. Presets (coin, zap, boom, jump), a randomizer, a piano key to audition; sounds CC0. Version 0.5.1, “in development”. — ChipTone on itch.io (search summary)
ZzFX (Frank Force, 2019)
- “Zuper Zmall Zound Zynth”: MIT, copyright 2019 Frank Force; the micro version “under 1 kilobyte” (about 880 bytes, search summary). v1.4.0. — KilledByAPixel/ZzFX
- A sound is an array of numbers, positionally: volume (1), randomness (0.05), frequency (220 Hz), attack (0), sustain (0), release (0.1 s), shape (0 sine, 1 triangle, 2 sawtooth, 3 tan, 4 noise via
sin(t**3), 5 square with duty from shapeCurve), shapeCurve (1), slide, deltaSlide, pitchJump, pitchJumpTime, repeatTime, noise, modulation, bitCrush, delay, sustainVolume (1), decay, tremolo, filter (sign picks low or high pass). 21 in the 1.4 signature though the header still says “20 controllable parameters”. Times are in seconds and frequency in Hz, unlike sfxr’s 0..1. — ZzFX.js - It renders: a plain JS loop writes samples at 44100 Hz into a mono AudioBuffer and plays it with a buffer source; no oscillator nodes. Sounds can be “precached” and played later. — ZzFX README
- ZzFXM (Keith Clark): a tracker-style music player built on ZzFX instruments for size-limited productions (js13k). — Keith Clark: ZzFXM
Rendered buffers versus node graphs
- Paul Adenot (Mozilla, Web Audio editor) on costs: a BiquadFilterNode is “relatively cheap”, about five multiplies and four adds per sample, two frames of latency; ConvolverNode is “very expensive” (FFTs per block, bursts); HRTF panning is “very expensive” (up to four convolvers while a source moves); equal-power panning “rather cheap”; StereoPanner cheaper still; a GainNode “very cheap to create and connect”. OscillatorNode uses wavetables, and a frequency change can force new tables to be computed. AudioBufferSourceNode resampling is linear in Blink and WebKit (cheap, lower quality). Decoded buffers cost length × channels × 4 bytes. Mobile advice: replace convolution reverb with delays, all-passes and low passes; replace HRTF with a short reverb and equal-power panning; keep a “degraded” mode for low-end devices. — Adenot: Web Audio API performance and debugging notes
- Output latency: OS X and iOS “a few milliseconds”; Windows WASAPI ~10 ms; Linux PulseAudio 30–40 ms; Android “from 12.5 ms on a Galaxy Nexus to about 150 ms on some cheap devices”. — same source
- So: a short effect rendered once into a buffer costs one buffer source per play, whatever its recipe, and its samples are the same on every device. A live graph costs nodes per play but can follow the game (a pitch that tracks speed, a filter that opens with weather). (inference) Tycho already does both: Engine voices are live graphs; Bank renders offline.
- Sizes: a 0.5 s mono effect at 44100 Hz is 22,050 floats, 88 KB as Float32; ten effects with three variants each under 3 MB. Rendering one in a JS loop takes well under a millisecond (measured in the demo: see the page). (inference for the byte sums)
Footsteps, doors, ambient beds
- Andy Farnell, Designing Sound (MIT Press, 2010, 688 pages): any sound built from first principles, analysis then model then a Pure Data patch. His 2005 demo produced “fire, water, wind, rain, some animals, some footsteps, some guns, some vehicles” for a first-person shooter. — Designing Sound interview with Andy Farnell, 2012; MIT Press
- Perry Cook, “Modeling Bill’s Gait” (AES 2002): walking sounds analyzed into gait timing and heel-toe events, resonances by linear prediction, and particle statistics; resynthesized with PhISEM, which treats loose surfaces as particle collisions in a Poisson process. Gravel, grass and snow under feet are among PhISEM’s applications. — Cook 2002, PDF; Cook 2003
- So a footstep is two parts: a body (heel then toe, ~50–100 ms apart, a resonance for the surface: a hollow board rings low and long, stone is a short dry click) and a texture (many tiny grains for grass, gravel, snow; none for stone). (inference from Cook)
- Water: van den Doel 2005, “Physically Based Models for Liquid Sounds”, builds streams, pouring, rain and waves from single bubble sounds: each bubble a damped sine at the Minnaert frequency of its radius, rising in pitch as it rises; a user study put the rise parameter at ξ ≈ 0.1. — van den Doel 2005 at Georgia Tech repository; Moss et al. 2009, Sounding Liquids
- Minnaert: f ≈ 3.26 m/s / r for an air bubble in water at the surface, so a 1 mm bubble rings near 3.3 kHz and a 3 mm one near 1.1 kHz (the constant from the standard formula; inference to apply it).
- Wind: band-passed noise whose center and level follow a slow gust signal; Tycho’s
Music.bandis exactly this (a band of noise, silent until set). Birds: short sine chirps with fast pitch sweeps, optionally FM. (inference; Farnell treats both, chapter list not verified)
Hardware limits as a style
- NES 2A03: two pulse channels (duty 12.5, 25, 50, 75%; 11-bit period; f = f_CPU / (16 (t + 1)), f_CPU ≈ 1.79 MHz NTSC), a triangle (32 steps, 4-bit, no volume control, an octave below a pulse at the same timer), noise (a 4-bit period index into a table; a mode bit shortens the sequence to a periodic buzz) and DMC (1-bit delta samples, 7-bit output). Envelopes clock at ~240 Hz, length and sweep at ~120 Hz. Pulses share one nonlinear DAC, triangle-noise-DMC another. — NESdev wiki: APU
- Game Boy: two pulse channels (four duties; CH1 with a frequency sweep), a wave channel playing a user waveform, and an LFSR noise channel; length timer at 256 Hz, envelope at 64 Hz stepping every 1–7 ticks. The wave RAM is 16 bytes, 32 4-bit samples (from the registers chapter, not verified here). — Pan Docs: Audio
- SNES S-DSP: eight voices of BRR-compressed samples at any pitch, 16-bit stereo at 32 kHz, ADSR envelopes, echo through an 8-tap FIR; the SPC700 and the DSP share 64 KB of RAM, echo buffer included. — SNESdev wiki: S-SMP (search summary)
- Amiga MOD (Ultimate Soundtracker, Karsten Obarski, December 1987): four channels matching Paula, patterns of 64 rows, 8-bit PCM samples, 15 instruments (31 later); each cell starts a note with an optional effect, or changes volume, flow or effect. — Wikipedia: MOD; Wikipedia: Ultimate Soundtracker
- The parallel to eight inks: a chip’s channel budget forced composers to imply chords with arpeggios, share the noise channel between drums and effects, and give each part a fixed timbre. A per-world rule like “four voices and a noise, pulse duties 12.5/25/50, a triangle bass” is a style the way eight inks is. (inference)
Music that follows play
- iMUSE (Michael Land and Peter McConnell, LucasArts): in SCUMM 5 in 1991, first in Monkey Island 2: LeChuck’s Revenge, patented 1994 as US 5,315,057. It acted like a “pit orchestra”, playing longer or shorter sections while waiting on the game, with “transitions from one musical theme to another … done seamlessly”; composers placed branch and loop markers in sequences. In Woodtick each building brings its own instrument’s variation of the town theme, and leaving closes with a flourish back to the main theme. — Wikipedia: iMUSE
- Vertical layering and horizontal re-sequencing (Winifred Phillips): layers are synchronized parts switched on and off by game triggers (The Maw: three layers; LittleBigPlanet 2 Toy Story: six); re-sequencing builds a piece from segments chosen and ordered during play (Speed Racer, Spore Hero); her GDC 2021 talk covers hybrids. — Game Developer: horizontal re-sequencing, GDC 2021; hybrid structure
- Super Mario World: riding Yoshi adds a bongo layer to the overworld theme and its arrangements (not Ghost House or Castle, which Yoshi can’t enter). The cheapest, best-known vertical layer. — MarioWiki: Ground BGM (Super Mario World)
- Animal Crossing: 24 hourly tracks in every mainline game, switching on the hour, with variants for weather (sunny, rainy, snowy). Time of day as horizontal re-sequencing by the clock. — Animal Crossing World: New Leaf’s 24 hourly tracks; Nintendo Music hourly sync
- Rez (Tetsuya Mizuguchi, Dreamcast 2001): shooting makes sounds in time with the music; at GDC 2016 Mizuguchi said quantization synced players’ rhythms even when they weren’t on the beat, “and it felt like magic”. — Game Developer: oral history of Rez (search summary)
- No Man’s Sky (Paul Weir, with 65daysofstatic): Pulse, “a glorified random file player”: instruments (sets of variations of one sound) on a canvas with rules for how often they play and their pitch, pan and volume; four soundscape types (planet, space, wanted, map); 24 sets making 60 basic soundscapes plus special cases. “Very little of the audio is procedurally created, only the creature vocals and background fauna” (VocAlien, a vocal-tract synth); “nearly all of our audio is streamed”. — A Sound Effect: Paul Weir on No Man’s Sky; GDC 2017 talk The Sound of No Man’s Sky
- Spore (Kent Jolly, Aaron McLeran, with Brian Eno): music generated in real time in a customized Pure Data (EApd); the system filtered notes into scales and modes “so that it started to sound like music instead of noise”. GDC 2008. — PdCon: Using Pure Data in Spore and Darkspore; CDM on Spore’s Pd score (search summary)
- Proteus (Ed Key, David Kanaga, 2013): the landscape, plants and animals generate the music, which shifts as you move; four seasons, about an hour; Best Audio, IndieCade 2011. — Wikipedia: Proteus
- Eno: Music for Airports (1978) phases tape loops of different lengths; in 2/1 seven loops, each one sung note (C at 20.1 s, high F at 19.6 s); “They all repeat in cycles that are called incommensurable — they are not likely to come back into sync again” (1996 talk). — Reverb Machine: deconstructing Music for Airports. Generative Music 1 (1996) was a floppy disk of 12 pieces for SSEYO’s Koan player, meant for a particular sound card. — Intermorphic archive; Wikipedia: Koan
- Melody by Markov chain: order-1 or order-2 chains over scale-degree intervals, small steps tending to follow small steps and a leap followed by a step back. — Melody Triangle (Queen Mary) (search summary). Disasterpeace’s #January labels candidate notes by scale degree and maps them onto the current mode, so one logic works in any mode. — Disasterpeace blog, #January (search summary)
Mobile web audio
- Autoplay: in Chrome (Web Audio since 70/71) a context made without a user gesture starts
suspended, andresume()only works once play is allowed; “call context.resume() after the first user gesture (e.g. a click, or tap)”; no exceptions. — Chromium: autoplay policy; Intent to Ship: Autoplay Policy for Web Audio - iOS ring/silent switch: Safari’s default audio session type
autobehaves as ambient, so Web Audio is muted by the switch. Since iOS 17,navigator.audioSession.type = 'playback'makes Web Audio play through it (exclusive: pauses other apps’ playback). Before, the workaround was a looping silent<audio>element. The AudioSession API is an Editor’s Draft implemented only in Safari. — MDN: AudioSession.type; WebKit bug 237322; Web audio iOS unmute write-up - A game that respects the switch should leave it as ambient: the player who flipped it meant it (inference). A setting “play with the switch on” could set
playback. - Backgrounding: Safari puts a context in an
interruptedstate (not in the spec’s original list) when the page is left, a tab switched or the screen turned off; it must be resumed. WebKit bugs report contexts that sayrunningwith time advancing yet silent after a long background (276687), and a home-screen app on iOS 26 whoseresume()never returns (291892). — MDN: BaseAudioContext.state; WebKit 276687; WebKit 291892 - AudioWorklet: Safari 14.1 (macOS) and iOS 14.5, April 2021; Chrome earlier. — web-features: audio-worklet
- Battery: a running context keeps the audio thread and output device busy even when silent, so suspend it when nothing is wanted (Tycho’s Engine already sleeps when hidden and when both settings are 0). Prefer buffers to long live graphs, no ConvolverNode or HRTF per sound, a voice cap. (inference, with Adenot’s cost list)
- Sample rate: a context runs at the device’s output rate unless asked (commonly 48 kHz on phones); a buffer made at 44.1 kHz is resampled linearly on play in Blink and WebKit. Rendering at the context’s own rate avoids that. (Adenot for resampling; the 48 kHz figure is common knowledge, not sourced here)
- Scheduling: the standard pattern is a JS timer that looks ~100 ms ahead and schedules notes on
ctx.currentTime, which is sample-accurate, never on the frame orsetTimeout. — web.dev: A tale of two clocks
A sound palette per world (inference throughout)
- The eight-ink rule’s twin: a world declares a small, fixed instrument set and harmonic space, and every cue and every note is drawn from it, seeded. Fields: a scale or mode (and a darker one for dusk and night), a root, a tempo and swing, three or four voices each with one waveform and register (bass, harmony, lead, arpeggio), a drum kit made of noise and a triangle kick, a filter ceiling (bright or muffled), a space (dry room, echoing hall), and an effects “accent” (which waveform its coins and doors use).
- Mode by brightness: Lydian, Ionian, Mixolydian, Dorian, Aeolian, Phrygian, Locrian, each one note flatter than the last. Stepping one or two along at dusk and night darkens the same tune without rewriting it, if the tune is stored as scale degrees (the #January pattern).
- Pentatonic and other five-note sets (major pentatonic, Japanese yo and in, the minor pentatonic) give a world a place without many notes that clash; an equal-tempered approximation of gamelan slendro or pelog is only a flavor, not the real tuning.
- Layers by danger: Tycho’s
presence(s, at)already gives the crossfade; a world lists each layer’s threshold.