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