voice_bird_cli/session/
finalize.rs1use 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#[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}