zk-audio 0.1.0

Audio processing library for voice recording and enhancement
Documentation
use crate::contracts::{AudioSink, MetricsCollector, ProcessorBuildRequest, ProcessorFactory};
use crate::core::{AudioProfile, AudioResult, AudioSpec, CaptureDiagnostics, DelayEffectConfig};
use crate::factory::ProfileProcessorFactory;
use crate::metrics::LevelMetrics;
use crate::mic_sim::{MicrophoneSimConfig, MicrophoneSimulatorFactory};
use crate::pipeline::{AudioPipeline, WavSink};
use std::collections::BTreeMap;
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub struct PipelineBuildRequest {
    pub output_path: PathBuf,
    pub profile: AudioProfile,
    pub sample_rate: u32,
    pub device_channels: u16,
    pub device_name: Option<String>,
    pub gain_db: f32,
    pub limiter_threshold: f32,
    pub high_pass_hz: f32,
    pub noise_suppression_amount: f32,
    pub noise_calibration_ms: u32,
    pub delay_effect: Option<DelayEffectConfig>,
    pub stage_overrides: BTreeMap<String, crate::core::ProcessorOverrideMode>,
    pub microphone_sim: MicrophoneSimConfig,
}

pub struct NativePipelineBuilder<F = ProfileProcessorFactory> {
    processor_factory: F,
}

impl NativePipelineBuilder<ProfileProcessorFactory> {
    pub fn new() -> Self {
        Self {
            processor_factory: ProfileProcessorFactory::new(),
        }
    }
}

impl Default for NativePipelineBuilder<ProfileProcessorFactory> {
    fn default() -> Self {
        Self::new()
    }
}

impl<F> NativePipelineBuilder<F>
where
    F: ProcessorFactory,
{
    #[cfg(test)]
    pub fn with_factory(processor_factory: F) -> Self {
        Self { processor_factory }
    }

    pub fn build(&self, request: PipelineBuildRequest) -> AudioResult<AudioPipeline> {
        let output_spec = AudioSpec {
            sample_rate: request.sample_rate,
            channels: 1,
        };
        let processors = self
            .processor_factory
            .build_processors(ProcessorBuildRequest {
                profile: request.profile,
                gain_db: request.gain_db,
                limiter_threshold: request.limiter_threshold,
                high_pass_hz: request.high_pass_hz,
                noise_suppression_amount: request.noise_suppression_amount,
                noise_calibration_ms: request.noise_calibration_ms,
                delay_effect: request.delay_effect,
                stage_overrides: request.stage_overrides.clone(),
            })?;
        let processor_names = processors
            .iter()
            .map(|processor| processor.name().to_string())
            .collect::<Vec<_>>();
        let microphone_sim_processors =
            MicrophoneSimulatorFactory::new().build_processors(request.microphone_sim)?;
        let microphone_sim_processor_names = microphone_sim_processors
            .iter()
            .map(|processor| processor.name().to_string())
            .collect::<Vec<_>>();
        let microphone_sim_model = request
            .microphone_sim
            .active_model()
            .map(|model| model.as_str().to_string());
        let mut notes = request
            .delay_effect
            .map(|effect| vec![format!("delay_effect={}", effect.preset.as_str())])
            .unwrap_or_default();
        if let Some(model) = &microphone_sim_model {
            notes.push(format!("microphone_sim={}", model));
        }
        notes.push("pipeline_order=microphone_sim->voice_processing->sink".to_string());

        self.build_with_parts(
            output_spec,
            CaptureDiagnostics {
                backend: "native".to_string(),
                profile: request.profile.as_str().to_string(),
                profile_base: Some(request.profile.as_str().to_string()),
                device_name: request.device_name,
                sample_rate: Some(request.sample_rate),
                channels: Some(request.device_channels),
                processor_names,
                processor_stage_overrides: request
                    .stage_overrides
                    .iter()
                    .map(|(stage, mode)| format!("{}={}", stage, mode.as_str()))
                    .collect(),
                resolved_delay_preset: request
                    .delay_effect
                    .map(|effect| effect.preset.as_str().to_string()),
                microphone_sim_model,
                microphone_sim_processor_names,
                notes,
                ..CaptureDiagnostics::default()
            },
            microphone_sim_processors,
            processors,
            Box::new(WavSink::create(&request.output_path, output_spec)?),
            Box::new(LevelMetrics::default()),
        )
    }

    fn build_with_parts(
        &self,
        spec: AudioSpec,
        diagnostics: CaptureDiagnostics,
        microphone_sim_processors: Vec<Box<dyn crate::mic_sim::contracts::MicrophoneSimProcessor>>,
        processors: Vec<Box<dyn crate::contracts::AudioProcessor>>,
        sink: Box<dyn AudioSink>,
        metrics: Box<dyn MetricsCollector>,
    ) -> AudioResult<AudioPipeline> {
        AudioPipeline::new(
            spec,
            diagnostics,
            microphone_sim_processors,
            processors,
            sink,
            metrics,
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::contracts::AudioProcessor;
    use crate::core::{AudioError, AudioFrame};
    use std::sync::{Arc, Mutex};

    struct RecordingFactory {
        names: Arc<Mutex<Vec<String>>>,
    }

    impl ProcessorFactory for RecordingFactory {
        fn build_processors(
            &self,
            request: ProcessorBuildRequest,
        ) -> AudioResult<Vec<Box<dyn AudioProcessor>>> {
            self.names
                .lock()
                .unwrap()
                .push(request.profile.as_str().to_string());
            Ok(vec![Box::new(NoopProcessor)])
        }
    }

    struct NoopProcessor;

    impl AudioProcessor for NoopProcessor {
        fn name(&self) -> &'static str {
            "noop"
        }

        fn prepare(&mut self, _spec: AudioSpec) -> AudioResult<()> {
            Ok(())
        }

        fn process(&mut self, _frame: &mut AudioFrame) -> AudioResult<()> {
            Ok(())
        }
    }

    #[test]
    fn builder_uses_factory_and_populates_diagnostics() {
        let names = Arc::new(Mutex::new(Vec::new()));
        let builder = NativePipelineBuilder::with_factory(RecordingFactory {
            names: Arc::clone(&names),
        });

        let output_path = std::env::temp_dir().join("zk-listen-builder-test.wav");
        let mut pipeline = builder
            .build(PipelineBuildRequest {
                output_path: output_path.clone(),
                profile: AudioProfile::VoiceHvac,
                sample_rate: 44_100,
                device_channels: 2,
                device_name: Some("Test Mic".to_string()),
                gain_db: 2.0,
                limiter_threshold: 0.92,
                high_pass_hz: 100.0,
                noise_suppression_amount: 0.5,
                noise_calibration_ms: 350,
                delay_effect: None,
                stage_overrides: std::collections::BTreeMap::new(),
                microphone_sim: MicrophoneSimConfig::default(),
            })
            .map_err(|err| AudioError::new(format!("builder failed: {}", err)))
            .unwrap();

        let diagnostics = pipeline.finalize(Some(10)).unwrap();
        assert_eq!(diagnostics.profile, "voice_hvac");
        assert_eq!(diagnostics.profile_base.as_deref(), Some("voice_hvac"));
        assert_eq!(diagnostics.device_name.as_deref(), Some("Test Mic"));
        assert_eq!(diagnostics.processor_names, vec!["noop"]);
        assert!(diagnostics.processor_stage_overrides.is_empty());
        assert!(diagnostics.resolved_delay_preset.is_none());
        assert!(diagnostics.microphone_sim_model.is_none());
        assert!(diagnostics.microphone_sim_processor_names.is_empty());
        assert!(diagnostics
            .notes
            .iter()
            .any(|note| note == "pipeline_order=microphone_sim->voice_processing->sink"));
        assert_eq!(names.lock().unwrap().as_slice(), ["voice_hvac"]);

        let _ = std::fs::remove_file(output_path);
    }
}