refactor: clean separation between lib and CLI
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "raust-engine"
|
||||
description = "Raust Synthesis Engine"
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cpal = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["pipewire"]
|
||||
|
||||
pipewire = ["cpal/pipewire"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,120 @@
|
||||
/// Plays audio produced by a generator closure on the host's default output device.
|
||||
///
|
||||
/// `AudioPlayer` owns the live [`cpal::Stream`] and exposes only playback
|
||||
/// control ([`play`](Self::play), [`pause`](Self::pause)). It has no
|
||||
/// knowledge of how the generator produces samples, so polyphony, pitch
|
||||
/// modulation, and voice mixing all belong behind the [`Generator`] closure,
|
||||
/// not here.
|
||||
pub struct AudioPlayer {
|
||||
stream: Stream,
|
||||
}
|
||||
use super::Generator;
|
||||
use cpal::{
|
||||
Device, Error, ErrorKind, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample, Stream, StreamConfig, SupportedStreamConfig, traits::{DeviceTrait, StreamTrait},
|
||||
};
|
||||
use std::io::ErrorKind as IoErrorKind;
|
||||
|
||||
impl AudioPlayer {
|
||||
/// Builds a stream for `device`/`config` around `generator`, dispatching
|
||||
/// on `config`'s sample format to the matching CPAL sample type.
|
||||
///
|
||||
/// The stream starts paused; call [`play`](Self::play) to produce sound.
|
||||
/// Use [`get_device_and_config`](crate::get_device_and_config) to obtain
|
||||
/// `device` and `config`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - the device reports a sample format `raust` does not support, or
|
||||
/// - CPAL fails to build the output stream.
|
||||
pub fn try_new(device: &Device, config: SupportedStreamConfig, generator: Generator) -> anyhow::Result<Self> {
|
||||
let stream = match config.sample_format() {
|
||||
SampleFormat::I8 => Self::create_stream::<i8>(device, config.into(), generator),
|
||||
SampleFormat::I16 => Self::create_stream::<i16>(device, config.into(), generator),
|
||||
SampleFormat::I24 => Self::create_stream::<I24>(device, config.into(), generator),
|
||||
SampleFormat::I32 => Self::create_stream::<i32>(device, config.into(), generator),
|
||||
SampleFormat::I64 => Self::create_stream::<i64>(device, config.into(), generator),
|
||||
SampleFormat::U8 => Self::create_stream::<u8>(device, config.into(), generator),
|
||||
SampleFormat::U16 => Self::create_stream::<u16>(device, config.into(), generator),
|
||||
SampleFormat::U32 => Self::create_stream::<u32>(device, config.into(), generator),
|
||||
SampleFormat::U64 => Self::create_stream::<u64>(device, config.into(), generator),
|
||||
SampleFormat::F32 => Self::create_stream::<f32>(device, config.into(), generator),
|
||||
SampleFormat::F64 => Self::create_stream::<f64>(device, config.into(), generator),
|
||||
sample_format => Err(anyhow::Error::new(std::io::Error::new(
|
||||
IoErrorKind::InvalidData,
|
||||
format!("Unsupported sample format '{sample_format}'"),
|
||||
))),
|
||||
}?;
|
||||
Ok(Self { stream })
|
||||
}
|
||||
|
||||
/// Builds, but does not start, an output stream of sample type `T` that
|
||||
/// pulls its samples from `generator`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if CPAL fails to build the output stream for `device`.
|
||||
pub fn create_stream<T>(
|
||||
device: &Device,
|
||||
config: StreamConfig,
|
||||
mut generator: Generator,
|
||||
) -> Result<Stream, anyhow::Error>
|
||||
where
|
||||
T: SizedSample + FromSample<f32>,
|
||||
{
|
||||
let channels = usize::from(config.channels);
|
||||
let err_fn = |err: Error| match err.kind() {
|
||||
ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => {
|
||||
eprintln!("{err}");
|
||||
}
|
||||
_ => eprintln!("Stream error: {err}"),
|
||||
};
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [T], _: &OutputCallbackInfo| {
|
||||
Self::write_data(data, channels, &mut generator);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn write_data<T>(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32)
|
||||
where
|
||||
T: Sample + FromSample<f32>,
|
||||
{
|
||||
for frame in output.chunks_mut(channels) {
|
||||
let value: T = T::from_sample(next_sample());
|
||||
for sample in frame.iter_mut() {
|
||||
*sample = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes playback.
|
||||
///
|
||||
/// The generator continues from exactly where [`pause`](Self::pause)
|
||||
/// left it; nothing resets.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if CPAL fails to resume the stream.
|
||||
pub fn play(&self) -> anyhow::Result<()> {
|
||||
self.stream.play()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Suspends playback.
|
||||
///
|
||||
/// The backend stops calling the generator until the next
|
||||
/// [`play`](Self::play) call, so no samples are produced while paused.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if CPAL fails to suspend the stream.
|
||||
pub fn pause(&self) -> anyhow::Result<()> {
|
||||
self.stream.pause()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Output device and stream configuration discovery.
|
||||
|
||||
use cpal::{Device, Error, ErrorKind, HostId, SupportedStreamConfig, traits::{DeviceTrait, HostTrait}};
|
||||
use std::io::{Error as IoError, ErrorKind as IoErrorKind};
|
||||
|
||||
/// Finds an output device and reads its default stream configuration.
|
||||
///
|
||||
/// Prefers `PipeWire` on Linux when the `pipewire` feature is enabled,
|
||||
/// falling back to the platform's default host otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if no default output device exists, or if the device's
|
||||
/// default output configuration cannot be read.
|
||||
pub fn get_device_and_config() -> anyhow::Result<(Device, SupportedStreamConfig)> {
|
||||
#[allow(unused_mut, unused_assignments)]
|
||||
let mut pipewire_host_id: Result<HostId, Error> = Err(ErrorKind::HostUnavailable.into());
|
||||
#[cfg(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
))]
|
||||
{
|
||||
#[cfg(feature = "pipewire")]
|
||||
{
|
||||
pipewire_host_id = Ok(HostId::PipeWire);
|
||||
}
|
||||
}
|
||||
let host = pipewire_host_id
|
||||
.and_then(cpal::host_from_id)
|
||||
.unwrap_or_else(|_| cpal::default_host());
|
||||
let device = host.default_output_device().map_or_else(
|
||||
|| {
|
||||
Err(anyhow::Error::new(IoError::new(
|
||||
IoErrorKind::NotFound,
|
||||
"Could not find a default output device",
|
||||
)))
|
||||
},
|
||||
Ok,
|
||||
)?;
|
||||
let config = device.default_output_config()?;
|
||||
Ok((device, config))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Real-time audio synthesis engine built on CPAL: output device discovery
|
||||
//! ([`get_device_and_config`]) and stream playback ([`AudioPlayer`]).
|
||||
|
||||
mod audio_player;
|
||||
pub use audio_player::AudioPlayer;
|
||||
mod init;
|
||||
pub use init::*;
|
||||
|
||||
/// Produces one `f32` sample per call.
|
||||
///
|
||||
/// `AudioPlayer` consumes a `Generator` when it builds its stream and moves
|
||||
/// it onto the audio callback thread, so `Send + 'static` must hold — the
|
||||
/// same bound [`cpal::Device::build_output_stream`] places on its data
|
||||
/// callback.
|
||||
pub type Generator = Box<dyn FnMut() -> f32 + Send + 'static>;
|
||||
|
||||
/// A single tone: a pitch held for a fixed duration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Note {
|
||||
/// Frequency, in Hertz.
|
||||
pub pitch: f32,
|
||||
/// How long to hold the note, in milliseconds.
|
||||
pub length: u64,
|
||||
}
|
||||
Reference in New Issue
Block a user