Add text rendering for sous_chef and mlx, split mlx-diarize out
Sous_chef uses a 3s gap threshold since its segments are
word-level. Mlx without diarization no longer requires an
HF token.

Assisted-by: Claude Opus 4.6 via pi
change mrsxqpnzzznrzpzqkxxxpzvyzkokwykk
commit c764fc243413cfc881baa16f235d680ea01cc1d9
author Alpha Chen <alpha@kejadlen.dev>
date
parent lpsnoxmn
diff --git a/bin/mlx-transcribe b/bin/mlx-transcribe
index 8d94ddf..efd878a 100755
--- a/bin/mlx-transcribe
+++ b/bin/mlx-transcribe
@@ -9,7 +9,7 @@
 # ]
 # ///
 
-"""Transcribe audio with mlx-whisper and diarize with pyannote. Outputs JSON."""
+"""Transcribe audio with mlx-whisper, optionally diarize with pyannote. Outputs JSON."""
 
 import argparse
 import json
@@ -17,7 +17,6 @@ import sys
 from pathlib import Path
 
 import mlx_whisper
-from pyannote.audio import Pipeline
 
 
 def transcribe(audio_path: str, model: str) -> dict:
@@ -33,6 +32,7 @@ def transcribe(audio_path: str, model: str) -> dict:
 def diarize(audio_path: str, hf_token: str):
     """Run pyannote speaker diarization."""
     import torch
+    from pyannote.audio import Pipeline
 
     pipeline = Pipeline.from_pretrained(
         "pyannote/speaker-diarization-3.1",
@@ -74,6 +74,11 @@ def main():
         default="mlx-community/whisper-large-v3-turbo",
         help="MLX whisper model (default: mlx-community/whisper-large-v3-turbo)",
     )
+    parser.add_argument(
+        "--diarize",
+        action="store_true",
+        help="Run pyannote speaker diarization (requires --hf-token)",
+    )
     parser.add_argument(
         "--hf-token",
         default=None,
@@ -83,11 +88,6 @@ def main():
 
     import os
 
-    hf_token = args.hf_token or os.environ.get("HUGGING_FACE_TOKEN")
-    if not hf_token:
-        print("Set --hf-token or $HUGGING_FACE_TOKEN for diarization.", file=sys.stderr)
-        sys.exit(1)
-
     audio = args.audio
     if not Path(audio).exists():
         print(f"Audio file not found: {audio}", file=sys.stderr)
@@ -95,13 +95,17 @@ def main():
 
     print(f"Transcribing {audio}...", file=sys.stderr)
     result = transcribe(audio, args.model)
+    segments = result["segments"]
 
-    print("Diarizing...", file=sys.stderr)
-    diarization = diarize(audio, hf_token)
-
-    segments = assign_speakers(result["segments"], diarization)
+    if args.diarize:
+        hf_token = args.hf_token or os.environ.get("HUGGING_FACE_TOKEN")
+        if not hf_token:
+            print("Set --hf-token or $HUGGING_FACE_TOKEN for diarization.", file=sys.stderr)
+            sys.exit(1)
+        print("Diarizing...", file=sys.stderr)
+        diarization_result = diarize(audio, hf_token)
+        segments = assign_speakers(segments, diarization_result)
 
-    # Write the full mlx-whisper result with speaker labels merged in.
     output = {
         "text": result["text"],
         "language": result.get("language"),
diff --git a/transcribers.rake b/transcribers.rake
index d10e074..84e3f5f 100644
--- a/transcribers.rake
+++ b/transcribers.rake
@@ -10,8 +10,9 @@ module Transcribers
     when "whisper-cpp-tdrz" then WhisperCppTdrz.new
     when "sous_chef" then SousChef.new
     when "mlx" then Mlx.new
+    when "mlx-diarize" then MlxDiarize.new
     else
-      abort "Unknown transcriber: #{name}. Use 'whisperx', 'whisper-cpp-large', 'whisper-cpp-tdrz', 'sous_chef', or 'mlx'."
+      abort "Unknown transcriber: #{name}. Use 'whisperx', 'whisper-cpp-large', 'whisper-cpp-tdrz', 'sous_chef', 'mlx', or 'mlx-diarize'."
     end
   end
 
@@ -142,6 +143,10 @@ module Transcribers
 
   class SousChef < Base
     BINARY = Pathname("sous_chef/.build/release/sous_chef")
+    # Sous_chef produces word-level segments, so gaps between words are
+    # much shorter than sentence-level transcribers. A higher threshold
+    # avoids splitting mid-sentence.
+    PARAGRAPH_GAP_S = 3
 
     def name = "sous_chef"
     def prereqs = [BINARY.to_s]
@@ -156,17 +161,111 @@ module Transcribers
     def call(audio_path, transcript_path)
       sh BINARY.to_s, audio_path, transcript_path
     end
+
+    def render(json_path, txt_path)
+      segments = JSON.parse(File.read(json_path))
+
+      paragraphs = []
+      current = []
+
+      segments.each_with_index do |seg, i|
+        if i > 0 && seg["start"] && segments[i - 1]["end"]
+          gap = seg["start"] - segments[i - 1]["end"]
+          if gap >= PARAGRAPH_GAP_S
+            paragraphs << flush_paragraph(current)
+            current = []
+          end
+        end
+        current << seg
+      end
+      paragraphs << flush_paragraph(current) unless current.empty?
+
+      File.write(txt_path, paragraphs.join("\n\n"))
+    end
+
+    private
+
+    def flush_paragraph(segments)
+      timestamp = format_time(segments.first["start"])
+      text = segments.map { |s| s["text"].strip }.join(" ")
+      "[#{timestamp}] #{text}"
+    end
+
+    def format_time(seconds)
+      return "?:??" unless seconds
+      total = seconds.to_i
+      h = total / 3600
+      m = (total % 3600) / 60
+      s = total % 60
+      h > 0 ? format("%d:%02d:%02d", h, m, s) : format("%d:%02d", m, s)
+    end
   end
 
   class Mlx < Base
     SCRIPT = Pathname("bin/mlx-transcribe")
+    PARAGRAPH_GAP_S = 0.5
 
     def name = "mlx"
     def register; end
 
+    def call(audio_path, transcript_path)
+      sh SCRIPT.to_s, audio_path, transcript_path
+    end
+
+    def render(json_path, txt_path)
+      data = JSON.parse(File.read(json_path))
+      segments = data["segments"]
+
+      paragraphs = []
+      current = []
+
+      segments.each_with_index do |seg, i|
+        if i > 0
+          gap = seg["start"] - segments[i - 1]["end"]
+          if gap >= PARAGRAPH_GAP_S
+            paragraphs << flush_paragraph(current)
+            current = []
+          end
+        end
+        current << seg
+      end
+      paragraphs << flush_paragraph(current) unless current.empty?
+
+      File.write(txt_path, paragraphs.join("\n\n"))
+    end
+
+    private
+
+    def flush_paragraph(segments)
+      timestamp = format_time(segments.first["start"])
+      text = segments.map { |s| s["text"].strip }.join(" ")
+      "[#{timestamp}] #{text}"
+    end
+
+    def format_time(seconds)
+      total = seconds.to_i
+      h = total / 3600
+      m = (total % 3600) / 60
+      s = total % 60
+      h > 0 ? format("%d:%02d:%02d", h, m, s) : format("%d:%02d", m, s)
+    end
+  end
+
+  class MlxDiarize < Mlx
+    def name = "mlx-diarize"
+
     def call(audio_path, transcript_path)
       hf_token = ENV.fetch("HUGGING_FACE_TOKEN") { abort "Set HUGGING_FACE_TOKEN for diarization." }
-      sh SCRIPT.to_s, audio_path, transcript_path, "--hf-token", hf_token
+      sh SCRIPT.to_s, audio_path, transcript_path, "--diarize", "--hf-token", hf_token
+    end
+
+    private
+
+    def flush_paragraph(segments)
+      timestamp = format_time(segments.first["start"])
+      speaker = segments.first["speaker"]
+      text = segments.map { |s| s["text"].strip }.join(" ")
+      speaker ? "[#{timestamp} | #{speaker}] #{text}" : "[#{timestamp}] #{text}"
     end
   end
 end