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
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