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