Initial commit of current state
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// Transcribe a WAV file with macOS 26's SpeechAnalyzer.
|
||||
//
|
||||
// The accurate recogniser on this OS — SpeechTranscriber, driven by
|
||||
// SpeechAnalyzer — is a Swift-only API built on actors and AsyncSequence, so
|
||||
// pyobjc cannot reach it. This is the smallest thing that can: read a file,
|
||||
// print JSON, exit. bot.py runs it as a subprocess.
|
||||
//
|
||||
// Why bother, versus the SFSpeechRecognizer path in apple_stt.py:
|
||||
// - Roughly four times more accurate on published benchmarks.
|
||||
// - No session length limit, which is what forces the transcript-stitching
|
||||
// workaround in apple_stt.py.
|
||||
// - Still supports vocabulary biasing, via AnalysisContext on the analyzer
|
||||
// rather than on the request.
|
||||
// - Returns ranked alternatives, which measurably help LLM post-correction.
|
||||
//
|
||||
// Usage:
|
||||
// speech-helper <file.wav> [--locale en-US] [--terms a,b,c] [--alternatives]
|
||||
// speech-helper --check [--locale en-US]
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
struct Options {
|
||||
var path: String?
|
||||
var locale = "en-US"
|
||||
var terms: [String] = []
|
||||
var alternatives = false
|
||||
var check = false
|
||||
var dictation = false
|
||||
}
|
||||
|
||||
func parseArguments() -> Options {
|
||||
var options = Options()
|
||||
var arguments = Array(CommandLine.arguments.dropFirst())
|
||||
while let argument = arguments.first {
|
||||
arguments.removeFirst()
|
||||
switch argument {
|
||||
case "--locale":
|
||||
options.locale = arguments.isEmpty ? options.locale : arguments.removeFirst()
|
||||
case "--terms":
|
||||
let raw = arguments.isEmpty ? "" : arguments.removeFirst()
|
||||
options.terms = raw.split(separator: ",").map {
|
||||
$0.trimmingCharacters(in: .whitespaces)
|
||||
}.filter { !$0.isEmpty }
|
||||
case "--alternatives":
|
||||
options.alternatives = true
|
||||
case "--dictation":
|
||||
options.dictation = true
|
||||
case "--check":
|
||||
options.check = true
|
||||
default:
|
||||
if options.path == nil { options.path = argument }
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func emit(_ payload: [String: Any]) {
|
||||
let data = try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
||||
}
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
emit(["error": message])
|
||||
exit(1)
|
||||
}
|
||||
|
||||
/// Download the on-device model if this locale hasn't been used before.
|
||||
///
|
||||
/// The first run for a locale has to fetch assets; later runs return
|
||||
/// immediately. Without this the analyzer fails rather than installing them.
|
||||
func ensureModel(for module: any SpeechModule) async throws {
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [module]) {
|
||||
try await request.downloadAndInstall()
|
||||
}
|
||||
}
|
||||
|
||||
func transcribe(_ options: Options) async throws {
|
||||
guard let path = options.path else { fail("no audio file given") }
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard FileManager.default.fileExists(atPath: path) else { fail("no such file: \(path)") }
|
||||
|
||||
let locale = Locale(identifier: options.locale)
|
||||
|
||||
// SpeechTranscriber has the better acoustic model but ignores
|
||||
// AnalysisContext.contextualStrings; DictationTranscriber is the older
|
||||
// model and does consume them. Which trade wins depends on the speech.
|
||||
let module: any SpeechModule = options.dictation
|
||||
? DictationTranscriber(locale: locale, preset: .shortDictation)
|
||||
: SpeechTranscriber(
|
||||
locale: locale,
|
||||
preset: options.alternatives ? .transcriptionWithAlternatives : .transcription
|
||||
)
|
||||
try await ensureModel(for: module)
|
||||
|
||||
// Vocabulary biasing lives on the analyzer's context here, not on the
|
||||
// recognition request as it did in the old API — so the accurate model and
|
||||
// term biasing can be used together.
|
||||
let context = AnalysisContext()
|
||||
if !options.terms.isEmpty {
|
||||
context.contextualStrings = [.general: options.terms]
|
||||
}
|
||||
|
||||
let file = try AVAudioFile(forReading: url)
|
||||
let analyzer = try await SpeechAnalyzer(
|
||||
inputAudioFile: file,
|
||||
modules: [module],
|
||||
analysisContext: context,
|
||||
finishAfterFile: true
|
||||
)
|
||||
|
||||
// Results arrive per utterance and are concatenated; unlike the old API
|
||||
// there is no restart to stitch around.
|
||||
var pieces: [String] = []
|
||||
var alternatives: [String] = []
|
||||
if let dictationModule = module as? DictationTranscriber {
|
||||
for try await result in dictationModule.results {
|
||||
let text = String(result.text.characters)
|
||||
if !text.isEmpty { pieces.append(text) }
|
||||
}
|
||||
} else if let speechModule = module as? SpeechTranscriber {
|
||||
for try await result in speechModule.results {
|
||||
let text = String(result.text.characters)
|
||||
if !text.isEmpty { pieces.append(text) }
|
||||
if options.alternatives {
|
||||
alternatives.append(contentsOf: result.alternatives.map { String($0.characters) })
|
||||
}
|
||||
}
|
||||
}
|
||||
try await analyzer.finalizeAndFinishThroughEndOfInput()
|
||||
|
||||
var payload: [String: Any] = [
|
||||
"text": pieces.joined(separator: " ").trimmingCharacters(in: .whitespaces)
|
||||
]
|
||||
if options.alternatives { payload["alternatives"] = alternatives }
|
||||
emit(payload)
|
||||
}
|
||||
|
||||
/// Report whether this machine can run the model, without transcribing.
|
||||
func check(_ options: Options) async throws {
|
||||
let locale = Locale(identifier: options.locale)
|
||||
let supported = await SpeechTranscriber.supportedLocales.contains {
|
||||
$0.identifier(.bcp47) == locale.identifier(.bcp47)
|
||||
}
|
||||
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
||||
let status = await AssetInventory.status(forModules: [transcriber])
|
||||
emit([
|
||||
"available": supported,
|
||||
"locale": options.locale,
|
||||
"assets": String(describing: status),
|
||||
])
|
||||
}
|
||||
|
||||
@main
|
||||
struct SpeechHelper {
|
||||
static func main() async {
|
||||
let options = parseArguments()
|
||||
do {
|
||||
if options.check {
|
||||
try await check(options)
|
||||
} else {
|
||||
try await transcribe(options)
|
||||
}
|
||||
} catch {
|
||||
fail(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user