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
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?
+}