Skip to main content

omni_dev/voice/
render.rs

1//! Rendering helpers for [`crate::voice::TranscriptEvent`] streams.
2//!
3//! Two output formats are supported, both designed to stream — each event
4//! is flushed as soon as the backend emits it so a slow transcriber gives
5//! incremental feedback on stdout instead of buffering the full transcript.
6//!
7//! * `Jsonl` — one `serde_json` line per event. Stable, machine-readable.
8//! * `Md` — human-readable transcript. Consecutive `Final` events from the
9//!   same speaker collapse into a single paragraph prefixed with
10//!   `[HH:MM:SS] **speaker**: ` (the `**speaker**: ` prefix is omitted
11//!   when no speaker is attached).
12//!
13//! Lives under `src/voice/` rather than `src/cli/voice/` because the
14//! `voice review` command in #804 will reuse `render_markdown`.
15//!
16//! `Partial` and `Endpoint` events are skipped in markdown mode: the batch
17//! backend in #801 emits no partials, and endpoint markers add no signal
18//! to a reader-oriented transcript.
19
20use std::io::Write;
21use std::time::Duration;
22
23use anyhow::Result;
24
25use crate::voice::transcriber::{SpeakerId, TranscriptEvent};
26
27/// Output format selector. The CLI maps `--format md|jsonl` directly onto
28/// this; the default value is chosen at runtime by [`detect_format`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OutputFormat {
31    /// JSON Lines — one event per line, machine-readable.
32    Jsonl,
33    /// Markdown — human-readable transcript view.
34    Md,
35}
36
37/// Resolves the effective output format.
38///
39/// Explicit `--format` always wins. With no flag, stdout-on-a-tty defaults
40/// to markdown; stdout-on-a-pipe defaults to JSONL. Mirrors the design
41/// principle that machine consumers (`| jq`, file redirection) get a
42/// stable schema and humans get prose.
43pub fn detect_format(explicit: Option<OutputFormat>, stdout_is_tty: bool) -> OutputFormat {
44    match explicit {
45        Some(fmt) => fmt,
46        None if stdout_is_tty => OutputFormat::Md,
47        None => OutputFormat::Jsonl,
48    }
49}
50
51/// Streams events as JSON Lines to `w`, flushing after each event.
52///
53/// The flush is deliberate: a streaming backend can emit a `Partial`
54/// half a second before its `Final` and a downstream `tail -f` consumer
55/// should see it without waiting for the next event to push it through
56/// stdio buffering.
57pub fn render_jsonl<I, W>(events: I, w: &mut W) -> Result<()>
58where
59    I: IntoIterator<Item = Result<TranscriptEvent>>,
60    W: Write,
61{
62    for event in events {
63        let event = event?;
64        serde_json::to_writer(&mut *w, &event)?;
65        writeln!(w)?;
66        w.flush()?;
67    }
68    Ok(())
69}
70
71/// Streams events as Markdown to `w`.
72///
73/// Groups consecutive `Final` events by speaker into one paragraph each
74/// (a paragraph being one timestamped line followed by a blank line),
75/// flushing at every speaker-paragraph boundary so the reader sees
76/// transcript output as it commits. `Partial` and `Endpoint` events are
77/// dropped.
78pub fn render_markdown<I, W>(events: I, w: &mut W) -> Result<()>
79where
80    I: IntoIterator<Item = Result<TranscriptEvent>>,
81    W: Write,
82{
83    let mut group: Option<MarkdownParagraph> = None;
84    for event in events {
85        let event = event?;
86        let TranscriptEvent::Final {
87            text,
88            start,
89            speaker,
90            ..
91        } = event
92        else {
93            continue;
94        };
95        match group.as_mut() {
96            Some(existing) if existing.speaker == speaker => {
97                existing.push_segment(&text);
98            }
99            _ => {
100                if let Some(prev) = group.take() {
101                    prev.write(w)?;
102                    w.flush()?;
103                }
104                group = Some(MarkdownParagraph::new(start, speaker, text));
105            }
106        }
107    }
108    if let Some(last) = group {
109        last.write(w)?;
110        w.flush()?;
111    }
112    Ok(())
113}
114
115struct MarkdownParagraph {
116    start: Duration,
117    speaker: Option<SpeakerId>,
118    text: String,
119}
120
121impl MarkdownParagraph {
122    fn new(start: Duration, speaker: Option<SpeakerId>, text: String) -> Self {
123        Self {
124            start,
125            speaker,
126            text,
127        }
128    }
129
130    fn push_segment(&mut self, segment: &str) {
131        // Single space joiner — matches how a human would read aloud
132        // consecutive finals from one speaker. Don't collapse internal
133        // whitespace; the backend's text is authoritative.
134        if !self.text.is_empty() && !segment.is_empty() {
135            self.text.push(' ');
136        }
137        self.text.push_str(segment);
138    }
139
140    fn write<W: Write>(&self, w: &mut W) -> Result<()> {
141        let ts = fmt_timestamp(self.start);
142        match self.speaker.as_deref() {
143            Some(name) => writeln!(w, "[{ts}] **{name}**: {}", self.text)?,
144            None => writeln!(w, "[{ts}] {}", self.text)?,
145        }
146        writeln!(w)?;
147        Ok(())
148    }
149}
150
151/// Formats a stream-relative `Duration` as `HH:MM:SS`, zero-padded, with
152/// hours always shown.
153///
154/// Hours always shown — even for short audio — so the column width is
155/// stable across a transcript and matches the convention used by
156/// `ffmpeg`, `mpv`, and most media players.
157fn fmt_timestamp(d: Duration) -> String {
158    let total = d.as_secs();
159    let h = total / 3600;
160    let m = (total % 3600) / 60;
161    let s = total % 60;
162    format!("{h:02}:{m:02}:{s:02}")
163}
164
165// ─── Review markdown (todos.md, decisions.md) ──────────────────────────
166
167use crate::voice::events::{ItemClass, Priority};
168use crate::voice::reconcile::{ReconciledDecision, ReconciledItem};
169
170/// Renders the `todos.md` body for a `voice review` pass.
171///
172/// Items are grouped by priority (High → Normal → Low), each group as
173/// a `## <Priority> priority` section. Within a group, items are sorted
174/// by their create-event id ascending so re-renders are byte-stable.
175/// Questions render with a `- ?` prefix to distinguish them from todos.
176///
177/// An empty section header is emitted only when at least one item lives
178/// in that bucket — sparse priority levels produce a sparser file.
179#[must_use]
180pub fn render_todos_md(items: &[ReconciledItem]) -> String {
181    let mut sorted: Vec<&ReconciledItem> = items.iter().collect();
182    sorted.sort_by_key(|i| i.created_event_id);
183
184    let mut out = String::from("# Todos\n");
185    for priority in [Priority::High, Priority::Normal, Priority::Low] {
186        let bucket: Vec<&&ReconciledItem> = sorted
187            .iter()
188            .filter(|i| i.item.priority == priority)
189            .collect();
190        if bucket.is_empty() {
191            continue;
192        }
193        out.push('\n');
194        out.push_str(&format!("## {} priority\n\n", priority_label(&priority)));
195        for entry in bucket {
196            out.push_str(&render_todo_line(entry));
197            out.push('\n');
198        }
199    }
200    out
201}
202
203fn priority_label(p: &Priority) -> &'static str {
204    match p {
205        Priority::High => "High",
206        Priority::Normal => "Normal",
207        Priority::Low => "Low",
208    }
209}
210
211fn render_todo_line(entry: &ReconciledItem) -> String {
212    let prefix = match entry.item.class {
213        ItemClass::Question => "- ?",
214        _ => "- [ ]",
215    };
216    let id_short = short_id(&entry.item.id);
217    match entry.item.valid_until {
218        Some(vu) => format!(
219            "{prefix} {text} — *expires {ts}* `{id_short}`",
220            text = entry.item.text,
221            ts = vu.to_rfc3339(),
222        ),
223        None => format!("{prefix} {text} `{id_short}`", text = entry.item.text),
224    }
225}
226
227/// Renders the `decisions.md` body for a `voice review` pass.
228///
229/// Decisions sort newest-first by their create-event id. When a
230/// decision recorded alternatives, they render as an indented
231/// continuation line; otherwise the alternatives line is omitted.
232#[must_use]
233pub fn render_decisions_md(decisions: &[ReconciledDecision]) -> String {
234    let mut sorted: Vec<&ReconciledDecision> = decisions.iter().collect();
235    sorted.sort_by_key(|d| std::cmp::Reverse(d.created_event_id));
236
237    let mut out = String::from("# Decisions\n");
238    if sorted.is_empty() {
239        return out;
240    }
241    out.push('\n');
242    for entry in sorted {
243        let id_short = short_id(&entry.decision.id);
244        out.push_str(&format!(
245            "- **{text}** `{id_short}`\n",
246            text = entry.decision.text,
247        ));
248        if !entry.decision.alternatives.is_empty() {
249            out.push_str(&format!(
250                "  Alternatives considered: {}\n",
251                entry.decision.alternatives.join(", "),
252            ));
253        }
254    }
255    out
256}
257
258fn short_id(u: &ulid::Ulid) -> String {
259    let s = u.to_string();
260    s.chars().take(8).collect()
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used, clippy::expect_used)]
265mod tests {
266    use super::*;
267    use crate::voice::transcriber::EndpointKind;
268
269    /// `Write` impl that fails on every `write` call. Lets tests exercise
270    /// the first `?` site in any rendering function without depending on
271    /// the exact number of internal writes a formatter issues.
272    struct AlwaysFailWriter;
273
274    impl Write for AlwaysFailWriter {
275        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
276            Err(std::io::Error::other("forced write failure"))
277        }
278        fn flush(&mut self) -> std::io::Result<()> {
279            Ok(())
280        }
281    }
282
283    /// `Write` impl that accepts all writes but fails on `flush`. Targets
284    /// the post-event `w.flush()?` branches in `render_jsonl` and
285    /// `render_markdown`'s paragraph boundaries.
286    struct FlushFailWriter;
287
288    impl Write for FlushFailWriter {
289        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
290            Ok(buf.len())
291        }
292        fn flush(&mut self) -> std::io::Result<()> {
293            Err(std::io::Error::other("forced flush failure"))
294        }
295    }
296
297    fn final_event(text: &str, start_secs: u64, speaker: Option<&str>) -> TranscriptEvent {
298        TranscriptEvent::Final {
299            event_id: ulid::Ulid::from_parts(0, u128::from(start_secs) + 1),
300            text: text.to_string(),
301            start: Duration::from_secs(start_secs),
302            end: Duration::from_secs(start_secs + 1),
303            confidence: 0.9,
304            words: None,
305            speaker: speaker.map(str::to_string),
306            revisable: false,
307        }
308    }
309
310    fn render_md_to_string<I>(events: I) -> String
311    where
312        I: IntoIterator<Item = TranscriptEvent>,
313    {
314        let mut buf: Vec<u8> = Vec::new();
315        render_markdown(events.into_iter().map(Ok), &mut buf).unwrap();
316        String::from_utf8(buf).unwrap()
317    }
318
319    fn render_jsonl_to_string<I>(events: I) -> String
320    where
321        I: IntoIterator<Item = TranscriptEvent>,
322    {
323        let mut buf: Vec<u8> = Vec::new();
324        render_jsonl(events.into_iter().map(Ok), &mut buf).unwrap();
325        String::from_utf8(buf).unwrap()
326    }
327
328    #[test]
329    fn detect_format_explicit_wins_over_tty() {
330        assert_eq!(
331            detect_format(Some(OutputFormat::Jsonl), true),
332            OutputFormat::Jsonl
333        );
334        assert_eq!(
335            detect_format(Some(OutputFormat::Md), false),
336            OutputFormat::Md
337        );
338    }
339
340    #[test]
341    fn detect_format_tty_defaults_to_md() {
342        assert_eq!(detect_format(None, true), OutputFormat::Md);
343    }
344
345    #[test]
346    fn detect_format_pipe_defaults_to_jsonl() {
347        assert_eq!(detect_format(None, false), OutputFormat::Jsonl);
348    }
349
350    #[test]
351    fn fmt_timestamp_pads_zero() {
352        assert_eq!(fmt_timestamp(Duration::from_secs(0)), "00:00:00");
353        assert_eq!(fmt_timestamp(Duration::from_secs(7)), "00:00:07");
354        assert_eq!(fmt_timestamp(Duration::from_secs(75)), "00:01:15");
355        assert_eq!(fmt_timestamp(Duration::from_secs(3_600)), "01:00:00");
356        assert_eq!(
357            fmt_timestamp(Duration::from_secs(3_600 + 23 * 60 + 45)),
358            "01:23:45"
359        );
360    }
361
362    #[test]
363    fn fmt_timestamp_truncates_subsecond() {
364        // 1.9s reads as 00:00:01 (whole seconds only, matching media-player
365        // convention).
366        assert_eq!(fmt_timestamp(Duration::from_millis(1_900)), "00:00:01");
367    }
368
369    #[test]
370    fn render_jsonl_emits_one_line_per_event_and_flushes() {
371        let out = render_jsonl_to_string([
372            final_event("hello", 0, None),
373            TranscriptEvent::Endpoint {
374                at: Duration::from_millis(1_500),
375                kind: EndpointKind::StreamEnd,
376            },
377        ]);
378        let lines: Vec<&str> = out.lines().collect();
379        assert_eq!(lines.len(), 2);
380        assert!(lines[0].starts_with(r#"{"type":"final""#));
381        assert!(lines[0].contains(r#""text":"hello""#));
382        assert!(lines[1].starts_with(r#"{"type":"endpoint""#));
383        assert!(lines[1].contains(r#""at":1.5"#));
384        // Trailing newline after the last event.
385        assert!(out.ends_with('\n'));
386    }
387
388    #[test]
389    fn render_jsonl_propagates_stream_error() {
390        let events: Vec<Result<TranscriptEvent>> = vec![Err(anyhow::anyhow!("backend exploded"))];
391        let mut buf: Vec<u8> = Vec::new();
392        let err = render_jsonl(events, &mut buf).unwrap_err();
393        assert!(err.to_string().contains("backend exploded"));
394    }
395
396    #[test]
397    fn render_markdown_propagates_stream_error() {
398        let events: Vec<Result<TranscriptEvent>> = vec![Err(anyhow::anyhow!("backend exploded"))];
399        let mut buf: Vec<u8> = Vec::new();
400        let err = render_markdown(events, &mut buf).unwrap_err();
401        assert!(err.to_string().contains("backend exploded"));
402    }
403
404    #[test]
405    fn render_markdown_handles_empty_text_segment_in_group() {
406        // Two consecutive same-speaker finals where the *second* has empty
407        // text. The push_segment joiner must skip the leading space; the
408        // output should be a single paragraph with the first final's text
409        // unchanged.
410        let out = render_md_to_string([
411            final_event("hello", 0, Some("alice")),
412            final_event("", 2, Some("alice")),
413        ]);
414        assert_eq!(out, "[00:00:00] **alice**: hello\n\n");
415    }
416
417    #[test]
418    fn render_markdown_skips_partial_and_endpoint() {
419        let out = render_md_to_string([
420            TranscriptEvent::Partial {
421                text: "ignored".into(),
422                start: Duration::from_secs(0),
423                end: Duration::from_secs(1),
424                words: None,
425                speaker: None,
426            },
427            final_event("kept", 0, None),
428            TranscriptEvent::Endpoint {
429                at: Duration::from_secs(2),
430                kind: EndpointKind::StreamEnd,
431            },
432        ]);
433        assert!(!out.contains("ignored"));
434        assert!(out.contains("kept"));
435        // No timestamp from the endpoint should bleed through.
436        assert!(!out.contains("[00:00:02]"));
437    }
438
439    #[test]
440    fn render_markdown_groups_consecutive_same_speaker_finals() {
441        let out = render_md_to_string([
442            final_event("hello", 0, Some("alice")),
443            final_event("world", 2, Some("alice")),
444            final_event("hi", 4, Some("bob")),
445        ]);
446        // alice's two segments merge onto one line; bob is a new paragraph.
447        let expected = "[00:00:00] **alice**: hello world\n\n[00:00:04] **bob**: hi\n\n";
448        assert_eq!(out, expected);
449    }
450
451    #[test]
452    fn render_markdown_groups_consecutive_none_speaker_finals() {
453        let out =
454            render_md_to_string([final_event("alpha", 0, None), final_event("beta", 3, None)]);
455        // Two consecutive None-speaker finals collapse into one paragraph
456        // with no speaker prefix.
457        assert_eq!(out, "[00:00:00] alpha beta\n\n");
458    }
459
460    #[test]
461    fn render_markdown_speaker_change_starts_new_paragraph() {
462        let out = render_md_to_string([
463            final_event("a", 0, Some("alice")),
464            final_event("b", 1, None),
465            final_event("c", 2, Some("alice")),
466        ]);
467        // None → alice → None all distinct paragraphs.
468        let expected = "[00:00:00] **alice**: a\n\n[00:00:01] b\n\n[00:00:02] **alice**: c\n\n";
469        assert_eq!(out, expected);
470    }
471
472    #[test]
473    fn render_jsonl_propagates_writer_error() {
474        let events = [Ok(final_event("a", 0, None))];
475        let err = render_jsonl(events, &mut AlwaysFailWriter).unwrap_err();
476        assert!(err.to_string().contains("forced write failure"));
477    }
478
479    #[test]
480    fn render_jsonl_propagates_flush_error() {
481        let events = [Ok(final_event("a", 0, None))];
482        let err = render_jsonl(events, &mut FlushFailWriter).unwrap_err();
483        assert!(err.to_string().contains("forced flush failure"));
484    }
485
486    #[test]
487    fn render_markdown_propagates_writer_error_with_speaker() {
488        // Exercises the paragraph-write `?` plus the with-speaker writeln
489        // arm inside MarkdownParagraph::write.
490        let events = [Ok(final_event("a", 0, Some("alice")))];
491        let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
492        assert!(err.to_string().contains("forced write failure"));
493    }
494
495    #[test]
496    fn render_markdown_propagates_writer_error_no_speaker() {
497        // Same as above, but exercises the no-speaker writeln arm.
498        let events = [Ok(final_event("a", 0, None))];
499        let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
500        assert!(err.to_string().contains("forced write failure"));
501    }
502
503    #[test]
504    fn render_markdown_propagates_flush_error_at_paragraph_end() {
505        // FlushFailWriter accepts the paragraph's writes (content +
506        // blank line), then fails on the trailing per-paragraph flush.
507        let events = [Ok(final_event("a", 0, None))];
508        let err = render_markdown(events, &mut FlushFailWriter).unwrap_err();
509        assert!(err.to_string().contains("forced flush failure"));
510    }
511
512    #[test]
513    fn render_markdown_propagates_writer_error_at_paragraph_break() {
514        // Two different-speaker finals exercise the mid-stream
515        // `prev.write(w)?` path (line 101) when the group changes.
516        // AlwaysFailWriter trips that branch on the first paragraph's
517        // writeln, before the speaker change is even reached — close
518        // enough to exercise the second-paragraph entry; the regression
519        // we want to catch is "errors mid-stream don't get swallowed."
520        let events = [
521            Ok(final_event("a", 0, Some("alice"))),
522            Ok(final_event("b", 1, Some("bob"))),
523        ];
524        let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
525        assert!(err.to_string().contains("forced write failure"));
526    }
527
528    #[test]
529    fn render_markdown_empty_input_writes_nothing() {
530        let out = render_md_to_string::<[TranscriptEvent; 0]>([]);
531        assert_eq!(out, "");
532    }
533}