Sound effects use src/io/sound.phel; the background soundtrack uses src/io/music.phel. Both run a system audio player, with no FFI or PHP extensions.
audio-player probes PATH once and memoises the first available player: afplay, paplay, aplay or play. The soundtrack uses the same probe.
Each event uses the first available sound:
- A baked Freedoom sound, if the event has one.
- A macOS system sound from
/System/Library/Sounds/*.aiff(macos-sounds), on macOS only. - The terminal bell (
\a).:heartbeatskips the bell so a low-health player is not beeped every second.
SFX volume reaches afplay -v 0..1, paplay --volume=0..65536 and play -v (issue #459). aplay has no volume flag.
tools/bake-weapon-sounds.phel extracts DMX lumps from the Freedoom IWADs (BSD) into src/io/sound_data.phel as base64 8-bit mono WAVs. The repo contains no binary sound assets. At runtime ensure-sfx-file! decodes each sound to a temporary WAV once, memoises its path in sfx-files, and plays it.
The map (freedoom-sfx) covers 36 events: the seven weapon reports, per-type monster sight and death cries, the fireball launch, the melee claw, monster pain, the player's grunt, and the world cues (item, weapon, powerup, door, locked door, switch).
Freedoom splits its bestiary across two IWADs. The tool falls back to the second for a lump the first lacks (revenant, archvile and mancubus live in freedoom2.wad), and prints any event found in neither:
vendor/bin/phel run tools/bake-weapon-sounds.phel /path/freedoom1.wad /path/freedoom2.wadCost of the full set: sound_data.phel 190 KB → 1.3 MB, phar 3.47 MB → 4.08 MB, cold start unchanged (the namespace is not on the startup path).
A per-event php/exec "player file &" costs a fork+exec on the game thread (~3-4 ms). The chaingun fires 20 times a second within an 8.3 ms frame budget, and this cost does not appear in F3's cast/render split. io/sound instead starts a long-lived sh on the first audible event and writes one command line per sound (~0.001 ms). The sidecar:
- reads a line at a time, so it costs nothing between events;
- backgrounds each player, so a long sound cannot delay the next;
- guards on
kill -0 <game-pid>, so a killed game leaves no orphan playing; - exits on EOF when teardown (
stop-sfx!) closes the pipe; - sends its stdout and stderr to
/dev/null, so a shell error never prints into the game screen.
Write rules:
- Non-blocking. A full pipe drops the sound instead of stalling the frame.
- No torn lines. Lines are 60-90 bytes, below
PIPE_BUF(512 on macOS, 4096 on Linux), so a write lands whole or fails. A short write gets a newline as a backstop. - Skip dead pipes. Ctrl-C can kill the sidecar mid-frame. Writes are skipped once
proc_get_statussays it is gone, else "Broken pipe" prints over the screen. - No retries. A failed start is remembered. Hosts without
proc_openfall back to the per-event exec.
A silent session (tests, PHEL_DOOM_SILENT, muted settings) starts no sidecar.
Core code queues {:name :vol} on the world's per-frame :sfx queue via combat/push-sfx. tick-world clears the queue at frame start so events do not replay. After the tick, commands/play plays each queued event, gated on :sound-on. The heartbeat and the door-advance cue play directly from the outer loop.
Sources:
- Combat: the weapon's
:fire-sfxon every shot,:killplus a per-type death cry on a kill (the cry atdeath-cry-gain0.4 of the kill volume: the Freedoom screams are mastered much hotter),:clickon dry fire,:reload,:player-pain. A monster's melee hit adds:claw. A shot that staggers a monster adds its pain cry (pain-sfx-for) at 0.75 of its distance-adjusted volume::enemy-painfor imps and revenants,:enemy-pain-heavyfor the demon family. Only the single-target weapons roll pain, so only they make monsters cry out. - Enemies: a per-type sight cry on wake,
:fireballon launch. - Pickups and world:
:item,:weapon-up,:powerup,:door(also secret reveal),:switch. - Locked door:
:lockedat volume 0.5, on the rising edge only, so holding into the door re-fires at the ~1.5s hint cadence.physics/try-moveenqueues it inline instead of callingpush-sfx, which would create acore/combat<->core/physicsrequire cycle.
The two maps in src/io/sound_data.phel and src/io/sound.phel are the full event list.
Every shot plays a report at a volume based on distance to the enemy hit or, on a miss, the wall (cast-wall-dist). Point-blank is full volume; distant targets drop toward ~0.1. Nearby walls sound loud and open levels quiet. The kill cue plays at the same volume.
The settings page (settings.md) drives two levels:
- SFX:
set-sfx-scalar!stores a 0..1 multiplier thatplay-sfx!applies to every event. 0 mutes with no bell fallback. The first audio probe merges into the sound state instead of replacing it, so a scalar set before the first sound survives. - Music:
set-music-volume!restarts the loop at the new level. 0 stops the soundtrack. Onlyafplaytakes a music volume; other players ignore it.
The N key toggles :sound-on: mute is instant, in-flight sounds finish, and the OST starts or stops (sync-music!). PHEL_DOOM_SILENT=1 (set by composer test) gates all output. io/sound also mutes itself when running under phel test.
An original procedural riff in E minor evokes DOOM without its copyrighted soundtrack (src/io/music.phel, issue #158). It uses no WAD or shipped audio asset.
doom-riffis an eighth-note ostinato.variant-riffadds random 3rd/5th/octave stabs over the root hits.session-riffchains 8 such sections into one track, seeded off the PID, so each session sounds different.riff-wav-bytessynthesises a 16-bit mono 22050 Hz WAV. Each note is a fundamental, a sub-octave and two harmonics under an envelope that returns to zero, so section joins and the loop wrap are click-free.- The track is written once per session to
sys_get_temp_dir()/phel-doom-music-<pid>.wavand looped by a backgrounded shell, which guards onkill -0 <game-pid>.
Lifecycle, driven from commands/play: start-music! when gameplay begins, pause-music! / resume-music! (SIGSTOP / SIGCONT) on the pause edge, sync-music! on N, stop-music! on teardown, which also deletes the temp WAV. Every entry point is a no-op under PHEL_DOOM_SILENT or with no audio player.