Initial commit
This commit is contained in:
14
doux-sova/Cargo.toml
Normal file
14
doux-sova/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "doux-sova"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
doux = { path = "..", features = ["native"] }
|
||||
crossbeam-channel = "0.5"
|
||||
cpal = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dependencies.sova_core]
|
||||
package = "core"
|
||||
git = "https://github.com/sova-org/Sova"
|
||||
143
doux-sova/README.md
Normal file
143
doux-sova/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# doux-sova
|
||||
|
||||
Integration layer connecting Doux audio engine to Sova's AudioEngineProxy.
|
||||
|
||||
## Quick Start with DouxManager
|
||||
|
||||
```rust
|
||||
use doux_sova::{DouxManager, DouxConfig, audio};
|
||||
|
||||
// 1. List available devices (for UI)
|
||||
let devices = audio::list_output_devices();
|
||||
for dev in &devices {
|
||||
let default_marker = if dev.is_default { " [default]" } else { "" };
|
||||
println!("{}: {} (max {} ch){}", dev.index, dev.name, dev.max_channels, default_marker);
|
||||
}
|
||||
|
||||
// 2. Create configuration
|
||||
let config = DouxConfig::default()
|
||||
.with_output_device("Built-in Output")
|
||||
.with_channels(2)
|
||||
.with_buffer_size(512)
|
||||
.with_sample_path("/path/to/samples");
|
||||
|
||||
// 3. Create and start manager
|
||||
let mut manager = DouxManager::new(config)?;
|
||||
let proxy = manager.start(clock.micros())?;
|
||||
|
||||
// 4. Register with Sova
|
||||
device_map.connect_audio_engine("doux", proxy)?;
|
||||
|
||||
// 5. Control during runtime
|
||||
manager.hush(); // Release all voices
|
||||
manager.panic(); // Immediately stop all voices
|
||||
manager.add_sample_path(path); // Add more samples
|
||||
manager.rescan_samples(); // Rescan all sample directories
|
||||
let state = manager.state(); // Get AudioEngineState (voices, cpu, etc)
|
||||
let scope = manager.scope_capture(); // Get oscilloscope buffer
|
||||
|
||||
// 6. Stop or restart
|
||||
manager.stop(); // Stop audio streams
|
||||
let new_config = DouxConfig::default().with_channels(4);
|
||||
let new_proxy = manager.restart(new_config, clock.micros())?;
|
||||
```
|
||||
|
||||
## Low-Level API
|
||||
|
||||
For direct engine access without lifecycle management:
|
||||
|
||||
```rust
|
||||
use std::sync::{Arc, Mutex};
|
||||
use doux::Engine;
|
||||
use doux_sova::create_integration;
|
||||
|
||||
// 1. Create the doux engine
|
||||
let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
|
||||
|
||||
// 2. Get initial sync time from Sova's clock
|
||||
let initial_time = clock.micros();
|
||||
|
||||
// 3. Create integration - returns thread handle and proxy
|
||||
let (handle, proxy) = create_integration(engine.clone(), initial_time);
|
||||
|
||||
// 4. Register proxy with Sova's device map
|
||||
device_map.connect_audio_engine("doux", proxy)?;
|
||||
|
||||
// 5. Start your audio output loop using the engine
|
||||
// (see doux/src/main.rs for cpal example)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Sova Scheduler
|
||||
│
|
||||
▼
|
||||
AudioEngineProxy::send(AudioEnginePayload)
|
||||
│
|
||||
▼ (crossbeam channel)
|
||||
│
|
||||
SovaReceiver thread
|
||||
│ converts HashMap → command string
|
||||
▼
|
||||
Engine::evaluate("/s/sine/freq/440/gain/0.5/...")
|
||||
```
|
||||
|
||||
## Time Synchronization
|
||||
|
||||
Pass `clock.micros()` at engine startup. Sova's timetags (microseconds) are converted to engine time (seconds) relative to this initial value. Events with timetags go to Doux's scheduler for sample-accurate playback.
|
||||
|
||||
## DouxConfig Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `with_output_device(name)` | Set output device by name |
|
||||
| `with_input_device(name)` | Set input device by name |
|
||||
| `with_channels(n)` | Set number of output channels |
|
||||
| `with_buffer_size(n)` | Set audio buffer size in frames |
|
||||
| `with_sample_path(path)` | Add a single sample directory |
|
||||
| `with_sample_paths(paths)` | Add multiple sample directories |
|
||||
|
||||
## DouxManager Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `start(time)` | Start audio streams, returns proxy |
|
||||
| `stop()` | Stop audio streams |
|
||||
| `restart(config, time)` | Restart with new configuration |
|
||||
| `hush()` | Release all voices gracefully |
|
||||
| `panic()` | Immediately stop all voices |
|
||||
| `add_sample_path(path)` | Add sample directory |
|
||||
| `rescan_samples()` | Rescan all sample directories |
|
||||
| `clear_samples()` | Clear sample pool |
|
||||
| `state()` | Returns `AudioEngineState` |
|
||||
| `is_running()` | Check if streams are active |
|
||||
| `sample_rate()` | Get current sample rate |
|
||||
| `channels()` | Get number of channels |
|
||||
| `config()` | Get current configuration |
|
||||
| `engine_handle()` | Access engine for telemetry |
|
||||
| `scope_capture()` | Get oscilloscope buffer |
|
||||
|
||||
## Re-exported Types
|
||||
|
||||
### AudioEngineState
|
||||
|
||||
```rust
|
||||
pub struct AudioEngineState {
|
||||
pub active_voices: u32,
|
||||
pub cpu_percent: f32,
|
||||
pub output_level: f32,
|
||||
}
|
||||
```
|
||||
|
||||
### DouxError
|
||||
|
||||
Error type for manager operations (device not found, stream errors, etc).
|
||||
|
||||
### ScopeCapture
|
||||
|
||||
Buffer containing oscilloscope data from `scope_capture()`.
|
||||
|
||||
## Parameters
|
||||
|
||||
All Sova Dirt parameters map directly to Doux event fields via `Event::parse()`. No manual mapping required - add new parameters to Doux and they work automatically.
|
||||
55
doux-sova/src/convert.rs
Normal file
55
doux-sova/src/convert.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Payload conversion from Sova to Doux command format.
|
||||
//!
|
||||
//! Converts Sova's `AudioEnginePayload` (HashMap of parameters) into
|
||||
//! Doux's slash-separated command strings (e.g., `/sound/sine/freq/440`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sova_core::clock::SyncTime;
|
||||
use sova_core::vm::variable::VariableValue;
|
||||
|
||||
use crate::time::TimeConverter;
|
||||
|
||||
/// Converts a Sova payload to a Doux command string.
|
||||
///
|
||||
/// The resulting string has the format `/key/value/key/value/...`.
|
||||
/// If a timetag is present, it's converted to engine time and prepended.
|
||||
pub fn payload_to_command(
|
||||
args: &HashMap<String, VariableValue>,
|
||||
timetag: Option<SyncTime>,
|
||||
time_converter: &TimeConverter,
|
||||
) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(tt) = timetag {
|
||||
let engine_time = time_converter.sync_to_engine_time(tt);
|
||||
parts.push("time".to_string());
|
||||
parts.push(engine_time.to_string());
|
||||
}
|
||||
|
||||
for (key, value) in args {
|
||||
parts.push(key.clone());
|
||||
parts.push(value_to_string(value));
|
||||
}
|
||||
|
||||
format!("/{}", parts.join("/"))
|
||||
}
|
||||
|
||||
/// Converts a Sova variable value to a string for Doux.
|
||||
fn value_to_string(v: &VariableValue) -> String {
|
||||
match v {
|
||||
VariableValue::Integer(i) => i.to_string(),
|
||||
VariableValue::Float(f) => f.to_string(),
|
||||
VariableValue::Decimal(sign, num, den) => {
|
||||
let f = (*num as f64) / (*den as f64);
|
||||
if *sign < 0 {
|
||||
format!("-{f}")
|
||||
} else {
|
||||
f.to_string()
|
||||
}
|
||||
}
|
||||
VariableValue::Str(s) => s.clone(),
|
||||
VariableValue::Bool(b) => if *b { "1" } else { "0" }.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
38
doux-sova/src/lib.rs
Normal file
38
doux-sova/src/lib.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod convert;
|
||||
pub mod manager;
|
||||
mod receiver;
|
||||
pub mod scope;
|
||||
mod time;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use crossbeam_channel::unbounded;
|
||||
use doux::Engine;
|
||||
use sova_core::clock::SyncTime;
|
||||
use sova_core::protocol::audio_engine_proxy::AudioEngineProxy;
|
||||
|
||||
use receiver::SovaReceiver;
|
||||
use time::TimeConverter;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use doux::audio;
|
||||
pub use doux::config::DouxConfig;
|
||||
pub use doux::error::DouxError;
|
||||
pub use manager::{AudioEngineState, DouxManager};
|
||||
pub use scope::ScopeCapture;
|
||||
|
||||
/// Creates a Sova integration for an existing engine.
|
||||
///
|
||||
/// This is the low-level API. For most use cases, prefer `DouxManager`
|
||||
/// which handles the full engine lifecycle.
|
||||
pub fn create_integration(
|
||||
engine: Arc<Mutex<Engine>>,
|
||||
initial_sync_time: SyncTime,
|
||||
) -> (JoinHandle<()>, AudioEngineProxy) {
|
||||
let (tx, rx) = unbounded();
|
||||
let time_converter = TimeConverter::new(initial_sync_time);
|
||||
let receiver = SovaReceiver::new(engine, rx, time_converter);
|
||||
let handle = thread::spawn(move || receiver.run());
|
||||
(handle, AudioEngineProxy::new(tx))
|
||||
}
|
||||
447
doux-sova/src/manager.rs
Normal file
447
doux-sova/src/manager.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
//! DouxManager - Lifecycle management for the Doux audio engine with Sova integration.
|
||||
//!
|
||||
//! Provides a high-level API for managing the complete audio engine lifecycle,
|
||||
//! including device selection, stream creation, and Sova scheduler integration.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use cpal::{Device, Stream, SupportedStreamConfig};
|
||||
use crossbeam_channel::Sender;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use doux::audio::{
|
||||
default_input_device, default_output_device, find_input_device, find_output_device,
|
||||
max_output_channels,
|
||||
};
|
||||
use doux::config::DouxConfig;
|
||||
use doux::error::DouxError;
|
||||
use doux::Engine;
|
||||
|
||||
use sova_core::clock::SyncTime;
|
||||
use sova_core::protocol::audio_engine_proxy::{AudioEnginePayload, AudioEngineProxy};
|
||||
|
||||
use crate::receiver::SovaReceiver;
|
||||
use crate::scope::ScopeCapture;
|
||||
use crate::time::TimeConverter;
|
||||
|
||||
/// Snapshot of the audio engine state for external visibility.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioEngineState {
|
||||
/// Whether audio streams are currently running.
|
||||
pub running: bool,
|
||||
/// Name of the output device, or None if using system default.
|
||||
pub device: Option<String>,
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: f32,
|
||||
/// Number of output channels.
|
||||
pub channels: usize,
|
||||
/// Requested buffer size in samples, if explicitly set.
|
||||
pub buffer_size: Option<u32>,
|
||||
/// Number of currently playing voices.
|
||||
pub active_voices: usize,
|
||||
/// Configured sample directory paths.
|
||||
pub sample_paths: Vec<PathBuf>,
|
||||
/// Last error message, if any.
|
||||
pub error: Option<String>,
|
||||
/// CPU load as a fraction (0.0 to 1.0+).
|
||||
pub cpu_load: f32,
|
||||
/// Peak number of voices seen since reset.
|
||||
pub peak_voices: usize,
|
||||
/// Maximum allowed voices.
|
||||
pub max_voices: usize,
|
||||
/// Number of events in the schedule queue.
|
||||
pub schedule_depth: usize,
|
||||
/// Total memory used by sample pool in megabytes.
|
||||
pub sample_pool_mb: f32,
|
||||
}
|
||||
|
||||
impl Default for AudioEngineState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
device: None,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
buffer_size: None,
|
||||
active_voices: 0,
|
||||
sample_paths: Vec::new(),
|
||||
error: None,
|
||||
cpu_load: 0.0,
|
||||
peak_voices: 0,
|
||||
max_voices: doux::types::MAX_VOICES,
|
||||
schedule_depth: 0,
|
||||
sample_pool_mb: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the Doux audio engine lifecycle with Sova integration.
|
||||
///
|
||||
/// Handles creating, starting, stopping, and restarting the audio engine
|
||||
/// with different configurations.
|
||||
pub struct DouxManager {
|
||||
/// The audio synthesis engine, shared with the audio callback thread.
|
||||
engine: Arc<Mutex<Engine>>,
|
||||
/// Current configuration (device, channels, sample paths).
|
||||
config: DouxConfig,
|
||||
/// Actual sample rate from the audio device.
|
||||
sample_rate: f32,
|
||||
/// Actual channel count (may be clamped to device maximum).
|
||||
actual_channels: usize,
|
||||
/// Handle to the CPAL output stream, None when stopped.
|
||||
output_stream: Option<Stream>,
|
||||
/// Handle to the CPAL input stream, None when stopped or no input.
|
||||
input_stream: Option<Stream>,
|
||||
/// Handle to the Sova receiver thread.
|
||||
receiver_handle: Option<JoinHandle<()>>,
|
||||
/// Sender end of the channel to the receiver, dropped to signal shutdown.
|
||||
proxy_sender: Option<Sender<AudioEnginePayload>>,
|
||||
/// Scope capture for oscilloscope display.
|
||||
scope: Option<Arc<ScopeCapture>>,
|
||||
}
|
||||
|
||||
/// Resolves the output device from config, returning an error if not found.
|
||||
fn resolve_output_device(config: &DouxConfig) -> Result<Device, DouxError> {
|
||||
match &config.output_device {
|
||||
Some(spec) => {
|
||||
find_output_device(spec).ok_or_else(|| DouxError::DeviceNotFound(spec.clone()))
|
||||
}
|
||||
None => default_output_device().ok_or(DouxError::NoDefaultDevice),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the device configuration and extracts the sample rate.
|
||||
fn get_device_config(device: &Device) -> Result<(SupportedStreamConfig, f32), DouxError> {
|
||||
let config = device
|
||||
.default_output_config()
|
||||
.map_err(|e| DouxError::DeviceConfigError(e.to_string()))?;
|
||||
let sample_rate = config.sample_rate().0 as f32;
|
||||
Ok((config, sample_rate))
|
||||
}
|
||||
|
||||
/// Computes the actual channel count, clamped to the device maximum.
|
||||
fn compute_channels(device: &Device, requested: u16) -> usize {
|
||||
let max_ch = max_output_channels(device);
|
||||
(requested as usize).min(max_ch as usize)
|
||||
}
|
||||
|
||||
impl DouxManager {
|
||||
/// Creates a new DouxManager with the given configuration.
|
||||
///
|
||||
/// This resolves the audio device and creates the engine, but does not
|
||||
/// start the audio streams. Call `start()` to begin audio processing.
|
||||
pub fn new(config: DouxConfig) -> Result<Self, DouxError> {
|
||||
let output_device = resolve_output_device(&config)?;
|
||||
let (_, sample_rate) = get_device_config(&output_device)?;
|
||||
let actual_channels = compute_channels(&output_device, config.channels);
|
||||
|
||||
// Create engine
|
||||
let mut engine = Engine::new_with_channels(sample_rate, actual_channels);
|
||||
|
||||
// Load sample directories
|
||||
for path in &config.sample_paths {
|
||||
let index = doux::loader::scan_samples_dir(path);
|
||||
engine.sample_index.extend(index);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
engine: Arc::new(Mutex::new(engine)),
|
||||
config,
|
||||
sample_rate,
|
||||
actual_channels,
|
||||
output_stream: None,
|
||||
input_stream: None,
|
||||
receiver_handle: None,
|
||||
proxy_sender: None,
|
||||
scope: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts the audio streams and returns an AudioEngineProxy for Sova.
|
||||
///
|
||||
/// The proxy can be registered with Sova's device map to receive events.
|
||||
pub fn start(&mut self, initial_sync_time: SyncTime) -> Result<AudioEngineProxy, DouxError> {
|
||||
let output_device = resolve_output_device(&self.config)?;
|
||||
let (device_config, _) = get_device_config(&output_device)?;
|
||||
|
||||
let stream_config = cpal::StreamConfig {
|
||||
channels: self.actual_channels as u16,
|
||||
sample_rate: device_config.sample_rate(),
|
||||
buffer_size: self
|
||||
.config
|
||||
.buffer_size
|
||||
.map(cpal::BufferSize::Fixed)
|
||||
.unwrap_or(cpal::BufferSize::Default),
|
||||
};
|
||||
|
||||
// Ring buffer for live audio input
|
||||
let input_buffer: Arc<Mutex<VecDeque<f32>>> =
|
||||
Arc::new(Mutex::new(VecDeque::with_capacity(8192)));
|
||||
|
||||
// Set up input stream if configured
|
||||
let input_device = match &self.config.input_device {
|
||||
Some(spec) => find_input_device(spec),
|
||||
None => default_input_device(),
|
||||
};
|
||||
|
||||
self.input_stream = input_device.and_then(|input_dev| {
|
||||
let input_config = input_dev.default_input_config().ok()?;
|
||||
let buf = Arc::clone(&input_buffer);
|
||||
let stream = input_dev
|
||||
.build_input_stream(
|
||||
&input_config.into(),
|
||||
move |data: &[f32], _| {
|
||||
let mut b = buf.lock().unwrap();
|
||||
for &sample in data {
|
||||
b.push_back(sample);
|
||||
if b.len() > 8192 {
|
||||
b.pop_front();
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| eprintln!("input stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
stream.play().ok()?;
|
||||
Some(stream)
|
||||
});
|
||||
|
||||
// Create scope capture for oscilloscope
|
||||
let scope = Arc::new(ScopeCapture::new());
|
||||
let scope_clone = Arc::clone(&scope);
|
||||
|
||||
// Build output stream
|
||||
let engine_clone = Arc::clone(&self.engine);
|
||||
let input_buf_clone = Arc::clone(&input_buffer);
|
||||
let live_scratch: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(vec![0.0; 1024]));
|
||||
let live_scratch_clone = Arc::clone(&live_scratch);
|
||||
let sample_rate = self.sample_rate;
|
||||
let output_channels = self.actual_channels;
|
||||
|
||||
let output_stream = output_device
|
||||
.build_output_stream(
|
||||
&stream_config,
|
||||
move |data: &mut [f32], _| {
|
||||
let mut buf = input_buf_clone.lock().unwrap();
|
||||
let mut scratch = live_scratch_clone.lock().unwrap();
|
||||
if scratch.len() < data.len() {
|
||||
scratch.resize(data.len(), 0.0);
|
||||
}
|
||||
for sample in scratch[..data.len()].iter_mut() {
|
||||
*sample = buf.pop_front().unwrap_or(0.0);
|
||||
}
|
||||
drop(buf);
|
||||
let mut engine = engine_clone.lock().unwrap();
|
||||
// Set buffer time budget for CPU load measurement
|
||||
let buffer_samples = data.len() / output_channels;
|
||||
let buffer_time_ns = (buffer_samples as f64 / sample_rate as f64 * 1e9) as u64;
|
||||
engine.metrics.load.set_buffer_time(buffer_time_ns);
|
||||
engine.process_block(data, &[], &scratch[..data.len()]);
|
||||
// Capture samples for oscilloscope (zero-allocation path)
|
||||
for chunk in data.chunks(output_channels) {
|
||||
if output_channels >= 2 {
|
||||
scope_clone.push_stereo(chunk[0], chunk[1]);
|
||||
} else {
|
||||
scope_clone.push_mono(chunk[0]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| eprintln!("output stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| DouxError::StreamCreationFailed(e.to_string()))?;
|
||||
|
||||
output_stream
|
||||
.play()
|
||||
.map_err(|e| DouxError::StreamCreationFailed(e.to_string()))?;
|
||||
|
||||
self.output_stream = Some(output_stream);
|
||||
self.scope = Some(scope);
|
||||
|
||||
// Create Sova integration
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let time_converter = TimeConverter::new(initial_sync_time);
|
||||
let receiver = SovaReceiver::new(Arc::clone(&self.engine), rx, time_converter);
|
||||
let handle = std::thread::spawn(move || receiver.run());
|
||||
|
||||
self.receiver_handle = Some(handle);
|
||||
self.proxy_sender = Some(tx.clone());
|
||||
|
||||
Ok(AudioEngineProxy::new(tx))
|
||||
}
|
||||
|
||||
/// Stops all audio streams and the Sova receiver.
|
||||
pub fn stop(&mut self) {
|
||||
// Drop streams to stop audio
|
||||
self.output_stream = None;
|
||||
self.input_stream = None;
|
||||
self.scope = None;
|
||||
|
||||
// Drop sender to signal receiver to stop
|
||||
self.proxy_sender = None;
|
||||
|
||||
// Wait for receiver thread to finish (it will exit when channel closes)
|
||||
if let Some(handle) = self.receiver_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Restarts the engine with a new configuration.
|
||||
///
|
||||
/// Stops the current engine, creates a new one with the new config,
|
||||
/// and returns a new AudioEngineProxy.
|
||||
pub fn restart(
|
||||
&mut self,
|
||||
config: DouxConfig,
|
||||
initial_sync_time: SyncTime,
|
||||
) -> Result<AudioEngineProxy, DouxError> {
|
||||
self.stop();
|
||||
|
||||
let output_device = resolve_output_device(&config)?;
|
||||
let (_, sample_rate) = get_device_config(&output_device)?;
|
||||
let actual_channels = compute_channels(&output_device, config.channels);
|
||||
|
||||
// Create new engine
|
||||
let mut engine = Engine::new_with_channels(sample_rate, actual_channels);
|
||||
|
||||
for path in &config.sample_paths {
|
||||
let index = doux::loader::scan_samples_dir(path);
|
||||
engine.sample_index.extend(index);
|
||||
}
|
||||
|
||||
self.engine = Arc::new(Mutex::new(engine));
|
||||
self.config = config;
|
||||
self.sample_rate = sample_rate;
|
||||
self.actual_channels = actual_channels;
|
||||
|
||||
self.start(initial_sync_time)
|
||||
}
|
||||
|
||||
/// Returns the actual sample rate being used.
|
||||
pub fn sample_rate(&self) -> f32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Returns the actual number of output channels.
|
||||
pub fn channels(&self) -> usize {
|
||||
self.actual_channels
|
||||
}
|
||||
|
||||
/// Returns the current configuration.
|
||||
pub fn config(&self) -> &DouxConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Returns whether audio streams are running.
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.output_stream.is_some()
|
||||
}
|
||||
|
||||
/// Returns a snapshot of the current audio engine state.
|
||||
pub fn state(&self) -> AudioEngineState {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (active_voices, cpu_load, peak_voices, schedule_depth, sample_pool_mb) = self
|
||||
.engine
|
||||
.lock()
|
||||
.map(|e| {
|
||||
(
|
||||
e.active_voices,
|
||||
e.metrics.load.get_load(),
|
||||
e.metrics.peak_voices.load(Ordering::Relaxed) as usize,
|
||||
e.metrics.schedule_depth.load(Ordering::Relaxed) as usize,
|
||||
e.metrics.sample_pool_mb(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0.0, 0, 0, 0.0));
|
||||
|
||||
AudioEngineState {
|
||||
running: self.is_running(),
|
||||
device: self
|
||||
.config
|
||||
.output_device
|
||||
.clone()
|
||||
.or_else(|| Some("System Default".to_string())),
|
||||
sample_rate: self.sample_rate,
|
||||
channels: self.actual_channels,
|
||||
buffer_size: self.config.buffer_size,
|
||||
active_voices,
|
||||
sample_paths: self.config.sample_paths.clone(),
|
||||
error: None,
|
||||
cpu_load,
|
||||
peak_voices,
|
||||
max_voices: doux::types::MAX_VOICES,
|
||||
schedule_depth,
|
||||
sample_pool_mb,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a sample directory and scans it.
|
||||
pub fn add_sample_path(&mut self, path: std::path::PathBuf) {
|
||||
let index = doux::loader::scan_samples_dir(&path);
|
||||
if let Ok(mut engine) = self.engine.lock() {
|
||||
engine.sample_index.extend(index);
|
||||
}
|
||||
self.config.sample_paths.push(path);
|
||||
}
|
||||
|
||||
/// Rescans all configured sample directories.
|
||||
pub fn rescan_samples(&mut self) {
|
||||
if let Ok(mut engine) = self.engine.lock() {
|
||||
engine.sample_index.clear();
|
||||
for path in &self.config.sample_paths {
|
||||
let index = doux::loader::scan_samples_dir(path);
|
||||
engine.sample_index.extend(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all loaded samples.
|
||||
pub fn clear_samples(&mut self) {
|
||||
if let Ok(mut engine) = self.engine.lock() {
|
||||
engine.sample_index.clear();
|
||||
engine.samples.clear();
|
||||
engine.sample_pool = doux::sample::SamplePool::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a hush command to release all voices.
|
||||
pub fn hush(&self) {
|
||||
if let Ok(mut engine) = self.engine.lock() {
|
||||
engine.hush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a panic command to immediately stop all voices.
|
||||
pub fn panic(&self) {
|
||||
if let Ok(mut engine) = self.engine.lock() {
|
||||
engine.panic();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a handle to the engine for telemetry access.
|
||||
///
|
||||
/// This allows external code to read engine metrics without holding
|
||||
/// the entire DouxManager (which is not Send due to cpal::Stream).
|
||||
pub fn engine_handle(&self) -> Arc<Mutex<Engine>> {
|
||||
Arc::clone(&self.engine)
|
||||
}
|
||||
|
||||
/// Returns the scope capture for oscilloscope display.
|
||||
///
|
||||
/// Returns None if the audio engine is not running.
|
||||
pub fn scope_capture(&self) -> Option<Arc<ScopeCapture>> {
|
||||
self.scope.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DouxManager {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
54
doux-sova/src/receiver.rs
Normal file
54
doux-sova/src/receiver.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Sova event receiver thread.
|
||||
//!
|
||||
//! Listens for events from Sova's scheduler via a crossbeam channel and
|
||||
//! forwards them to the Doux engine as command strings.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossbeam_channel::Receiver;
|
||||
use doux::Engine;
|
||||
use sova_core::protocol::audio_engine_proxy::AudioEnginePayload;
|
||||
|
||||
use crate::convert::payload_to_command;
|
||||
use crate::time::TimeConverter;
|
||||
|
||||
/// Receives events from Sova and forwards them to the Doux engine.
|
||||
///
|
||||
/// Runs in a dedicated thread, blocking on channel receive. Exits when
|
||||
/// the sender is dropped (channel closed).
|
||||
pub struct SovaReceiver {
|
||||
/// Shared reference to the audio engine.
|
||||
engine: Arc<Mutex<Engine>>,
|
||||
/// Channel receiving events from Sova's scheduler.
|
||||
rx: Receiver<AudioEnginePayload>,
|
||||
/// Converts Sova timestamps to engine time.
|
||||
time_converter: TimeConverter,
|
||||
}
|
||||
|
||||
impl SovaReceiver {
|
||||
/// Creates a new receiver with the given engine, channel, and time converter.
|
||||
pub fn new(
|
||||
engine: Arc<Mutex<Engine>>,
|
||||
rx: Receiver<AudioEnginePayload>,
|
||||
time_converter: TimeConverter,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
rx,
|
||||
time_converter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the receiver loop until the channel is closed.
|
||||
///
|
||||
/// Each received payload is converted to a Doux command string
|
||||
/// and evaluated by the engine.
|
||||
pub fn run(self) {
|
||||
while let Ok(payload) = self.rx.recv() {
|
||||
let cmd = payload_to_command(&payload.args, payload.timetag, &self.time_converter);
|
||||
if let Ok(mut engine) = self.engine.lock() {
|
||||
engine.evaluate(&cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
doux-sova/src/scope.rs
Normal file
107
doux-sova/src/scope.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Lock-free oscilloscope capture for the audio engine.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const BUFFER_SIZE: usize = 1600;
|
||||
|
||||
/// Lock-free triple-buffer for audio oscilloscope capture.
|
||||
///
|
||||
/// Uses three buffers: one being written by the audio thread, one ready for
|
||||
/// reading, and one in transition. This allows lock-free concurrent access
|
||||
/// from the audio callback (writer) and UI thread (reader).
|
||||
pub struct ScopeCapture {
|
||||
buffers: [Box<[f32; BUFFER_SIZE]>; 3],
|
||||
write_idx: AtomicUsize,
|
||||
write_buffer: AtomicUsize,
|
||||
read_buffer: AtomicUsize,
|
||||
}
|
||||
|
||||
// SAFETY: All mutable access is through atomic operations or single-writer guarantee.
|
||||
// The write methods are only called from one audio callback thread at a time.
|
||||
unsafe impl Send for ScopeCapture {}
|
||||
// SAFETY: Concurrent read/write is safe due to triple-buffering design.
|
||||
// Writer and reader operate on different buffers, synchronized via atomics.
|
||||
unsafe impl Sync for ScopeCapture {}
|
||||
|
||||
impl ScopeCapture {
|
||||
/// Creates a new scope capture with zeroed buffers.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffers: [
|
||||
Box::new([0.0; BUFFER_SIZE]),
|
||||
Box::new([0.0; BUFFER_SIZE]),
|
||||
Box::new([0.0; BUFFER_SIZE]),
|
||||
],
|
||||
write_idx: AtomicUsize::new(0),
|
||||
write_buffer: AtomicUsize::new(0),
|
||||
read_buffer: AtomicUsize::new(2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a stereo sample pair, converting to mono for display.
|
||||
#[inline]
|
||||
pub fn push_stereo(&self, left: f32, right: f32) {
|
||||
let mono = (left + right) * 0.5;
|
||||
self.push_mono(mono);
|
||||
}
|
||||
|
||||
/// Pushes a mono sample to the write buffer.
|
||||
#[inline]
|
||||
pub fn push_mono(&self, sample: f32) {
|
||||
let buf_idx = self.write_buffer.load(Ordering::Relaxed);
|
||||
let write_pos = self.write_idx.load(Ordering::Relaxed);
|
||||
|
||||
let buf_ptr = self.buffers[buf_idx].as_ptr() as *mut f32;
|
||||
// SAFETY: write_pos is always < BUFFER_SIZE, and only one writer exists.
|
||||
unsafe {
|
||||
*buf_ptr.add(write_pos) = sample;
|
||||
}
|
||||
|
||||
let next_pos = write_pos + 1;
|
||||
if next_pos >= BUFFER_SIZE {
|
||||
let next_buf = (buf_idx + 1) % 3;
|
||||
self.read_buffer.store(buf_idx, Ordering::Release);
|
||||
self.write_buffer.store(next_buf, Ordering::Relaxed);
|
||||
self.write_idx.store(0, Ordering::Relaxed);
|
||||
} else {
|
||||
self.write_idx.store(next_pos, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns peak (min, max) pairs for waveform display.
|
||||
pub fn read_peaks(&self, num_peaks: usize) -> Vec<(f32, f32)> {
|
||||
if num_peaks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let buf_idx = self.read_buffer.load(Ordering::Acquire);
|
||||
let buf = &self.buffers[buf_idx];
|
||||
|
||||
let window = (BUFFER_SIZE / num_peaks).max(1);
|
||||
buf.chunks(window)
|
||||
.take(num_peaks)
|
||||
.map(|chunk| {
|
||||
chunk
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(min, max), &s| (min.min(s), max.max(s)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns a copy of the current read buffer samples.
|
||||
pub fn read_samples(&self) -> Vec<f32> {
|
||||
let buf_idx = self.read_buffer.load(Ordering::Acquire);
|
||||
self.buffers[buf_idx].to_vec()
|
||||
}
|
||||
|
||||
/// Returns the buffer size in samples.
|
||||
pub const fn buffer_size() -> usize {
|
||||
BUFFER_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScopeCapture {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
34
doux-sova/src/time.rs
Normal file
34
doux-sova/src/time.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Time synchronization between Sova and Doux.
|
||||
//!
|
||||
//! Sova uses microsecond timestamps (SyncTime) from its internal clock.
|
||||
//! Doux uses seconds relative to engine start. This module converts between them.
|
||||
|
||||
use sova_core::clock::SyncTime;
|
||||
|
||||
/// Converts Sova timestamps to Doux engine time.
|
||||
///
|
||||
/// Stores the initial sync time (engine start) and computes relative
|
||||
/// offsets in seconds for incoming events.
|
||||
pub struct TimeConverter {
|
||||
/// Microsecond timestamp when the engine was started.
|
||||
engine_start_micros: SyncTime,
|
||||
}
|
||||
|
||||
impl TimeConverter {
|
||||
/// Creates a converter with the given initial sync time.
|
||||
///
|
||||
/// Pass `clock.micros()` at engine startup.
|
||||
pub fn new(initial_sync_time: SyncTime) -> Self {
|
||||
Self {
|
||||
engine_start_micros: initial_sync_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a Sova timetag to engine time in seconds.
|
||||
///
|
||||
/// Returns the number of seconds since engine start.
|
||||
pub fn sync_to_engine_time(&self, timetag: SyncTime) -> f64 {
|
||||
let delta = timetag.saturating_sub(self.engine_start_micros);
|
||||
(delta as f64) / 1_000_000.0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user