Skip to main content

alpha2_native/
audio_host.rs

1use std::collections::HashMap;
2
3use alpha2_audio::{AudioGraph, ChannelLayout, ProcessCtx, TransportState};
4#[cfg(feature = "timeline")]
5use alpha2_engine::{AudioRenderer, EngineProject};
6use alpha2_media::{
7    AudioBlock, AudioBlockProvider, AudioBlockSink, AudioSpan, CancelToken, EncodeError, SourceId,
8};
9use alpha2_time::{MediaTime, TICKS_PER_SECOND};
10
11/// Native offline-render failure.
12#[derive(Clone, Debug, Eq, PartialEq)]
13#[non_exhaustive]
14pub enum OfflineAudioError {
15    /// Input channel count or length is invalid.
16    InvalidInput,
17    /// Sink rejected a block.
18    Sink(EncodeError),
19    /// The project could not be mixed at the requested configuration.
20    #[cfg(feature = "timeline")]
21    Engine(alpha2_engine::EngineError),
22    /// An audible clip needed media the caller had not decoded.
23    MissingSources(Vec<SourceId>),
24}
25
26impl std::fmt::Display for OfflineAudioError {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::InvalidInput => formatter.write_str("offline audio input is invalid"),
30            Self::Sink(error) => write!(formatter, "offline audio sink failed: {error}"),
31            #[cfg(feature = "timeline")]
32            Self::Engine(error) => write!(formatter, "offline audio rendering failed: {error}"),
33            Self::MissingSources(sources) => {
34                write!(
35                    formatter,
36                    "offline audio is missing {} sources",
37                    sources.len()
38                )
39            }
40        }
41    }
42}
43
44impl std::error::Error for OfflineAudioError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::Sink(error) => Some(error),
48            #[cfg(feature = "timeline")]
49            Self::Engine(error) => Some(error),
50            _ => None,
51        }
52    }
53}
54
55/// Decoded, project-rate planar audio held per source for offline mixing.
56///
57/// The native decoder produces whole-stream planar audio, so one retained block
58/// per source is enough; the renderer indexes it by absolute source position.
59#[derive(Default)]
60pub struct DecodedAudioSources {
61    sources: HashMap<SourceId, AudioBlock>,
62}
63
64impl DecodedAudioSources {
65    /// Creates an empty cache.
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Retains one source's decoded audio, replacing any earlier block.
72    pub fn insert(&mut self, source: SourceId, block: AudioBlock) {
73        self.sources.insert(source, block);
74    }
75
76    /// Drops one source's decoded audio.
77    pub fn remove(&mut self, source: &SourceId) -> bool {
78        self.sources.remove(source).is_some()
79    }
80
81    /// Returns how many sources are retained.
82    #[must_use]
83    pub fn len(&self) -> usize {
84        self.sources.len()
85    }
86
87    /// Returns whether no source is retained.
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.sources.is_empty()
91    }
92}
93
94/// Each source is decoded once from end to end, so a block is its source's
95/// entire audio: before and after it the source is silent.
96impl AudioBlockProvider for DecodedAudioSources {
97    fn block(
98        &self,
99        source: &SourceId,
100        _start_sample: i64,
101        _frames: usize,
102    ) -> Option<AudioSpan<'_>> {
103        self.sources.get(source).map(AudioSpan::whole)
104    }
105}
106
107/// One offline project-audio render request.
108#[cfg(feature = "timeline")]
109#[derive(Copy, Clone, Debug, Eq, PartialEq)]
110pub struct ProjectAudioRequest<'a> {
111    /// Output channel count. One or two.
112    pub channels: usize,
113    /// Absolute project-rate sample index the range starts at. The blocks
114    /// written are positioned from the range's start, which is sample zero.
115    pub start_sample: i64,
116    /// Samples per channel to produce.
117    pub total_frames: usize,
118    /// Repeating block schedule, which may be irregular.
119    pub block_sizes: &'a [usize],
120}
121
122/// Mixes a whole project audio range and writes exact project-rate blocks.
123///
124/// This is the offline counterpart of the browser pump: the caller decodes each
125/// source once, and the engine owns gain, fades, panning, effects, bus summing,
126/// and limiting. Irregular `block_sizes` are repeated so callers can prove the
127/// mix does not depend on the host's block size.
128///
129/// # Errors
130/// Returns [`OfflineAudioError::InvalidInput`] for an empty schedule or range,
131/// [`OfflineAudioError::Engine`] when the renderer rejects the configuration,
132/// [`OfflineAudioError::MissingSources`] when an audible clip had no decoded
133/// media, and [`OfflineAudioError::Sink`] when the destination rejects output.
134#[cfg(feature = "timeline")]
135pub fn render_project_audio(
136    snapshot: &EngineProject,
137    request: &ProjectAudioRequest<'_>,
138    sources: &dyn AudioBlockProvider,
139    sink: &mut dyn AudioBlockSink,
140    cancel: &CancelToken,
141) -> Result<(), OfflineAudioError> {
142    let ProjectAudioRequest {
143        channels,
144        start_sample,
145        total_frames,
146        block_sizes,
147    } = *request;
148    if total_frames == 0 || block_sizes.is_empty() || block_sizes.contains(&0) || start_sample < 0 {
149        return Err(OfflineAudioError::InvalidInput);
150    }
151    let max_block = block_sizes.iter().copied().max().unwrap_or(1);
152    let mut renderer =
153        AudioRenderer::new(snapshot, channels, max_block).map_err(OfflineAudioError::Engine)?;
154
155    let mut produced = 0;
156    let mut sequence = 0;
157    let mut missing: Vec<SourceId> = Vec::new();
158    while produced < total_frames {
159        if cancel.is_cancelled() {
160            sink.abort_audio().map_err(OfflineAudioError::Sink)?;
161            return Err(OfflineAudioError::Sink(EncodeError::Cancelled));
162        }
163        let count = block_sizes[sequence % block_sizes.len()].min(total_frames - produced);
164        let offset = i64::try_from(produced).unwrap_or(i64::MAX);
165        let status = renderer
166            .render(
167                snapshot,
168                start_sample.saturating_add(offset),
169                count,
170                sources,
171                TransportState::Playing,
172            )
173            .map_err(OfflineAudioError::Engine)?;
174        for source in &status.missing_sources {
175            if !missing.contains(source) {
176                missing.push(source.clone());
177            }
178        }
179        let mut block = renderer.block(&status);
180        // The range is its own file: its first sample is the sink's sample zero.
181        block.start_sample = offset;
182        sink.write_audio(&block, cancel)
183            .map_err(OfflineAudioError::Sink)?;
184        produced += count;
185        sequence += 1;
186    }
187    if !missing.is_empty() {
188        // Report an incomplete mix rather than committing partial audio.
189        sink.abort_audio().map_err(OfflineAudioError::Sink)?;
190        return Err(OfflineAudioError::MissingSources(missing));
191    }
192    sink.finish_audio(cancel).map_err(OfflineAudioError::Sink)
193}
194
195/// Host sample representation considered during deterministic negotiation.
196#[derive(Copy, Clone, Debug, Eq, PartialEq)]
197pub enum HostSampleFormat {
198    /// Native 32-bit floating point.
199    F32,
200    /// Signed 16-bit integer.
201    I16,
202    /// Any format not supported by the realtime host.
203    Unsupported,
204}
205
206/// Platform-neutral device range used by negotiation tests and cpal mapping.
207#[derive(Copy, Clone, Debug, Eq, PartialEq)]
208pub struct HostAudioConfig {
209    /// Channel count.
210    pub channels: u16,
211    /// Lowest supported sample rate.
212    pub min_sample_rate: u32,
213    /// Highest supported sample rate.
214    pub max_sample_rate: u32,
215    /// Device sample representation.
216    pub format: HostSampleFormat,
217}
218
219/// Selects an output that plays stereo, requiring exact project-rate `f32`
220/// support.
221///
222/// Exactly two channels come first, then the fewest above two, then mono.
223/// Backends do not list the same counts: ALSA lists every count a device
224/// takes, WASAPI only its mix format's, and `CoreAudio` the sum of the
225/// device's stream channels, so a 5.1 endpoint or a multichannel interface
226/// may offer no stereo entry. Stereo is written to such a device's first two
227/// channels, which are front left and front right on all three, and the rest
228/// are silent.
229#[must_use]
230pub fn negotiate_output_config(
231    configs: &[HostAudioConfig],
232    project_rate: u32,
233) -> Option<HostAudioConfig> {
234    configs
235        .iter()
236        .copied()
237        .filter(|config| {
238            config.format == HostSampleFormat::F32
239                && config.channels > 0
240                && (config.min_sample_rate..=config.max_sample_rate).contains(&project_rate)
241        })
242        .min_by_key(|config| match config.channels {
243            2 => (0, 0),
244            1 => (2, 0),
245            more => (1, more),
246        })
247}
248
249/// Runs the same graph used by realtime hosts and writes exact project-rate blocks.
250///
251/// `inputs` are the graph's buses, flattened: each bus's planes in the graph's
252/// layout, one bus after another. All channels must have the same length.
253/// The output is in the graph's layout. Irregular `block_sizes` are repeated
254/// until the source is exhausted.
255///
256/// # Errors
257/// Returns [`OfflineAudioError::InvalidInput`] when `inputs` are not exactly
258/// the graph's buses, or for a malformed schedule, and
259/// [`OfflineAudioError::Sink`] when the destination rejects output.
260pub fn render_offline(
261    graph: &mut AudioGraph,
262    sample_rate: u32,
263    inputs: &[Vec<f32>],
264    block_sizes: &[usize],
265    sink: &mut dyn AudioBlockSink,
266    cancel: &CancelToken,
267) -> Result<(), OfflineAudioError> {
268    let layout = graph.layout();
269    let frames = inputs.first().map_or(0, Vec::len);
270    if sample_rate == 0
271        || inputs.is_empty()
272        || graph.buses().checked_mul(layout.channels()) != Some(inputs.len())
273        || inputs.iter().any(|channel| channel.len() != frames)
274        || block_sizes.is_empty()
275        || block_sizes.contains(&0)
276    {
277        return Err(OfflineAudioError::InvalidInput);
278    }
279    let max_block = block_sizes.iter().copied().max().unwrap_or(1);
280    graph.prepare(sample_rate, max_block);
281    let mut output: Vec<Vec<f32>> = (0..layout.channels())
282        .map(|_| vec![0.0; max_block])
283        .collect();
284    let mut offset = 0;
285    let mut sequence = 0;
286    while offset < frames {
287        if cancel.is_cancelled() {
288            sink.abort_audio().map_err(OfflineAudioError::Sink)?;
289            return Err(OfflineAudioError::Sink(EncodeError::Cancelled));
290        }
291        let count = block_sizes[sequence % block_sizes.len()].min(frames - offset);
292        let refs: Vec<&[f32]> = inputs
293            .iter()
294            .map(|channel| &channel[offset..offset + count])
295            .collect();
296        let mut outputs: Vec<&mut [f32]> = output
297            .iter_mut()
298            .map(|channel| &mut channel[..count])
299            .collect();
300        let sample_position = i64::try_from(offset).unwrap_or(i64::MAX);
301        graph.process(
302            &ProcessCtx {
303                position: MediaTime::from_ticks(
304                    sample_position
305                        .saturating_mul(TICKS_PER_SECOND)
306                        .div_euclid(i64::from(sample_rate)),
307                ),
308                sample_position,
309                sample_rate,
310                transport: TransportState::Playing,
311            },
312            &refs,
313            &mut outputs,
314        );
315        drop(outputs);
316        sink.write_audio(
317            &AudioBlock {
318                start_sample: sample_position,
319                sample_rate,
320                speakers: match layout {
321                    ChannelLayout::Mono => alpha2_media::SpeakerLayout::MONO,
322                    ChannelLayout::Stereo => alpha2_media::SpeakerLayout::STEREO,
323                },
324                channels: output
325                    .iter()
326                    .map(|channel| channel[..count].to_vec())
327                    .collect(),
328            },
329            cancel,
330        )
331        .map_err(OfflineAudioError::Sink)?;
332        offset += count;
333        sequence += 1;
334    }
335    sink.finish_audio(cancel).map_err(OfflineAudioError::Sink)
336}
337
338#[cfg(feature = "cpal-host")]
339mod realtime {
340    use std::sync::Arc;
341    use std::sync::atomic::{AtomicU64, Ordering};
342
343    use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
344    use cpal::{SampleFormat, Stream, StreamConfig};
345
346    use super::{
347        HostAudioConfig, HostSampleFormat, MediaTime, ProcessCtx, TICKS_PER_SECOND, TransportState,
348        negotiate_output_config,
349    };
350
351    /// Pull source invoked exclusively by the audio callback.
352    ///
353    /// `alpha2_engine::AudioRenderer` is an offline/block renderer and does
354    /// not implement this contract because timeline evaluation may allocate.
355    /// Realtime hosts must supply a pre-indexed, allocation-free source or feed
356    /// this callback from a bounded producer ring.
357    pub trait RealtimeAudioSource: Send + 'static {
358        /// Fills project-rate planar output without allocation or blocking.
359        fn render(&mut self, ctx: &ProcessCtx, output: &mut [&mut [f32]]);
360        /// Algorithmic latency already present in the source graph.
361        fn latency_samples(&self) -> u32 {
362            0
363        }
364    }
365
366    /// Native realtime host failure.
367    #[derive(Debug)]
368    #[non_exhaustive]
369    pub enum CpalHostError {
370        /// No output device exists.
371        NoOutputDevice,
372        /// No compatible floating-point configuration exists.
373        NoCompatibleFormat,
374        /// The callback limit is zero frames.
375        InvalidCallbackSize,
376        /// Device query or stream construction failed.
377        Device(String),
378    }
379
380    impl std::fmt::Display for CpalHostError {
381        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382            match self {
383                Self::NoOutputDevice => formatter.write_str("no audio output device is available"),
384                Self::NoCompatibleFormat => {
385                    formatter.write_str("no compatible audio output format is available")
386                }
387                Self::InvalidCallbackSize => {
388                    formatter.write_str("the audio callback limit must be at least one frame")
389                }
390                Self::Device(message) => write!(formatter, "audio device failed: {message}"),
391            }
392        }
393    }
394
395    impl std::error::Error for CpalHostError {}
396
397    /// Running cpal output stream and observable underrun count.
398    pub struct CpalOutputHost {
399        stream: Stream,
400        underruns: Arc<AtomicU64>,
401        sample_rate: u32,
402        channels: u16,
403        latency_samples: u32,
404    }
405
406    impl CpalOutputHost {
407        /// Opens the default output at the exact project rate in `f32`, with
408        /// the channels [`negotiate_output_config`] picks.
409        ///
410        /// `source` renders at most `max_callback_frames` frames at a time,
411        /// into that many frames of scratch allocated here. The device's own
412        /// buffer may be larger, and is rendered in as many calls as it takes.
413        ///
414        /// # Errors
415        /// Returns [`CpalHostError::InvalidCallbackSize`] when
416        /// `max_callback_frames` is zero, and a device or format error when no
417        /// exact realtime configuration can be opened.
418        pub fn open(
419            mut source: impl RealtimeAudioSource,
420            project_rate: u32,
421            max_callback_frames: usize,
422        ) -> Result<Self, CpalHostError> {
423            let device = cpal::default_host()
424                .default_output_device()
425                .ok_or(CpalHostError::NoOutputDevice)?;
426            if max_callback_frames == 0 {
427                return Err(CpalHostError::InvalidCallbackSize);
428            }
429            let supported = device
430                .supported_output_configs()
431                .map_err(|error| CpalHostError::Device(error.to_string()))?
432                .collect::<Vec<_>>();
433            let offered = supported
434                .iter()
435                .map(|config| HostAudioConfig {
436                    channels: config.channels(),
437                    min_sample_rate: config.min_sample_rate(),
438                    max_sample_rate: config.max_sample_rate(),
439                    format: match config.sample_format() {
440                        SampleFormat::F32 => HostSampleFormat::F32,
441                        SampleFormat::I16 => HostSampleFormat::I16,
442                        _ => HostSampleFormat::Unsupported,
443                    },
444                })
445                .collect::<Vec<_>>();
446            let chosen = negotiate_output_config(&offered, project_rate)
447                .ok_or(CpalHostError::NoCompatibleFormat)?;
448            let selected = offered
449                .iter()
450                .position(|config| *config == chosen)
451                .map(|index| supported[index])
452                .ok_or(CpalHostError::NoCompatibleFormat)?;
453            let device_rate = project_rate;
454            let config: StreamConfig = selected.with_sample_rate(device_rate).config();
455            let channels = usize::from(config.channels);
456            let mut planar = [
457                vec![0.0_f32; max_callback_frames],
458                vec![0.0_f32; max_callback_frames],
459            ];
460            let underruns = Arc::new(AtomicU64::new(0));
461            let callback_underruns = Arc::clone(&underruns);
462            let error_underruns = Arc::clone(&underruns);
463            let mut sample_position = 0_i64;
464            let latency_samples = source.latency_samples();
465            let stream = device
466                .build_output_stream(
467                    &config,
468                    move |data: &mut [f32], _| {
469                        if !fill_device_buffer(
470                            &mut source,
471                            &mut planar,
472                            data,
473                            channels,
474                            device_rate,
475                            &mut sample_position,
476                        ) {
477                            callback_underruns.fetch_add(1, Ordering::Relaxed);
478                        }
479                    },
480                    move |_| {
481                        error_underruns.fetch_add(1, Ordering::Relaxed);
482                    },
483                    None,
484                )
485                .map_err(|error| CpalHostError::Device(error.to_string()))?;
486            Ok(Self {
487                stream,
488                underruns,
489                sample_rate: device_rate,
490                channels: config.channels,
491                latency_samples,
492            })
493        }
494        /// Starts callback delivery.
495        ///
496        /// # Errors
497        /// Returns the device error reported by cpal.
498        pub fn play(&self) -> Result<(), CpalHostError> {
499            self.stream
500                .play()
501                .map_err(|error| CpalHostError::Device(error.to_string()))
502        }
503        /// Number of errors the device's stream reported, and of device
504        /// buffers that were not whole frames and were played as silence.
505        #[must_use]
506        pub fn underruns(&self) -> u64 {
507            self.underruns.load(Ordering::Relaxed)
508        }
509        /// Negotiated device sample rate.
510        #[must_use]
511        pub const fn sample_rate(&self) -> u32 {
512            self.sample_rate
513        }
514        /// Negotiated output channels.
515        #[must_use]
516        pub const fn channels(&self) -> u16 {
517            self.channels
518        }
519        /// Source graph latency for transport compensation.
520        #[must_use]
521        pub const fn latency_samples(&self) -> u32 {
522            self.latency_samples
523        }
524    }
525
526    /// Renders `source` into one interleaved device buffer of `channels`,
527    /// advancing `sample_position` by the frames written.
528    ///
529    /// The buffer is rendered in pieces no longer than the scratch planes,
530    /// each at its own position. The source renders mono for a mono device
531    /// and stereo otherwise; a device with more channels gets stereo in its
532    /// first two and silence in the rest. Returns false, after writing
533    /// silence, only for a buffer that is not whole frames.
534    pub(super) fn fill_device_buffer(
535        source: &mut impl RealtimeAudioSource,
536        planar: &mut [Vec<f32>; 2],
537        data: &mut [f32],
538        channels: usize,
539        device_rate: u32,
540        sample_position: &mut i64,
541    ) -> bool {
542        let piece = planar[0].len();
543        if channels == 0 || piece == 0 || !data.len().is_multiple_of(channels) {
544            data.fill(0.0);
545            return false;
546        }
547        let rendered = channels.min(2);
548        for chunk in data.chunks_mut(piece * channels) {
549            let frames = chunk.len() / channels;
550            let ctx = ProcessCtx {
551                position: MediaTime::from_ticks(
552                    sample_position
553                        .saturating_mul(TICKS_PER_SECOND)
554                        .div_euclid(i64::from(device_rate)),
555                ),
556                sample_position: *sample_position,
557                sample_rate: device_rate,
558                transport: TransportState::Playing,
559            };
560            let (left, right) = planar.split_at_mut(1);
561            if rendered == 1 {
562                source.render(&ctx, &mut [&mut left[0][..frames]]);
563            } else {
564                source.render(&ctx, &mut [&mut left[0][..frames], &mut right[0][..frames]]);
565            }
566            for (frame, destination) in chunk.chunks_exact_mut(channels).enumerate() {
567                for (channel, sample) in destination.iter_mut().enumerate() {
568                    *sample = if channel < rendered {
569                        planar[channel][frame]
570                    } else {
571                        0.0
572                    };
573                }
574            }
575            *sample_position =
576                sample_position.saturating_add(i64::try_from(frames).unwrap_or(i64::MAX));
577        }
578        true
579    }
580}
581
582#[cfg(feature = "cpal-host")]
583pub use realtime::*;
584
585#[cfg(test)]
586#[path = "audio_host_tests.rs"]
587mod tests;