Scaffold the sous_chef Swift package
Command-line tool using SpeechAnalyzer to transcribe audio files.
The Rakefile now builds it automatically and wires it into the
transcript file tasks.

Assisted-by: Claude Opus 4.6 via pi
change ouwqpmxlquwlmuvqluwmxtyyqrqtlvpp
commit fbcc23e09225edc1431ca3703f1b27bc1dad7b55
author Alpha Chen <alpha@kejadlen.dev>
date
parent mruquqzm
diff --git a/Rakefile b/Rakefile
index bd1ee88..87165a2 100644
--- a/Rakefile
+++ b/Rakefile
@@ -7,6 +7,7 @@ require_relative "lib/download"
 CACHE_DIR = "cache"
 AUDIO_DIR = "audio"
 TRANSCRIPTS_DIR = "transcripts"
+SOUS_CHEF = "sous_chef/.build/release/sous_chef"
 HRN_FEED = File.join(CACHE_DIR, "hrn_feed.xml")
 HRN_FEED_URL = "https://rss.art19.com/cooking-issues"
 
@@ -23,6 +24,10 @@ file HRN_FEED => CACHE_DIR do
   File.write(HRN_FEED, response.body)
 end
 
+file SOUS_CHEF do
+  sh "cd sous_chef && swift build -c release"
+end
+
 Rake::Task[HRN_FEED].invoke
 
 EPISODES = CookingIssues::Feed.parse(HRN_FEED)
@@ -33,9 +38,8 @@ EPISODES.values.each do |ep|
     CookingIssues::Download.fetch(ep.audio_url, ep.audio_path)
   end
 
-  file ep.transcript_path => [ep.audio_path, TRANSCRIPTS_DIR] do
-    puts "Transcribing #{ep.number}. #{ep.title}..."
-    puts "  TODO: sous_chef #{ep.audio_path} #{ep.transcript_path}"
+  file ep.transcript_path => [ep.audio_path, TRANSCRIPTS_DIR, SOUS_CHEF] do
+    sh SOUS_CHEF, ep.audio_path, ep.transcript_path
   end
 end
 
diff --git a/sous_chef/Package.swift b/sous_chef/Package.swift
new file mode 100644
index 0000000..c3bd651
--- /dev/null
+++ b/sous_chef/Package.swift
@@ -0,0 +1,19 @@
+// swift-tools-version: 6.1
+
+import PackageDescription
+
+let package = Package(
+    name: "sous_chef",
+    platforms: [.macOS(.v26)],
+    dependencies: [
+        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
+    ],
+    targets: [
+        .executableTarget(
+            name: "sous_chef",
+            dependencies: [
+                .product(name: "ArgumentParser", package: "swift-argument-parser"),
+            ]
+        ),
+    ]
+)
diff --git a/sous_chef/Sources/SousChef.swift b/sous_chef/Sources/SousChef.swift
new file mode 100644
index 0000000..8c75d7f
--- /dev/null
+++ b/sous_chef/Sources/SousChef.swift
@@ -0,0 +1,118 @@
+import ArgumentParser
+import AVFoundation
+import Foundation
+import Speech
+
+@main
+struct SousChef: AsyncParsableCommand {
+    static let configuration = CommandConfiguration(
+        abstract: "Transcribe a podcast audio file using SpeechAnalyzer."
+    )
+
+    @Argument(help: "Path to the input audio file.")
+    var input: String
+
+    @Argument(help: "Path to write the output JSON transcript.")
+    var output: String
+
+    func run() async throws {
+        let inputURL = URL(fileURLWithPath: input)
+        let outputURL = URL(fileURLWithPath: output)
+
+        // Set up the transcriber.
+        guard let locale = SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: "en-US")) else {
+            throw TranscriptionError.unsupportedLocale
+        }
+        let transcriber = SpeechTranscriber(locale: locale, preset: .timeIndexedTranscriptionWithAlternatives)
+
+        // Install assets if needed.
+        if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
+            log("Downloading assets...")
+            try await request.downloadAndInstall()
+        }
+
+        // Open the audio file.
+        let audioFile = try AVAudioFile(forReading: inputURL)
+
+        // Create the analyzer.
+        let analyzer = SpeechAnalyzer(modules: [transcriber])
+
+        // Collect results in a separate task.
+        var segments: [Segment] = []
+        let resultsTask = Task {
+            var collected: [Segment] = []
+            for try await result in transcriber.results {
+                let text = String(result.text.characters)
+                let timeRange = extractTimeRange(from: result.text)
+                collected.append(Segment(text: text, start: timeRange?.start, end: timeRange?.end))
+                log("  \(text)")
+            }
+            return collected
+        }
+
+        // Run the analysis.
+        log("Transcribing \(inputURL.lastPathComponent)...")
+        let lastSampleTime = try await analyzer.analyzeSequence(from: audioFile)
+
+        // Finalize.
+        if let lastSampleTime {
+            try await analyzer.finalizeAndFinish(through: lastSampleTime)
+        } else {
+            try analyzer.cancelAndFinishNow()
+        }
+
+        segments = try await resultsTask.value
+
+        // Write JSON output.
+        let transcript = Transcript(segments: segments)
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        let data = try encoder.encode(transcript)
+        try data.write(to: outputURL)
+
+        log("Done. Wrote \(segments.count) segments to \(output).")
+    }
+
+    private func extractTimeRange(from text: AttributedString) -> (start: Double, end: Double)? {
+        // Walk the attributed string looking for time range attributes.
+        var earliest: Double?
+        var latest: Double?
+
+        for run in text.runs {
+            if let timeRange = run.audioTimeRange {
+                let start = CMTimeGetSeconds(timeRange.lowerBound)
+                let end = CMTimeGetSeconds(timeRange.upperBound)
+                if earliest == nil || start < earliest! { earliest = start }
+                if latest == nil || end > latest! { latest = end }
+            }
+        }
+
+        guard let start = earliest, let end = latest else { return nil }
+        return (start, end)
+    }
+
+    private func log(_ message: String) {
+        FileHandle.standardError.write(Data((message + "\n").utf8))
+    }
+}
+
+enum TranscriptionError: Error, CustomStringConvertible {
+    case unsupportedLocale
+
+    var description: String {
+        switch self {
+        case .unsupportedLocale:
+            "en-US is not supported on this device."
+        }
+    }
+}
+
+struct Transcript: Encodable {
+    let segments: [Segment]
+}
+
+struct Segment: Encodable {
+    let text: String
+    let start: Double?
+    let end: Double?
+}