refactor: clean separation between lib and CLI
This commit is contained in:
+30
-13
@@ -1,5 +1,12 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "raust"
|
members = ["raust-cli", "raust-engine"]
|
||||||
|
resolver = "3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||||
@@ -7,18 +14,28 @@ license = "GPL-3.0-or-later"
|
|||||||
homepage = "https://labs.phundrak.com/phundrak/raust"
|
homepage = "https://labs.phundrak.com/phundrak/raust"
|
||||||
repository = "https://labs.phundrak.com/phundrak/raust"
|
repository = "https://labs.phundrak.com/phundrak/raust"
|
||||||
|
|
||||||
[lib]
|
[workspace.dependencies]
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
path = "src/main.rs"
|
|
||||||
name = "raust"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
cpal = { version = "0.18.2", features = ["realtime"] }
|
cpal = { version = "0.18.2", features = ["realtime"] }
|
||||||
|
raust-engine = { version = "=0.1.0", path = "raust-engine" }
|
||||||
|
|
||||||
[features]
|
[workspace.lints.rust]
|
||||||
default = []
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
pipewire = ["cpal/pipewire"]
|
[workspace.lints.clippy]
|
||||||
|
pedantic = { level = "deny", priority = -1 }
|
||||||
|
nursery = { level = "deny", priority = -1 }
|
||||||
|
unwrap_used = "deny"
|
||||||
|
expect_used = "deny"
|
||||||
|
indexing_slicing = "deny"
|
||||||
|
arithmetic_side_effects = "deny"
|
||||||
|
unreachable = "deny"
|
||||||
|
unimplemented = "deny"
|
||||||
|
unchecked_time_subtraction = "deny"
|
||||||
|
todo = "deny"
|
||||||
|
string_slice = "deny"
|
||||||
|
panic_in_result_fn = "deny"
|
||||||
|
panic = "deny"
|
||||||
|
exit = "deny"
|
||||||
|
as_conversions = "deny"
|
||||||
|
many_single_char_names = "allow"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "raust-cli"
|
||||||
|
version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
|
cpal = { workspace = true }
|
||||||
|
raust-engine = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use cpal::traits::DeviceTrait;
|
||||||
|
use raust_engine::{AudioPlayer, Note};
|
||||||
|
|
||||||
|
/// Play a single tone through the default audio output device.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Frequency of the tone to play, in Hertz.
|
||||||
|
#[arg(short, long, default_value = "440.0")]
|
||||||
|
frequency: f32,
|
||||||
|
|
||||||
|
/// How long to play the tone, in milliseconds.
|
||||||
|
#[arg(short, long, default_value = "1000")]
|
||||||
|
length_milli: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
let note = Note {
|
||||||
|
pitch: args.frequency,
|
||||||
|
length: args.length_milli,
|
||||||
|
};
|
||||||
|
let (device, config) = raust_engine::get_device_and_config()?;
|
||||||
|
println!("Output device: {}", device.id()?);
|
||||||
|
println!("Default output config: {config:?}");
|
||||||
|
let mut clock = 0f32;
|
||||||
|
// Sample rates are a few hundred kHz at most, nowhere near f32's
|
||||||
|
// 24-bit exact-integer range, so this cast never loses precision.
|
||||||
|
#[allow(clippy::cast_precision_loss, clippy::as_conversions)]
|
||||||
|
let sample_rate = config.sample_rate() as f32;
|
||||||
|
let generator = move || {
|
||||||
|
clock = (clock + 1.0) % sample_rate;
|
||||||
|
(clock * note.pitch * 2.0 * std::f32::consts::PI / sample_rate).sin()
|
||||||
|
};
|
||||||
|
let audio_player = AudioPlayer::try_new(&device, config, Box::new(generator))?;
|
||||||
|
|
||||||
|
audio_player.play()?;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(note.length));
|
||||||
|
audio_player.pause()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "nightly"
|
channel = "stable"
|
||||||
components = ["clippy", "rust-docs", "rust-src", "rust-analyzer", "rustfmt"]
|
components = ["clippy", "rust-docs", "rust-src", "rust-analyzer", "rustfmt"]
|
||||||
profile = "default"
|
profile = "default"
|
||||||
|
|||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
use cpal::{
|
|
||||||
Device, Error, ErrorKind, FromSample, HostId, I24, OutputCallbackInfo, Sample, SampleFormat,
|
|
||||||
SizedSample, StreamConfig,
|
|
||||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn add(a: u32, b: u32) -> u32 {
|
|
||||||
a + b
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn synthesize() -> anyhow::Result<()> {
|
|
||||||
#[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().expect("Failed to find output device");
|
|
||||||
println!("Output device: {}", device.id()?);
|
|
||||||
let config = device.default_output_config()?;
|
|
||||||
println!("Default output config: {config:?}");
|
|
||||||
match config.sample_format() {
|
|
||||||
SampleFormat::I8 => run::<i8>(&device, config.into()),
|
|
||||||
SampleFormat::I16 => run::<i16>(&device, config.into()),
|
|
||||||
SampleFormat::I24 => run::<I24>(&device, config.into()),
|
|
||||||
SampleFormat::I32 => run::<i32>(&device, config.into()),
|
|
||||||
// SampleFormat::I48 => run::<I48>(&device, config.into()),
|
|
||||||
SampleFormat::I64 => run::<i64>(&device, config.into()),
|
|
||||||
SampleFormat::U8 => run::<u8>(&device, config.into()),
|
|
||||||
SampleFormat::U16 => run::<u16>(&device, config.into()),
|
|
||||||
// SampleFormat::U24 => run::<U24>(&device, config.into()),
|
|
||||||
SampleFormat::U32 => run::<u32>(&device, config.into()),
|
|
||||||
// SampleFormat::U48 => run::<U48>(&device, config.into()),
|
|
||||||
SampleFormat::U64 => run::<u64>(&device, config.into()),
|
|
||||||
SampleFormat::F32 => run::<f32>(&device, config.into()),
|
|
||||||
SampleFormat::F64 => run::<f64>(&device, config.into()),
|
|
||||||
sample_format => panic!("Unsupported sample format '{sample_format}'"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run<T>(device: &Device, config: StreamConfig) -> Result<(), anyhow::Error>
|
|
||||||
where
|
|
||||||
T: SizedSample + FromSample<f32>,
|
|
||||||
{
|
|
||||||
let sample_rate = config.sample_rate as f32;
|
|
||||||
let channels = config.channels as usize;
|
|
||||||
|
|
||||||
// Produce a sinusoid of maximum amplitude.
|
|
||||||
let mut sample_clock = 0f32;
|
|
||||||
let mut next_value = move || {
|
|
||||||
sample_clock = (sample_clock + 1.0) % sample_rate;
|
|
||||||
(sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin()
|
|
||||||
};
|
|
||||||
|
|
||||||
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| write_data(data, channels, &mut next_value),
|
|
||||||
err_fn,
|
|
||||||
None,
|
|
||||||
)?;
|
|
||||||
stream.play()?;
|
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
|
||||||
|
|
||||||
stream.pause()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
use raust::synthesize;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
synthesize().expect("Failed to run synthesis engine");
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user