Oddio Audio Library

repository·main·Indexed 41 days ago

https://github.com/ralith/oddio

A lightweight, real-time, game-oriented audio library featuring 3D spatialization, Doppler effects, and a composable signal system. Version 0.7.4 provides a decoupled Sans I/O architecture, allowing audio output to be sent to any destination. It includes primitives for signal processing such as the Adapt filter for level normalization, constant-power cross-fading via Fader, and thread-safe volume control through GainControl.

Tokens
11K
Snippets
43
Records
48
Agent score
51%

What's inside Oddio

  1. Overview of Oddio audio library

    main

    Oddio is a lightweight, real-time, game-oriented audio library designed for high performance and flexibility. Key features include:

    • Sans I/O: Decoupled from specific I/O backends; you can send audio output to any destination.
    • Real-time Performance: Efficient, wait-free audio output designed to prevent glitches unless CPU resources are exhausted.
    • 3D Spatialization: Built-in support for 3D audio, including Doppler effects and propagation delay.
    • Extensibility: Users can implement the Signal trait for custom streaming synthesis and filtering.
    • Composability: Signals can be transformed while maintaining access to the inner Signal's controls.
  2. How to play and control spatial audio with `SpatialScene`

    main

    Oddio uses a hierarchical model for audio playback. A SpatialScene acts as a specialized Mixer that spatializes the signals played within it.

    Workflow:

    1. Initialize: Create a scene and a handle using SpatialScene::new().
    2. Prepare Audio: Wrap static audio data in Frames and convert it into a FramesSignal.
    3. Play: Use scene_handle.play(frames, options) to start playback. This returns a handle to the specific instance of that sound.
    4. Control: Use the returned handle to update the sound's position or velocity in real-time using handle.set_motion(position, velocity, fade).

    Example:

    let (mut scene_handle, mut scene) = oddio::SpatialScene::new();
    
    // In game logic:
    let position = [0.0, 0.0, 0.0].into();
    let velocity = [0.0, 0.0, 0.0].into();
    let frames = oddio::FramesSignal::from(oddio::Frames::from_slice(sample_rate, &frames));
    
    let mut handle = scene_handle.play(frames, oddio::SpatialOptions {
        position,
        velocity,
        ..Default::default()
    });
    
    // When position/velocity changes:
    handle.set_motion(position, velocity, false);
    let (mut scene_handle, mut scene) = oddio::SpatialScene::new();
    
    // In audio callback:
    # let data = &mut [][..];
    # let output_sample_rate = 44100;
    let out_frames = oddio::frame_stereo(data);
    oddio::run(&mut scene, output_sample_rate, out_frames);
    
    // In game logic:
    # let frames = [];
    # let sample_rate = 44100;
    # let position = [0.0, 0.0, 0.0].into();
    # let velocity = [0.0, 0.0, 0.0].into();
    let frames = oddio::FramesSignal::from(oddio::Frames::from_slice(sample_rate, &frames));
    let mut handle = scene_handle
        .play(frames, oddio::SpatialOptions { position, velocity, ..Default::default() });
    
    // When position/velocity changes:
    handle.set_motion(position, velocity, false);
  3. How `Stream` and `StreamControl` work together

    main

    The Stream and StreamControl pattern decouples audio generation from audio playback. This is essential for maintaining low-latency audio output while performing heavy tasks like decoding or network I/O on a separate thread.

    1. Producer Thread: Holds the StreamControl object. It calls .write() to push decoded audio frames into the buffer.
    2. Consumer (Audio) Thread: Holds the Stream object. It calls .sample() (via the Signal trait) to pull audio data for the hardware output. The Stream uses linear interpolation (lerp) to provide smooth playback even if the sampling timing varies slightly.

    When the StreamControl is dropped, the Stream will eventually reach its end and report is_finished() == true once all buffered samples have been played.

    // Thread A (Producer)
    loop {
        let data = decoder.next_chunk();
        control.write(&data);
    }
    
    // Thread B (Consumer/Audio Output)
    loop {
        stream.sample(interval, &mut output_buffer);
        if stream.is_finished() {
            break;
        }
    }
  4. Implement dynamic volume control with `Gain` and `GainControl`

    main

    For volume controls that change over time (e.g., a fader), use Gain. Gain::new(signal) returns a tuple containing the Gain signal processor and a GainControl handle.

    GainControl is thread-safe and can be used to update the volume from a different thread (like a UI thread) while the audio processing thread is running. Changes to the gain are automatically smoothed over a period of 0.1 seconds to prevent audible clicks or pops.

    Best Practice: Place a Gain combinator near the end of your signal pipeline where the input amplitude is in the range [0, 1]. Map your maximum volume to 0 dB and your minimum to a value like -60 dB.

    // Create the gain processor and its control handle
    let (mut control, mut gain_signal) = Gain::new(my_signal);
    
    // In a different thread or later in your logic:
    control.set_gain(-10.0); // Smoothly transition to -10 dB
  5. Core Oddio Primitives

    main

    Oddio is built around several key abstractions for managing audio signals:

    • Sample: A type alias for f32, representing unitless instantaneous sound wave amplitude.
    • Frames: Stores static audio data.
    • FramesSignal: A signal derived from Frames that can be played.
    • Mixer: Allows multiple signals to be played concurrently and controlled during playback.
    • SpatialScene: A specialized mixer that applies spatialization to its signals.
    • Signal: The base trait for anything that can produce audio frames over time.
  6. How SpatialScene and SpatialSceneControl work together

    main

    Spatial audio in Oddio is managed through a pair of objects: SpatialScene and SpatialSceneControl.

    • SpatialScene is the audio processing component (a Signal) that produces stereo output. It should be integrated into your audio graph.
    • SpatialSceneControl is the handle used to manipulate the scene. You use it to play new sounds, update the listener's rotation, and manage active signals.

    This separation allows you to keep the audio processing on a high-priority audio thread while managing the scene logic from a different thread.

    // Create the scene and its control handle
    let (mut control, mut scene) = SpatialScene::new();
    
    // 'scene' is used for audio processing (e.g., in your audio callback)
    // 'control' is used to manage the sounds and listener
  7. How Fader cross-fading works

    main

    The Fader implements a constant-power cross-fade. Instead of a linear gain ramp, it uses the square root of the progress to calculate fade levels:

    • Fade out (old signal): (1.0 - progress).sqrt()
    • Fade in (new signal): progress.sqrt()

    This mathematical approach ensures that the total perceived power remains constant during the transition, preventing the 'dip' in volume often heard with linear cross-fades when blending uncorrelated audio signals.

  8. Use Mixer to combine multiple audio signals

    main

    A Mixer<T> is a Signal that allows you to mix multiple dynamic audio signals together. It is initialized using Mixer::new(), which returns a tuple containing a MixerControl<T> (to manage playback from other threads) and the Mixer<T> instance itself (to be sampled in your audio loop).

    To add signals to the mixer, use the MixerControl::play method. This method returns a Mixed handle that allows you to stop the specific signal or check its status.

    // Initialize the mixer and its control handle
    let (mut mixer_control, mut mixer) = Mixer::new();
    
    // Play a signal using the control handle
    let mut handle = mixer_control.play(some_signal);
    
    // In your audio processing loop:
    let mut output_buffer = [0.0; 1024];
    mixer.sample(0.1, &mut output_buffer);
  9. Basic usage pattern for Oddio

    main

    Oddio separates audio processing into two contexts: the audio callback (real-time thread) and the game logic (control thread).

    1. Setup: Create a SpatialScene to manage audio sources and their spatial properties.
    2. Audio Callback: Use oddio::run to process the scene and generate audio frames based on the current sample rate.
    3. Game Logic: Use the scene_handle to play audio signals (like FramesSignal) and update their spatial properties (position and velocity) using the returned handle.
    let (mut scene_handle, mut scene) = oddio::SpatialScene::new();
    
    // In audio callback:
    let out_frames = oddio::frame_stereo(data);
    oddio::run(&mut scene, output_sample_rate, out_frames);
    
    // In game logic:
    let frames = oddio::FramesSignal::from(oddio::Frames::from_slice(sample_rate, &frames));
    let mut handle = scene_handle
        .play(frames, oddio::SpatialOptions { position, velocity, ..Default::default() });
    
    // When position/velocity changes:
    handle.set_motion(position, velocity, false);
  10. Configure AdaptOptions for the Adapt filter

    main

    The AdaptOptions struct defines how the Adapt filter behaves. You can use AdaptOptions::default() for standard behavior or specify custom values:

    FieldTypeDescription
    tauf32How smoothly the filter responds. Smaller values reduce time spent outside the target range but lower perceived dynamic range. 0.1 is a recommended starting value.
    max_gainf32The maximum linear gain the filter is allowed to apply, regardless of the input signal.
    lowf32The RMS threshold below which the gain will increase (up to max_gain).
    highf32The RMS threshold above which the gain will decrease. This should typically be set lower than your desired maximum peak output to prevent clipping transients.

    Default Values:

    • tau: 0.1
    • max_gain: f32::INFINITY
    • low: 0.1 / 2.0f32.sqrt()
    • high: 0.5 / 2.0f32.sqrt()
    let options = AdaptOptions {
        tau: 0.1,
        max_gain: 10.0,
        low: 0.05,
        high: 0.4,
    };
  11. Complete example of `swap` communication

    main

    This example demonstrates the full lifecycle of a swap primitive: initializing, updating a value via the sender, flushing, refreshing the receiver, and accessing the new value.

    let (mut sender, mut receiver) = swap(|| 0);
    
    // 1. Prepare and send a value
    *sender.pending() = 1;
    sender.flush();
    
    // 2. Poll the receiver
    if receiver.refresh() {
        // 3. Access the updated value
        assert_eq!(*receiver.received(), 1);
    }
  12. Trigger a cross-fade using FaderControl

    main

    The FaderControl<T> provides a thread-safe way to command a Fader<T> to transition to a new signal.

    Use the fade_to(signal, duration) method to specify the target signal and the time (in seconds) over which the cross-fade should occur. The transition uses constant-power fading (square root of progress) to maintain consistent loudness during the blend.

    // control is the FaderControl<T> returned by Fader::new()
    control.fade_to(new_signal, 2.0); // Fade to new_signal over 2 seconds