use std::path::Path;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
pub type EventId = ulid::Ulid;
pub type SpeakerId = String;
pub type AudioChunk = Vec<i16>;
pub trait AudioInput: Send {
fn next_chunk(&mut self) -> Option<AudioChunk>;
}
pub trait EventStream: Iterator<Item = Result<TranscriptEvent>> + Send {}
impl<T> EventStream for T where T: Iterator<Item = Result<TranscriptEvent>> + Send {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Word {
pub text: String,
#[serde(with = "duration_secs")]
pub start: Duration,
#[serde(with = "duration_secs")]
pub end: Duration,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EndpointKind {
SilenceGap,
UtteranceEnd,
StreamEnd,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TranscriptEvent {
Partial {
text: String,
#[serde(with = "duration_secs")]
start: Duration,
#[serde(with = "duration_secs")]
end: Duration,
#[serde(skip_serializing_if = "Option::is_none")]
words: Option<Vec<Word>>,
#[serde(skip_serializing_if = "Option::is_none")]
speaker: Option<SpeakerId>,
},
Final {
event_id: EventId,
text: String,
#[serde(with = "duration_secs")]
start: Duration,
#[serde(with = "duration_secs")]
end: Duration,
confidence: f32,
#[serde(skip_serializing_if = "Option::is_none")]
words: Option<Vec<Word>>,
#[serde(skip_serializing_if = "Option::is_none")]
speaker: Option<SpeakerId>,
revisable: bool,
},
Endpoint {
#[serde(with = "duration_secs")]
at: Duration,
kind: EndpointKind,
},
}
pub trait Transcriber: Send + Sync {
fn transcribe(&self, audio: Box<dyn AudioInput>) -> Result<Box<dyn EventStream>>;
}
#[derive(Debug)]
pub struct VecAudioInput {
samples: Vec<i16>,
cursor: usize,
chunk_samples: usize,
}
impl VecAudioInput {
pub fn from_wav_path(path: impl AsRef<Path>, chunk_samples: usize) -> Result<Self> {
let path = path.as_ref();
let mut reader = hound::WavReader::open(path)
.with_context(|| format!("Failed to open WAV at {}", path.display()))?;
let spec = reader.spec();
if spec.sample_rate != 16_000 {
bail!(
"WAV at {} must be 16000 Hz (got {}). Resample before constructing VecAudioInput.",
path.display(),
spec.sample_rate
);
}
if spec.channels != 1 {
bail!(
"WAV at {} must be mono (got {} channels). Mix down before constructing VecAudioInput.",
path.display(),
spec.channels
);
}
if spec.bits_per_sample != 16 || spec.sample_format != hound::SampleFormat::Int {
bail!(
"WAV at {} must be 16-bit signed PCM (got {}-bit {:?})",
path.display(),
spec.bits_per_sample,
spec.sample_format
);
}
let samples: Vec<i16> = reader
.samples::<i16>()
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("Failed to decode i16 PCM samples from {}", path.display()))?;
Ok(Self::from_samples(samples, chunk_samples))
}
pub fn from_samples(samples: Vec<i16>, chunk_samples: usize) -> Self {
Self {
samples,
cursor: 0,
chunk_samples: chunk_samples.max(1),
}
}
}
impl AudioInput for VecAudioInput {
fn next_chunk(&mut self) -> Option<AudioChunk> {
if self.cursor >= self.samples.len() {
return None;
}
let end = (self.cursor + self.chunk_samples).min(self.samples.len());
let chunk = self.samples[self.cursor..end].to_vec();
self.cursor = end;
Some(chunk)
}
}
mod duration_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_f64(d.as_secs_f64())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let secs = f64::deserialize(d)?;
Ok(Duration::from_secs_f64(secs.max(0.0)))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_fixture_wav(
dir: &TempDir,
name: &str,
sample_rate: u32,
channels: u16,
bits: u16,
samples: &[i16],
) -> std::path::PathBuf {
let path = dir.path().join(name);
let spec = hound::WavSpec {
channels,
sample_rate,
bits_per_sample: bits,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(&path, spec).unwrap();
for s in samples {
writer.write_sample(*s).unwrap();
}
writer.finalize().unwrap();
path
}
#[test]
fn vec_audio_input_from_samples_chunks_correctly() {
let mut input = VecAudioInput::from_samples(vec![1, 2, 3, 4, 5], 2);
assert_eq!(input.next_chunk(), Some(vec![1, 2]));
assert_eq!(input.next_chunk(), Some(vec![3, 4]));
assert_eq!(input.next_chunk(), Some(vec![5]));
assert_eq!(input.next_chunk(), None);
}
#[test]
fn vec_audio_input_zero_chunk_size_clamps_to_one() {
let mut input = VecAudioInput::from_samples(vec![10, 20], 0);
assert_eq!(input.next_chunk(), Some(vec![10]));
assert_eq!(input.next_chunk(), Some(vec![20]));
assert_eq!(input.next_chunk(), None);
}
#[test]
fn vec_audio_input_empty_yields_none() {
let mut input = VecAudioInput::from_samples(vec![], 16);
assert!(input.next_chunk().is_none());
}
#[test]
fn vec_audio_input_reads_16k_mono_i16_wav() {
let tmp = TempDir::new().unwrap();
let path = write_fixture_wav(&tmp, "ok.wav", 16_000, 1, 16, &[100, 200, 300, 400]);
let mut input = VecAudioInput::from_wav_path(&path, 2).unwrap();
assert_eq!(input.next_chunk(), Some(vec![100, 200]));
assert_eq!(input.next_chunk(), Some(vec![300, 400]));
assert!(input.next_chunk().is_none());
}
#[test]
fn vec_audio_input_rejects_wrong_sample_rate() {
let tmp = TempDir::new().unwrap();
let path = write_fixture_wav(&tmp, "44k.wav", 44_100, 1, 16, &[0, 0]);
let err = VecAudioInput::from_wav_path(&path, 16).unwrap_err();
assert!(err.to_string().contains("16000 Hz"), "got: {err}");
}
#[test]
fn vec_audio_input_rejects_stereo() {
let tmp = TempDir::new().unwrap();
let path = write_fixture_wav(&tmp, "stereo.wav", 16_000, 2, 16, &[0, 0, 0, 0]);
let err = VecAudioInput::from_wav_path(&path, 16).unwrap_err();
assert!(err.to_string().contains("mono"), "got: {err}");
}
#[test]
fn vec_audio_input_rejects_wrong_bit_depth() {
let tmp = TempDir::new().unwrap();
let path = dir_with_wav_f32(&tmp);
let err = VecAudioInput::from_wav_path(&path, 16).unwrap_err();
assert!(err.to_string().contains("16-bit"), "got: {err}");
}
fn dir_with_wav_f32(dir: &TempDir) -> std::path::PathBuf {
let path = dir.path().join("f32.wav");
let spec = hound::WavSpec {
channels: 1,
sample_rate: 16_000,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
};
let mut writer = hound::WavWriter::create(&path, spec).unwrap();
writer.write_sample(0.0_f32).unwrap();
writer.finalize().unwrap();
path
}
#[test]
fn vec_audio_input_missing_file_errors() {
let err = VecAudioInput::from_wav_path("/nope/does/not/exist.wav", 16).unwrap_err();
assert!(err.to_string().contains("Failed to open WAV"), "got: {err}");
}
#[test]
fn event_stream_blanket_impl_compiles() {
fn accepts(_s: Box<dyn EventStream>) {}
let events: Vec<Result<TranscriptEvent>> = vec![Ok(TranscriptEvent::Endpoint {
at: Duration::from_secs(1),
kind: EndpointKind::StreamEnd,
})];
accepts(Box::new(events.into_iter()));
}
#[test]
fn transcript_event_serde_round_trips() {
let event = TranscriptEvent::Final {
event_id: ulid::Ulid::from_parts(0, 1),
text: "hello".to_string(),
start: Duration::from_millis(0),
end: Duration::from_millis(500),
confidence: 0.97,
words: None,
speaker: None,
revisable: false,
};
let json = serde_json::to_string(&event).unwrap();
let back: TranscriptEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
#[test]
fn duration_serialises_as_seconds() {
let event = TranscriptEvent::Endpoint {
at: Duration::from_millis(1500),
kind: EndpointKind::StreamEnd,
};
let json = serde_json::to_string(&event).unwrap();
assert!(
json.contains("\"at\":1.5"),
"duration should serialise as f64 seconds, got: {json}"
);
}
#[test]
fn duration_deserialise_rejects_non_numeric_seconds() {
let bad_json = r#"{"type":"endpoint","at":"not a number","kind":"stream_end"}"#;
let result: Result<TranscriptEvent, _> = serde_json::from_str(bad_json);
assert!(result.is_err(), "expected deserialization to fail");
}
#[test]
fn vec_audio_input_propagates_decode_failure() {
let tmp = TempDir::new().unwrap();
let path = write_fixture_wav(&tmp, "truncated.wav", 16_000, 1, 16, &[1, 2, 3, 4]);
let len = std::fs::metadata(&path).unwrap().len();
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_len(len - 1)
.unwrap();
let err = VecAudioInput::from_wav_path(&path, 16).unwrap_err();
assert!(
err.to_string().contains("Failed to decode i16 PCM samples"),
"got: {err}"
);
}
}