Initial commit

This commit is contained in:
2026-01-18 15:39:46 +01:00
commit 587f2bd7e7
106 changed files with 14918 additions and 0 deletions

14
doux-sova/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}