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