Add Parakeet transcriber via parakeet-mlx
Uses sentence-count paragraphing since chunking overlap
produces negative time gaps between sentences.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/bin/parakeet-transcribe b/bin/parakeet-transcribe
new file mode 100755
index 0000000..0a2e83b
--- /dev/null
+++ b/bin/parakeet-transcribe
@@ -0,0 +1,65 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "parakeet-mlx",
+# ]
+# ///
+
+"""Transcribe audio with parakeet-mlx. Outputs JSON."""
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from parakeet_mlx import from_pretrained
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("audio", help="Path to audio file")
+ parser.add_argument("output", help="Path to write JSON transcript")
+ parser.add_argument(
+ "--model",
+ default="mlx-community/parakeet-tdt-0.6b-v3",
+ help="Parakeet model (default: mlx-community/parakeet-tdt-0.6b-v3)",
+ )
+ args = parser.parse_args()
+
+ audio = args.audio
+ if not Path(audio).exists():
+ print(f"Audio file not found: {audio}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Loading model {args.model}...", file=sys.stderr)
+ model = from_pretrained(args.model)
+
+ print(f"Transcribing {audio}...", file=sys.stderr)
+ result = model.transcribe(audio, chunk_duration=120.0, overlap_duration=15.0)
+
+ output = {
+ "text": result.text,
+ "sentences": [
+ {
+ "text": s.text,
+ "start": s.start,
+ "end": s.end,
+ "tokens": [
+ {"text": t.text, "start": t.start, "end": t.end}
+ for t in s.tokens
+ ],
+ }
+ for s in result.sentences
+ ],
+ }
+
+ Path(args.output).write_text(
+ json.dumps(output, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ print(f"Done. Wrote {len(result.sentences)} sentences to {args.output}.", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lib/tasks/transcribers.rake b/lib/tasks/transcribers.rake
index 84e3f5f..c76b38c 100644
--- a/lib/tasks/transcribers.rake
+++ b/lib/tasks/transcribers.rake
@@ -11,8 +11,9 @@ module Transcribers
when "sous_chef" then SousChef.new
when "mlx" then Mlx.new
when "mlx-diarize" then MlxDiarize.new
+ when "parakeet" then Parakeet.new
else
- abort "Unknown transcriber: #{name}. Use 'whisperx', 'whisper-cpp-large', 'whisper-cpp-tdrz', 'sous_chef', 'mlx', or 'mlx-diarize'."
+ abort "Unknown transcriber: #{name}. Use 'whisperx', 'whisper-cpp-large', 'whisper-cpp-tdrz', 'sous_chef', 'mlx', 'mlx-diarize', or 'parakeet'."
end
end
@@ -268,4 +269,43 @@ module Transcribers
speaker ? "[#{timestamp} | #{speaker}] #{text}" : "[#{timestamp}] #{text}"
end
end
+
+ class Parakeet < Base
+ SCRIPT = Pathname("bin/parakeet-transcribe")
+ # Chunking overlap produces negative gaps between sentences, so
+ # gap-based splitting doesn't work. Group by sentence count instead.
+ SENTENCES_PER_PARAGRAPH = 5
+
+ def name = "parakeet"
+ 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))
+ paragraphs = data["sentences"].each_slice(SENTENCES_PER_PARAGRAPH).map do |group|
+ flush_paragraph(group)
+ end
+
+ File.write(txt_path, paragraphs.join("\n\n"))
+ end
+
+ private
+
+ def flush_paragraph(sentences)
+ timestamp = format_time(sentences.first["start"])
+ text = sentences.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
end