cranpose_audio/lib.rs
1#![deny(unsafe_code)]
2#![deny(missing_docs)]
3
4//! The real-time audio engine behind [`cranpose_services::audio`].
5//!
6//! `cranpose-services` defines the Compose-shaped API — [`AudioPlayer`], the
7//! [`SoundId`] handle, `ProvideAudio`, `rememberSoundBank` — and ships a no-op
8//! default. This crate is the implementation an app installs when it wants
9//! sound: a software mixer on the platform's real-time thread, fed through a
10//! lock-free queue from the UI thread.
11//!
12//! ```rust,ignore
13//! // Once, at startup, before the first composition.
14//! cranpose_audio::install();
15//! ```
16//!
17//! # What runs where
18//!
19//! | Thread | Work |
20//! | --- | --- |
21//! | UI | decode, clip and voice handle bookkeeping, one queue push per call |
22//! | Audio (real-time) | drain the queue, resample, mix, clamp |
23//!
24//! The audio callback allocates nothing, locks nothing and logs nothing. Clips
25//! reach it as `Arc<[f32]>` inside a command; clips it drops travel back over a
26//! second queue so the deallocation happens on the UI thread.
27//!
28//! # When the device is open
29//!
30//! Only while there is sound to make. The output device opens on the first
31//! [`play`](cranpose_services::AudioPlayer::play), not on
32//! [`install`](install) and not on
33//! [`load_clip`](cranpose_services::AudioPlayer::load_clip) — a clip load is a
34//! queue push, and the queue exists before any mixer does — and the mixer gives
35//! the stream up again once nothing has sounded for a couple of seconds. A
36//! silent screen therefore costs no audio thread and no output route, whether
37//! it is the first screen or one reached after an hour of play.
38//!
39//! # Devices
40//!
41//! * Android and Wear OS: AAudio through the `ndk` crate (`aaudio` feature, on
42//! by default). No Java glue and no C++ toolchain.
43//! * Desktop: `cpal` (`cpal-backend` feature, off by default because it links
44//! a system audio library).
45//! * Anything else: [`AudioError::Unsupported`], and the service falls back to
46//! the no-op player so the app still runs.
47
48mod backend;
49mod engine;
50mod mixer;
51mod ring;
52
53pub use engine::AudioEngine;
54pub use mixer::{MAX_CLIPS, MAX_VOICES};
55
56use cranpose_services::{set_platform_audio, AudioPlayerRef};
57use std::rc::Rc;
58
59/// Creates an engine without registering it, for an app that wants to hold the
60/// handle itself.
61pub fn create() -> Rc<AudioEngine> {
62 Rc::new(AudioEngine::new())
63}
64
65/// Creates an engine and installs it as the platform audio player.
66///
67/// Call once at startup, on the thread that runs the composition. The output
68/// device opens on the first sound, not here and not when clips are loaded, so
69/// installing the engine in an app that never plays anything costs nothing.
70pub fn install() -> AudioPlayerRef {
71 let engine: AudioPlayerRef = create();
72 set_platform_audio(Rc::clone(&engine));
73 engine
74}
75
76/// Whether this build has a real output device compiled in. `false` means
77/// [`install`] registers an engine that will report
78/// [`AudioError::Unsupported`](cranpose_services::AudioError::Unsupported).
79pub fn has_device_backend() -> bool {
80 backend::is_compiled()
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use cranpose_services::{clear_platform_audio, default_audio};
87
88 #[test]
89 fn install_registers_the_engine_as_the_platform_player() {
90 clear_platform_audio();
91 assert!(!default_audio().is_available());
92 install();
93 assert_eq!(default_audio().is_available(), has_device_backend());
94 clear_platform_audio();
95 assert!(!default_audio().is_available());
96 }
97
98 #[test]
99 fn create_does_not_register_anything() {
100 clear_platform_audio();
101 let engine = create();
102 assert!(!engine.is_running());
103 assert!(!default_audio().is_available());
104 }
105}