Skip to main content

voice_bird_cli/session/
finalize.rs

1use std::io::Write;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::session::writer::WrittenSegment;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct SessionMeta {
10    pub version: String,
11    pub model: String,
12    pub engine: String,
13    pub source: String,
14    pub device: String,
15    pub started_at: String,
16    pub ended_at: String,
17    pub duration_ms: u64,
18}
19
20#[derive(Debug, Serialize)]
21struct FinalTranscript<'a> {
22    segments: &'a [WrittenSegment],
23    meta: &'a SessionMeta,
24}
25
26/// Owned version of `FinalTranscript` — used to deserialize
27/// transcript.json for export/recovery.
28#[derive(Debug, Deserialize)]
29pub struct FinalTranscriptValue {
30    pub segments: Vec<WrittenSegment>,
31    pub meta: SessionMeta,
32}
33
34pub fn finalize(
35    jsonl: &Path,
36    out_json: &Path,
37    out_txt: &Path,
38    out_meta: &Path,
39    meta: &SessionMeta,
40) -> anyhow::Result<()> {
41    let segments = read_jsonl(jsonl)?;
42    write_atomic(out_json, |f| {
43        serde_json::to_writer_pretty(
44            f,
45            &FinalTranscript {
46                segments: &segments,
47                meta,
48            },
49        )?;
50        Ok(())
51    })?;
52    write_atomic(out_txt, |f| {
53        for s in &segments {
54            writeln!(f, "{}", s.text)?;
55        }
56        Ok(())
57    })?;
58    write_atomic(out_meta, |f| {
59        serde_json::to_writer_pretty(f, meta)?;
60        Ok(())
61    })?;
62    Ok(())
63}
64
65fn read_jsonl(path: &Path) -> anyhow::Result<Vec<WrittenSegment>> {
66    let s = std::fs::read_to_string(path)?;
67    let mut out = Vec::new();
68    for line in s.lines() {
69        let line = line.trim();
70        if line.is_empty() {
71            continue;
72        }
73        out.push(serde_json::from_str(line)?);
74    }
75    Ok(out)
76}
77
78fn write_atomic<F>(path: &Path, write: F) -> anyhow::Result<()>
79where
80    F: FnOnce(&mut std::fs::File) -> anyhow::Result<()>,
81{
82    let tmp = path.with_extension("tmp");
83    {
84        let mut f = std::fs::File::create(&tmp)?;
85        write(&mut f)?;
86        f.sync_data()?;
87    }
88    std::fs::rename(&tmp, path)?;
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::session::writer::{SegmentWriter, WrittenSegment};
96    use tempfile::TempDir;
97
98    #[test]
99    fn writes_json_and_txt_from_jsonl() {
100        let dir = TempDir::new().unwrap();
101        let jsonl = dir.path().join("transcript.jsonl");
102        {
103            let mut w = SegmentWriter::open(&jsonl).unwrap();
104            w.append(&WrittenSegment {
105                t_start_ms: 0,
106                t_end_ms: 1500,
107                text: "hello".into(),
108            })
109            .unwrap();
110            w.append(&WrittenSegment {
111                t_start_ms: 1500,
112                t_end_ms: 3000,
113                text: "world".into(),
114            })
115            .unwrap();
116        }
117
118        let meta = SessionMeta {
119            version: "0.3.0".into(),
120            model: "distil-small.en".into(),
121            engine: "whisper_rs".into(),
122            source: "mic".into(),
123            device: "MacBook Pro Microphone".into(),
124            started_at: "2026-04-16T14:32:07Z".into(),
125            ended_at: "2026-04-16T14:32:10Z".into(),
126            duration_ms: 3000,
127        };
128
129        let out_json = dir.path().join("transcript.json");
130        let out_txt = dir.path().join("transcript.txt");
131        let out_meta = dir.path().join("meta.json");
132        finalize(&jsonl, &out_json, &out_txt, &out_meta, &meta).unwrap();
133
134        let j: serde_json::Value =
135            serde_json::from_str(&std::fs::read_to_string(&out_json).unwrap()).unwrap();
136        assert_eq!(j["segments"].as_array().unwrap().len(), 2);
137        assert_eq!(j["meta"]["model"], "distil-small.en");
138
139        let t = std::fs::read_to_string(&out_txt).unwrap();
140        assert_eq!(t, "hello\nworld\n");
141
142        let m: SessionMeta =
143            serde_json::from_str(&std::fs::read_to_string(&out_meta).unwrap()).unwrap();
144        assert_eq!(m.model, "distil-small.en");
145    }
146
147    #[test]
148    fn empty_jsonl_produces_empty_transcript() {
149        let dir = TempDir::new().unwrap();
150        let jsonl = dir.path().join("transcript.jsonl");
151        std::fs::write(&jsonl, "").unwrap();
152        let meta = SessionMeta::default();
153
154        finalize(
155            &jsonl,
156            &dir.path().join("transcript.json"),
157            &dir.path().join("transcript.txt"),
158            &dir.path().join("meta.json"),
159            &meta,
160        )
161        .unwrap();
162
163        assert_eq!(
164            std::fs::read_to_string(dir.path().join("transcript.txt")).unwrap(),
165            ""
166        );
167    }
168}