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
change qlkwtnmxsttqwkzrvyxpnzworpkxrvoo
commit 10b03c1fd7d6e9ac93fc7b9499a61f877afb5bdb
author Alpha Chen <alpha@kejadlen.dev>
date
parent zxmsoynw
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