Add timestamps to sous_chef output and enforce register
Switch to timeIndexedTranscriptionWithAlternatives preset so
each segment carries its audio time range.

Assisted-by: Claude Opus 4.6 via pi
change qrqwwuwvwsyvtrwuormukzzprlqykrvx
commit 859ffce0169c1ba1292602cd6f000a3e94f54d6c
author Alpha Chen <alpha@kejadlen.dev>
date
parent qsqltxzt
diff --git a/sous_chef/Sources/SousChef.swift b/sous_chef/Sources/SousChef.swift
index 91b7b21..2484017 100644
--- a/sous_chef/Sources/SousChef.swift
+++ b/sous_chef/Sources/SousChef.swift
@@ -23,7 +23,7 @@ struct SousChef: AsyncParsableCommand {
         guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: "en-US")) else {
             throw TranscriptionError.unsupportedLocale
         }
-        let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
+        let transcriber = SpeechTranscriber(locale: locale, preset: .timeIndexedTranscriptionWithAlternatives)
 
         // Install assets if needed.
         if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
@@ -39,13 +39,15 @@ struct SousChef: AsyncParsableCommand {
 
         // Collect results in a separate task.
         let resultsTask = Task {
-            var segments: [String] = []
+            var lines: [String] = []
             for try await result in transcriber.results {
                 let text = String(result.text.characters)
-                segments.append(text)
-                log("  \(text)")
+                let timestamp = Self.formatTimestamp(result.text)
+                let line = "[\(timestamp)] \(text)"
+                lines.append(line)
+                log("  \(line)")
             }
-            return segments
+            return lines
         }
 
         // Run the analysis.
@@ -59,13 +61,29 @@ struct SousChef: AsyncParsableCommand {
             await analyzer.cancelAndFinishNow()
         }
 
-        let segments = try await resultsTask.value
+        let lines = try await resultsTask.value
 
-        // Write plain text output.
-        let text = segments.joined(separator: "\n")
+        // Write timestamped transcript.
+        let text = lines.joined(separator: "\n")
         try text.write(to: outputURL, atomically: true, encoding: .utf8)
 
-        log("Done. Wrote \(segments.count) segments to \(output).")
+        log("Done. Wrote \(lines.count) segments to \(output).")
+    }
+
+    /// Extract the start time from the first audioTimeRange attribute in the result text.
+    private static func formatTimestamp(_ text: AttributedString) -> String {
+        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)
+            }
+        }
+        return "?:??"
     }
 
     private func log(_ message: String) {
diff --git a/transcribers.rake b/transcribers.rake
index 916de2d..aca6a8a 100644
--- a/transcribers.rake
+++ b/transcribers.rake
@@ -22,11 +22,29 @@ module Transcribers
     include Rake::DSL
 
     def prereqs = []
+
+    def register
+      raise NotImplementedError, "#{self.class}#register not implemented"
+    end
+  end
+
+  class Whisperx < Base
+    def name = "whisperx"
     def register; end
 
-    private
+    def call(audio_path, transcript_path)
+      hf_token = ENV.fetch("HUGGING_FACE_TOKEN") { abort "Set HUGGING_FACE_TOKEN for diarization." }
+      sh "uvx", "whisperx", audio_path,
+        "--model", "large-v3",
+        "--compute_type", "int8",
+        "--device", "cpu",
+        "--diarize", "--hf_token", hf_token,
+        "--output_dir", File.dirname(transcript_path),
+        "--output_format", "txt"
+    end
+  end
 
-    # Shared setup for whisper.cpp model downloads.
+  class WhisperCpp < Base
     def register_model(name)
       models_dir = MODELS_DIR
       script = DOWNLOAD_SCRIPT
@@ -45,23 +63,9 @@ module Transcribers
     end
   end
 
-  class Whisperx < Base
-    def name = "whisperx"
-
-    def call(audio_path, transcript_path)
-      hf_token = ENV.fetch("HUGGING_FACE_TOKEN") { abort "Set HUGGING_FACE_TOKEN for diarization." }
-      sh "whisperx", audio_path,
-        "--model", "large-v3",
-        "--compute_type", "int8",
-        "--device", "cpu",
-        "--diarize", "--hf_token", hf_token,
-        "--output_dir", File.dirname(transcript_path),
-        "--output_format", "txt"
-    end
-  end
-
-  class WhisperCppLarge < Base
-    MODEL = "large-v3-turbo"
+  class WhisperCppLarge < WhisperCpp
+    # MODEL = "large-v3-turbo"
+    MODEL = "large-v3"
 
     def name = "whisper-cpp-large"
     def prereqs = [Transcribers.model_path(MODEL).to_s]
@@ -79,7 +83,7 @@ module Transcribers
     end
   end
 
-  class WhisperCppTdrz < Base
+  class WhisperCppTdrz < WhisperCpp
     MODEL = "small.en-tdrz"
 
     def name = "whisper-cpp-tdrz"
@@ -106,7 +110,8 @@ module Transcribers
     def prereqs = [BINARY.to_s]
 
     def register
-      file BINARY.to_s do
+      sources = FileList["sous_chef/**/*.swift"].exclude(%r{/\.build/})
+      file BINARY.to_s => sources do
         sh "cd sous_chef && swift build -c release"
       end
     end