Skip to main content

opencode_voice/audio/
wav.rs

1//! WAV file writing and temporary file management.
2
3use anyhow::{Context, Result};
4use hound::{WavSpec, WavWriter};
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8use crate::audio::AudioConfig;
9
10/// Writes i16 PCM samples to a WAV file.
11///
12/// Creates a WAV file with the specified audio config (channels, sample_rate, bit_depth).
13pub fn write_wav(samples: &[i16], config: &AudioConfig, path: &Path) -> Result<()> {
14    let spec = WavSpec {
15        channels: config.channels,
16        sample_rate: config.sample_rate,
17        bits_per_sample: config.bit_depth,
18        sample_format: hound::SampleFormat::Int,
19    };
20
21    let mut writer = WavWriter::create(path, spec)
22        .with_context(|| format!("Failed to create WAV file at {}", path.display()))?;
23
24    for &sample in samples {
25        writer
26            .write_sample(sample)
27            .context("Failed to write audio sample")?;
28    }
29
30    writer.finalize().context("Failed to finalize WAV file")?;
31
32    Ok(())
33}
34
35/// Returns a path in the system temp directory with a UUID filename.
36///
37/// Example: `/tmp/opencode-voice-550e8400-e29b-41d4-a716-446655440000.wav`
38pub fn create_temp_wav_path() -> PathBuf {
39    let filename = format!("opencode-voice-{}.wav", Uuid::new_v4());
40    std::env::temp_dir().join(filename)
41}
42
43/// RAII wrapper for a temporary WAV file — deletes on drop.
44///
45/// The file is created lazily when `write` is called. On drop, the file is
46/// deleted (errors are silently ignored). Use `into_path()` to take ownership
47/// of the path without triggering deletion.
48pub struct TempWav {
49    path: PathBuf,
50}
51
52impl TempWav {
53    /// Creates a new TempWav with a fresh temp path (no file created yet).
54    pub fn new() -> Self {
55        TempWav {
56            path: create_temp_wav_path(),
57        }
58    }
59
60    /// Writes audio samples to the WAV file.
61    pub fn write(&self, samples: &[i16], config: &AudioConfig) -> Result<()> {
62        write_wav(samples, config, &self.path)
63    }
64
65    /// Consumes the TempWav and returns the path WITHOUT deleting the file.
66    ///
67    /// The caller takes ownership of the file and is responsible for cleanup.
68    pub fn into_path(self) -> PathBuf {
69        let path = self.path.clone();
70        std::mem::forget(self); // Prevent Drop from deleting the file
71        path
72    }
73}
74
75impl Drop for TempWav {
76    fn drop(&mut self) {
77        // Silently ignore if file doesn't exist or deletion fails
78        let _ = std::fs::remove_file(&self.path);
79    }
80}
81
82impl Default for TempWav {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::audio::default_audio_config;
92
93    #[test]
94    fn test_create_temp_wav_path_has_uuid() {
95        let path = create_temp_wav_path();
96        let filename = path.file_name().unwrap().to_str().unwrap();
97        assert!(filename.starts_with("opencode-voice-"));
98        assert!(filename.ends_with(".wav"));
99    }
100
101    #[test]
102    fn test_create_temp_wav_path_in_temp_dir() {
103        let path = create_temp_wav_path();
104        assert!(path.starts_with(std::env::temp_dir()));
105    }
106
107    #[test]
108    fn test_write_and_delete() {
109        let config = default_audio_config();
110        let samples: Vec<i16> = vec![0i16; 1000]; // 1000 silent samples
111        let wav = TempWav::new();
112        let path = wav.path.to_path_buf();
113
114        // File doesn't exist yet
115        assert!(!path.exists(), "File should not exist before write");
116
117        // Write samples
118        wav.write(&samples, &config).expect("write should succeed");
119        assert!(path.exists(), "File should exist after write");
120
121        // Drop should delete the file
122        drop(wav);
123        assert!(!path.exists(), "File should be deleted after drop");
124    }
125
126    #[test]
127    fn test_drop_no_panic_when_file_missing() {
128        // TempWav drop should not panic even if the file was never written
129        let wav = TempWav::new();
130        // Don't write anything — just drop
131        drop(wav); // Should not panic
132    }
133
134    #[test]
135    fn test_write_wav_creates_valid_file() {
136        let config = default_audio_config();
137        let samples: Vec<i16> = (0..160).map(|i| i as i16 * 100).collect(); // 10ms at 16kHz
138        let path = create_temp_wav_path();
139
140        write_wav(&samples, &config, &path).expect("write_wav should succeed");
141        assert!(path.exists());
142
143        // Read back and verify
144        let reader = hound::WavReader::open(&path).expect("should be readable");
145        let spec = reader.spec();
146        assert_eq!(spec.channels, 1);
147        assert_eq!(spec.sample_rate, 16000);
148        assert_eq!(spec.bits_per_sample, 16);
149
150        // Cleanup
151        let _ = std::fs::remove_file(&path);
152    }
153}