Switch all transcribers to JSON output
Keeps full data (timestamps, words, speakers, alternatives)
for each transcriber. A separate render step can produce
readable text from any of them.

Assisted-by: Claude Opus 4.6 via pi
change nttslpyzpyzxkqorkpwyyruvyppxwsvz
commit f05b6f33a3ea51f62213cb571856e2f5391f96b6
author Alpha Chen <alpha@kejadlen.dev>
date
parent ynnqzllr
diff --git a/Rakefile b/Rakefile
index c57222b..05cc1b6 100644
--- a/Rakefile
+++ b/Rakefile
@@ -37,7 +37,7 @@ def audio_path(ep)
 end
 
 def transcript_path(ep)
-  (TRANSCRIPTS_DIR / TRANSCRIBER.name / "#{ep.slug}.txt").to_s
+  (TRANSCRIPTS_DIR / TRANSCRIBER.name / "#{ep.slug}.json").to_s
 end
 
 EPISODES.values.each do |ep|
diff --git a/bin/mlx-transcribe b/bin/mlx-transcribe
index fd46548..8d94ddf 100755
--- a/bin/mlx-transcribe
+++ b/bin/mlx-transcribe
@@ -9,9 +9,10 @@
 # ]
 # ///
 
-"""Transcribe audio with mlx-whisper and diarize with pyannote."""
+"""Transcribe audio with mlx-whisper and diarize with pyannote. Outputs JSON."""
 
 import argparse
+import json
 import sys
 from pathlib import Path
 
@@ -29,7 +30,7 @@ def transcribe(audio_path: str, model: str) -> dict:
     )
 
 
-def diarize(audio_path: str, hf_token: str) -> Pipeline:
+def diarize(audio_path: str, hf_token: str):
     """Run pyannote speaker diarization."""
     import torch
 
@@ -64,32 +65,10 @@ def assign_speakers(segments: list[dict], diarization) -> list[dict]:
     return labeled
 
 
-def format_time(seconds: float) -> str:
-    """Format seconds as H:MM:SS or M:SS."""
-    h = int(seconds) // 3600
-    m = (int(seconds) % 3600) // 60
-    s = int(seconds) % 60
-    if h > 0:
-        return f"{h}:{m:02d}:{s:02d}"
-    return f"{m}:{s:02d}"
-
-
-def format_output(segments: list[dict]) -> str:
-    """Format labeled segments as timestamped, speaker-attributed lines."""
-    lines = []
-    for seg in segments:
-        start = format_time(seg["start"])
-        end = format_time(seg["end"])
-        speaker = seg["speaker"]
-        text = seg["text"].strip()
-        lines.append(f"[{start} → {end} | {speaker}] {text}")
-    return "\n".join(lines)
-
-
 def main():
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument("audio", help="Path to audio file")
-    parser.add_argument("output", help="Path to write transcript")
+    parser.add_argument("output", help="Path to write JSON transcript")
     parser.add_argument(
         "--model",
         default="mlx-community/whisper-large-v3-turbo",
@@ -121,9 +100,17 @@ def main():
     diarization = diarize(audio, hf_token)
 
     segments = assign_speakers(result["segments"], diarization)
-    text = format_output(segments)
 
-    Path(args.output).write_text(text, encoding="utf-8")
+    # Write the full mlx-whisper result with speaker labels merged in.
+    output = {
+        "text": result["text"],
+        "language": result.get("language"),
+        "segments": segments,
+    }
+    Path(args.output).write_text(
+        json.dumps(output, ensure_ascii=False, indent=2),
+        encoding="utf-8",
+    )
     print(f"Done. Wrote {len(segments)} segments to {args.output}.", file=sys.stderr)
 
 
diff --git a/sous_chef/Sources/SousChef.swift b/sous_chef/Sources/SousChef.swift
index 2484017..4ec15b2 100644
--- a/sous_chef/Sources/SousChef.swift
+++ b/sous_chef/Sources/SousChef.swift
@@ -39,15 +39,21 @@ struct SousChef: AsyncParsableCommand {
 
         // Collect results in a separate task.
         let resultsTask = Task {
-            var lines: [String] = []
+            var segments: [Segment] = []
             for try await result in transcriber.results {
                 let text = String(result.text.characters)
-                let timestamp = Self.formatTimestamp(result.text)
-                let line = "[\(timestamp)] \(text)"
-                lines.append(line)
-                log("  \(line)")
+                let timeRange = Self.extractTimeRange(result.text)
+                let alternatives = result.alternatives.map { String($0.characters) }
+                let segment = Segment(
+                    text: text,
+                    start: timeRange?.start,
+                    end: timeRange?.end,
+                    alternatives: alternatives.isEmpty ? nil : alternatives
+                )
+                segments.append(segment)
+                log("  [\(segment.startFormatted)] \(text)")
             }
-            return lines
+            return segments
         }
 
         // Run the analysis.
@@ -61,29 +67,27 @@ struct SousChef: AsyncParsableCommand {
             await analyzer.cancelAndFinishNow()
         }
 
-        let lines = try await resultsTask.value
+        let segments = try await resultsTask.value
 
-        // Write timestamped transcript.
-        let text = lines.joined(separator: "\n")
-        try text.write(to: outputURL, atomically: true, encoding: .utf8)
+        // Write JSON output.
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        let data = try encoder.encode(segments)
+        try data.write(to: outputURL)
 
-        log("Done. Wrote \(lines.count) segments to \(output).")
+        log("Done. Wrote \(segments.count) segments to \(output).")
     }
 
-    /// Extract the start time from the first audioTimeRange attribute in the result text.
-    private static func formatTimestamp(_ text: AttributedString) -> String {
+    /// Extract the time range from the first audioTimeRange attribute in the result text.
+    private static func extractTimeRange(_ text: AttributedString) -> (start: Double, end: Double)? {
         for run in text.runs {
             if let timeRange = run[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] {
-                let seconds = CMTimeGetSeconds(timeRange.start)
-                let h = Int(seconds) / 3600
-                let m = (Int(seconds) % 3600) / 60
-                let s = Int(seconds) % 60
-                return h > 0
-                    ? String(format: "%d:%02d:%02d", h, m, s)
-                    : String(format: "%d:%02d", m, s)
+                let start = CMTimeGetSeconds(timeRange.start)
+                let end = CMTimeGetSeconds(timeRange.start + timeRange.duration)
+                return (start, end)
             }
         }
-        return "?:??"
+        return nil
     }
 
     private func log(_ message: String) {
@@ -91,6 +95,24 @@ struct SousChef: AsyncParsableCommand {
     }
 }
 
+struct Segment: Encodable {
+    let text: String
+    let start: Double?
+    let end: Double?
+    let alternatives: [String]?
+
+    var startFormatted: String {
+        guard let start else { return "?:??" }
+        let total = Int(start)
+        let h = total / 3600
+        let m = (total % 3600) / 60
+        let s = total % 60
+        return h > 0
+            ? String(format: "%d:%02d:%02d", h, m, s)
+            : String(format: "%d:%02d", m, s)
+    }
+}
+
 enum TranscriptionError: Error, CustomStringConvertible {
     case unsupportedLocale
 
diff --git a/transcribers.rake b/transcribers.rake
index 9727922..b48979d 100644
--- a/transcribers.rake
+++ b/transcribers.rake
@@ -41,7 +41,7 @@ module Transcribers
         "--device", "cpu",
         "--diarize", "--hf_token", hf_token,
         "--output_dir", File.dirname(transcript_path),
-        "--output_format", "txt"
+        "--output_format", "json"
     end
   end
 
@@ -77,8 +77,8 @@ module Transcribers
     def call(audio_path, transcript_path)
       sh "whisper-cli",
         "--model", Transcribers.model_path(MODEL).to_s,
-        "--output-txt",
-        "--output-file", transcript_path.delete_suffix(".txt"),
+        "--output-json",
+        "--output-file", transcript_path.delete_suffix(".json"),
         audio_path
     end
   end
@@ -97,8 +97,8 @@ module Transcribers
       sh "whisper-cli",
         "--model", Transcribers.model_path(MODEL).to_s,
         "-tdrz",
-        "--output-txt",
-        "--output-file", transcript_path.delete_suffix(".txt"),
+        "--output-json",
+        "--output-file", transcript_path.delete_suffix(".json"),
         audio_path
     end
   end