Sound effects

day-part-sound plays short clips bundled with the app (a tap, a chime, a card sliding onto a pile) through each platform’s low-latency engine, once or as a loop that repeats until it is stopped. It is a headless part in parts/, like day-part-haptics, and the two are meant to be used together: a game pairs each haptic phrase with a clip, and the player turns either off in its settings.

Authoring

use day_part_sound::Play;

// Decode ahead, so the first play starts at once.
day_part_sound::preload(&[res::assets::sounds::tap_wav, res::assets::sounds::win_wav]);

day_part_sound::play(&res::assets::sounds::tap_wav);
day_part_sound::play_with(&res::assets::sounds::win_wav, Play { volume: 0.8, pan: -0.5, priority: 1 });
FunctionWhat it does
play(&clip)play at full volume, centered
play_with(&clip, Play { volume, pan, priority })play at a volume from 0 to 1, a pan from −1 (left) to 1 (right), and a priority
play_loop(&clip, Play { volume, pan, .. })repeat the clip until the Loop it returns is dropped
Loop::set_volume(v) / Loop::set_pan(p)change a loop while it sounds
Loop::is_playing() / Loop::stop()whether the loop is still held, and stopping it by name
preload(&[clip, …])load clips now, so their first play is immediate
unload_all()stop everything, loops included, and release every loaded clip, for a screen that is closing
set_volume(v) / volume()the master volume every play and every loop is scaled by
set_enabled(on) / is_enabled()a switch for all sound, for an app-wide setting
is_supported()whether this platform has a sound engine Day can reach
recent()the clips most recently played, oldest first, for tests and walkthroughs
loops()the clips looping now, oldest first, for tests and walkthroughs

Every call is fire-and-forget. None returns an error or panics, and none blocks on audio: a missing or unreadable clip is logged once and stays silent, and so does a platform without an engine. Call them from the UI thread, where the rest of an app’s code runs.

A bundled clip is named by its generated res::assets::… constant (resources.md). These constants can also be used in static cue tables; do not repeat the asset’s path. Games Fair keeps its cues as statics that pair a clip with a haptic phrase; its gamekit/src/chrome.rs is a worked example.

Loops

A clip that should keep sounding (an engine hum, a siren, rain) is a loop. play_loop starts it and returns a Loop; the clip repeats for as long as that value lives and stops when it is dropped.

use day_part_sound::{Loop, Play};

struct Race {
    engine: Option<Loop>,
}

impl Race {
    fn start(&mut self) {
        self.engine = Some(day_part_sound::play_loop(&res::assets::sounds::engine_wav, Play::at(0.6)));
    }
    fn throttle(&self, level: f32) {
        if let Some(engine) = &self.engine {
            engine.set_volume(0.3 + 0.7 * level);
        }
    }
    fn pause(&mut self) {
        self.engine = None; // the engine stops
    }
}

Tying the sound to a value means it ends with whatever owns that value: a paused game sets an Option to None, and a page that closes drops its state. Replacing one loop with another is an assignment, which starts the new loop and then stops the old one. Bind the result, because play_loop(…); alone drops the handle and stops the loop at once; the compiler warns about it.

  • Authoring. The clip repeats from its last sample straight to its first, so it must close on itself: a whole number of cycles of a tone, or a recording cut where the level and the slope match. A clip that fades in or out pulses once per repeat.
  • Volume and the switch. A loop is scaled by the master volume and obeys set_enabled, and follows both as they change. While its volume comes to zero it is silent but still held, and it starts again from the top of the clip when it has a volume.
  • Voices. Loops have LOOPS (4) voices of their own. A loop never takes one of the eight clip voices and never loses its voice to a clip, which is why Play::priority means nothing to play_loop. A fifth loop stops the oldest; that loop’s handle then reports is_playing() false.
  • Loading. Where clips load in the background (Android, HarmonyOS, the web), a loop asked for before its clip is ready starts when it is.
  • The background. On Android a loop pauses while the app has no activity in front and resumes with it. On the web the audio context is suspended while a loop’s tab is hidden. iOS silences the .ambient session in the background. A desktop app keeps sounding when another window is in front, as desktop apps do. A game should still stop its loops when it pauses.
  • The thread. A Loop belongs to the UI thread and is neither Send nor Sync.

Clips

Clips are ordinary data assets under the app’s resource/assets/, bundled on every platform like any other file there. The format is WAV. The canonical clip is 16-bit PCM, mono, 44.1 kHz, which every engine plays exactly as it is. Other PCM layouts (stereo; 8-, 24- or 32-bit integer; 32-bit float; another sample rate) also play: where an engine takes raw samples (Apple, Windows) the part converts them to the canonical form as it loads, and elsewhere the platform’s decoder reads them. Compressed formats are not supported on every engine, so the part does not accept them.

Keep clips short. Android’s and HarmonyOS’s SoundPool refuse a clip that decodes past about 1 MB, which is about 11 seconds of the canonical format, and every clip stays in memory while loaded. ffmpeg converts anything to the canonical form:

ffmpeg -i in.ogg -ac 1 -ar 44100 -c:a pcm_s16le -map_metadata -1 out.wav

Mixing

  • Voices. At most VOICES (8) clips sound at once. When all are busy, the oldest voice with the lowest priority stops for the new clip; a clip whose priority is below everything playing is skipped instead.
  • Repeats. A clip asked for again within MIN_REPEAT_MS (40 ms) of its last start is skipped, so a burst of the same sound (a streak of bricks, a deal of cards) stays crisp rather than piling up into one loud smear.
  • Volume. A play’s volume is multiplied by the master volume; a result of 0 plays nothing.
  • Pan. Apple platforms, Android, HarmonyOS, Linux and the web place a clip or a loop across the stereo field. Windows plays every one centered.
  • Other audio. Sound effects mix with music and podcasts from other apps rather than interrupting them. On iOS they follow the Silent switch.

Per-platform realization

PlatformEngineNotesLoops
iOSAVAudioEngine with a pool of AVAudioPlayerNodesthe .ambient audio session: mixes with other audio, silenced by the Silent switchnative: the node is given the buffer with the Loops option
macOSAVAudioEngine with a pool of AVAudioPlayerNodesthe engine starts on the first loadnative, as on iOS
AndroidSoundPool with USAGE_GAME audio attributesloads in the background from the APK’s assetsnative: a stream played with loop = -1
HarmonyOSSoundPool with STREAM_USAGE_GAMEloads in the background from the app’s raw filesnative: a stream played with loop: -1
webWeb Audiofetches and decodes each clip; the audio context starts on the page’s first tap or key press, as browsers requirenative: a source node with loop set
WindowsXAudio2a mastering voice in the game-effects category, 8 source voices for clips and 4 for loopsnative: the buffer’s LoopCount is XAUDIO2_LOOP_INFINITE
Linux (GTK, Qt)libpulse, loaded at run time, with the part’s own mixerreaches PulseAudio and PipeWire’s Pulse layer; the stream is open only while something soundsemulated: the mixer wraps the clip from its last sample to its first

Every native loop repeats sample-exactly inside the engine. Linux mixes in software.

On Apple platforms a route change (headphones, a new output device) stops the engine. The part starts it again on the next call that makes sound and puts every loop back on its node, so a loop that was the only sound stays silent until then.

Where loading happens in the background (Android, HarmonyOS, the web), a clip played before it is ready is skipped, because a late sound effect is worse than none; preload when a screen opens avoids that.

The part has no cargo features: the engine is chosen by #[cfg(target_os)] and, for Android, HarmonyOS and the web, by the daybridge arms in src/lib.rs (bridge.md). On Apple platforms it links AVFAudio through [package.metadata.day.ios] and [package.metadata.day.macos] (extending.md). No platform asks the user for a permission to play sound.

Linux

The part plays through libpulse, the client library of the PulseAudio sound server. PipeWire speaks the same protocol through its Pulse layer (pipewire-pulse), so the one library reaches the sound server on every current desktop. It is the only audio library the part uses on Linux.

libpulse’s simple API offers a stream to write samples into, so the part mixes in software. One thread owns one stereo stream at 44.1 kHz. Each clip and each loop sounding is a voice in the mix: every 10 ms the thread sums them, each at its own volume and pan, and writes the result.

  • Latency. The stream holds 50 ms, so a clip is heard, and a stopped loop goes silent, within about that.
  • Voices and priority. The mixer keeps the same rules as the native engines: eight clip voices, the oldest of the lowest priority making way for a ninth, and four loop voices.
  • Loops. A loop is a voice that wraps from its last sample to its first, so every repeat is sample-exact. This is the one platform where the part does the looping itself.
  • Changes. A new loop volume or pan is eased in across one 10 ms write, and a stopped loop or a displaced clip fades out across one, because a step in level is a click.
  • The stream. It opens when the first sound plays and closes two seconds after the last one ends. A quiet app holds no connection, and a run of taps shares one.
  • Without a server. When no sound server answers, clips are dropped and a held loop is tried again every few seconds.

What a machine needs. libpulse-simple.so.0 at run time. Desktop installs carry it: it is in libpulse0 on Debian and Ubuntu, pulseaudio-libs on Fedora, and libpulse on Arch. The part loads it with dlopen, so the app has no build-time dependency on it, starts without it, and reports is_supported() false there. A minimal container or CI image may need the package installed:

sudo apt install libpulse0        # Debian / Ubuntu

In a Flatpak. The sandbox has no route to the sound server unless the manifest asks for one. The part declares finish-args = ["--socket=pulseaudio"] in its [package.metadata.day.flatpak] table, and day pack adds that to the manifest of any app that depends on it (extending.md). The GNOME and KDE runtimes both ship libpulse. That socket carries recording as well as playback, which is how Flatpak defines it.

In an AppImage. The AppImage uses the host’s libpulse, like any other app on the machine.

Checking it

Run with DAY_LOG=debug and every play logs a play sounds/… line, and every loop a loop sounds/… line, under the day_part_sound target, whether or not the machine has speakers. In a test or a dayscript, recent() and loops() give the same record: Day-Showcase’s walkthrough taps a sound button and asserts the path it shows, then turns its loop on and off and asserts what is looping.

What it does not do

The part plays clips, not music. A loop repeats a short clip held in memory; a music track needs streaming and seeking, and would exceed SoundPool’s limit. That is a job for a streaming player rather than a sound-effects pool. A loop also keeps the pitch and speed its clip was recorded at.