Skip to content

Make Sfx

"""Generate original UI sound effects for HoloViz demo and explainer videos.

Deterministic for a given ffmpeg version: the same arguments always write the
same WAV, so commit this script, not the audio. Numpy writes the sounds and
ffmpeg levels each to the same loudness under a -1.5 dBFS peak ceiling, so
none clips or startles. The sounds are synthesized here, so they carry the
repository's license and need no SOURCES.md entry. Requires ffmpeg.

    python make_sfx.py --list
    python make_sfx.py --out sfx/                  # every sound + all-sounds.wav
    python make_sfx.py bubble success --pitch -2   # two sounds, two semitones down
"""

import argparse
import pathlib

import _ffmpeg
import numpy as np

SR = 48_000
PITCH = 1.0  # frequency multiplier for tonal parts, set from --pitch


def times(duration):
    return np.arange(int(SR * duration)) / SR


def envelope(t, attack, decay):
    return np.minimum(t / attack, 1) * np.exp(-t / decay)


def osc(freq):
    """Sine with continuous phase; ``freq`` is Hz per sample or a constant."""
    freq = np.broadcast_to(np.asarray(freq, dtype=float), np.shape(freq) or (1,))
    return np.sin(2 * np.pi * np.cumsum(freq * PITCH) / SR)


def tone(freq, t):
    return osc(np.full_like(t, freq))


def band_noise(n, lo, hi, seed):
    """Seeded noise band-passed in the frequency domain (no scipy needed)."""
    spectrum = np.fft.rfft(np.random.default_rng(seed).standard_normal(n))
    freqs = np.maximum(np.fft.rfftfreq(n, 1 / SR), 1)
    width = np.log(hi / lo) / 2.5
    spectrum *= np.exp(-0.5 * (np.log(freqs / np.sqrt(lo * hi)) / width) ** 2)
    return np.fft.irfft(spectrum, n)


def room(x, mix=0.18, seed=3):
    """Short stereo reverb from two decaying noise impulses."""
    ir_t = times(0.45)
    channels = []
    for s in (seed, seed + 1):
        ir = np.random.default_rng(s).standard_normal(len(ir_t)) * np.exp(-ir_t / 0.09)
        ir[0] = 0
        wet = np.convolve(x, ir)
        dry = np.pad(x, (0, len(ir) - 1))
        channels.append(dry + mix * wet / (np.max(np.abs(wet)) + 1e-9) * np.max(np.abs(x)))
    return np.stack(channels, 1)


def layer(duration, *events):
    """Mix ``(start_seconds, signal)`` events into one buffer."""
    n = int(SR * duration)
    out = np.zeros(n)
    for start, x in events:
        i = int(start * SR)
        out[i : i + len(x)] += x[: n - i]
    return out


def bubble():
    t = times(0.16)
    return osc(380 + 900 * (1 - np.exp(-t / 0.03))) * envelope(t, 0.003, 0.045)


def boing():
    t = times(0.55)
    f = 220 * (1 + 0.35 * np.exp(-t / 0.15) * np.sin(2 * np.pi * 14 * t))
    return osc(f) * envelope(t, 0.004, 0.16) + 0.25 * osc(2 * f) * envelope(t, 0.004, 0.07)


def pluck(freq=660, duration=0.5):
    t = times(duration)
    body = tone(freq, t) * envelope(t, 0.002, 0.18)
    attack = 0.35 * tone(4 * freq, t) * envelope(t, 0.001, 0.025)
    return body + attack


def success():
    return layer(1.1, (0, pluck(784, 0.4)), (0.08, pluck(988, 0.4)), (0.16, pluck(1319, 0.9)))


def sparkle(seed=5):
    rng = np.random.default_rng(seed)
    t = times(0.12)
    events = []
    for k in range(9):
        freq = 2000 * 2 ** (rng.integers(0, 12) / 12)
        start = k * 0.07 + rng.uniform(0, 0.02)
        events.append((start, 0.6 ** (k / 4) * tone(freq, t) * envelope(t, 0.001, 0.03)))
    return layer(0.9, *events)


def swoosh_pop():
    air = band_noise(int(SR * 0.45), 400, 4000, seed=2)
    ramp = np.linspace(0, 1, len(air))
    air *= np.sin(np.pi * ramp) ** 3 * (0.4 + 0.6 * ramp)
    return layer(0.7, (0, 0.5 * air / np.max(np.abs(air))), (0.38, bubble()))


def tap():
    t = times(0.08)
    knock = tone(1100, t) * envelope(t, 0.0005, 0.012)
    click = band_noise(len(t), 1500, 5000, seed=9) * envelope(t, 0.0003, 0.004)
    return knock + 0.13 * click


def riser():
    t = times(0.6)
    rise = np.minimum(t / 0.5, 1) ** 2
    cutoff = np.exp(-np.maximum(t - 0.55, 0) / 0.01)
    return 0.6 * osc(300 * 2 ** (2.5 * t / 0.6)) * rise * cutoff


SOUNDS = {
    "bubble": (bubble, "rising pop, for a box or chip appearing"),
    "boing": (boing, "springy wobble, for something dropping in and settling"),
    "success": (success, "three-note rising pluck, for the final result"),
    "sparkle": (sparkle, "scattered high plinks, for a reveal or payoff"),
    "swoosh-pop": (swoosh_pop, "air sweep landing on a bubble, for a token arriving"),
    "tap": (tap, "soft woody tick, for keystrokes or small landings"),
    "riser": (riser, "short pitch-up, for anticipation before a reveal"),
}


def save(path, x, loudness=-16, ceiling_db=-1.5):
    """Write a stereo WAV whose loudest 400 ms sits at ``loudness`` LUFS.

    Levelling by loudness rather than peak makes every sound equally loud to
    the ear, and ffmpeg's limiter keeps the peak under ``ceiling_db``, so no
    sound clips or startles next to the others.
    """
    stereo = room(x)
    stereo = stereo - stereo.mean(axis=0)
    raw = pathlib.Path(str(path) + ".raw.wav")
    _ffmpeg.write_wav(raw, 0.5 * stereo / np.max(np.abs(stereo)))
    try:
        _ffmpeg.level_short_sound(raw, path, loudness, ceiling_db)
    finally:
        raw.unlink(missing_ok=True)


def main():
    global PITCH
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("names", nargs="*", help="sounds to render (default: all)")
    parser.add_argument("--list", action="store_true", help="list sounds and exit")
    parser.add_argument("--out", default=".", help="output directory")
    parser.add_argument("--pitch", type=float, default=0, help="shift tonal parts by N semitones")
    parser.add_argument(
        "--loudness",
        type=float,
        default=-16,
        help="loudness of each sound's loudest 400 ms, in LUFS",
    )
    args = parser.parse_args()

    if args.list:
        for name, (_, use) in SOUNDS.items():
            print(f"{name:11s} {use}")
        return
    unknown = set(args.names) - SOUNDS.keys()
    if unknown:
        parser.error(f"unknown sound(s): {', '.join(sorted(unknown))}; see --list")

    PITCH = 2 ** (args.pitch / 12)
    out_dir = pathlib.Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    rendered = {name: SOUNDS[name][0]() for name in args.names or SOUNDS}
    for name, x in rendered.items():
        save(out_dir / f"{name}.wav", x, args.loudness)
        print(f"{name:11s} {len(x) / SR:.2f}s  {out_dir / name}.wav")
    if not args.names:
        gap = np.zeros(int(SR * 0.45))
        reel = np.concatenate([np.concatenate([x, gap]) for x in rendered.values()])
        save(out_dir / "all-sounds.wav", reel, args.loudness)


if __name__ == "__main__":
    main()