8e4d0c2d89
- Dictado por voz (Speech/SFSpeechRecognizer, on-device) en el nombre de plato y en ingredientes, con transcripción en vivo. - Un solo texto dictado se separa en ingredientes individuales vía Apple Foundation Models on-device (IngredientParser), con fallback heurístico. - Permisos de micrófono y reconocimiento de voz en Info.plist. - Cadenas de dictado en los 6 idiomas. - iPad/Mac: el calendario reclama ~50% de la altura disponible en lugar de quedar fijo a ~240px; iPhone (compact) sin cambios. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A3HaWmmtTQ1vdTERtSYU6p
124 lines
4.6 KiB
Swift
124 lines
4.6 KiB
Swift
import Foundation
|
|
import Speech
|
|
import AVFoundation
|
|
|
|
/// Live dictation wrapper around `SFSpeechRecognizer` + `AVAudioEngine`. It
|
|
/// publishes an incrementally-updated `transcript` while recording so users can
|
|
/// speak a dish name — or a whole list of ingredients in one go — instead of
|
|
/// typing. Prefers on-device recognition when the locale supports it (private,
|
|
/// offline); otherwise falls back to Apple's server recognition.
|
|
@MainActor
|
|
final class SpeechDictationService: ObservableObject {
|
|
enum State: Equatable {
|
|
case idle
|
|
case recording
|
|
case denied // microphone or speech-recognition permission refused
|
|
case unavailable // no recognizer available for the requested locale
|
|
}
|
|
|
|
@Published private(set) var state: State = .idle
|
|
@Published private(set) var transcript: String = ""
|
|
|
|
private let audioEngine = AVAudioEngine()
|
|
private var recognizer: SFSpeechRecognizer?
|
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
|
private var task: SFSpeechRecognitionTask?
|
|
|
|
var isRecording: Bool { state == .recording }
|
|
|
|
/// Requests permissions and, on success, starts live transcription in the
|
|
/// given locale. Partial results stream into `transcript`.
|
|
func start(localeIdentifier: String) {
|
|
guard state != .recording else { return }
|
|
transcript = ""
|
|
|
|
SFSpeechRecognizer.requestAuthorization { [weak self] speechAuth in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
guard speechAuth == .authorized else { self.state = .denied; return }
|
|
self.requestMicAndStart(localeIdentifier: localeIdentifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestMicAndStart(localeIdentifier: String) {
|
|
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
guard granted else { self.state = .denied; return }
|
|
self.beginSession(localeIdentifier: localeIdentifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func beginSession(localeIdentifier: String) {
|
|
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)),
|
|
recognizer.isAvailable else {
|
|
state = .unavailable
|
|
return
|
|
}
|
|
self.recognizer = recognizer
|
|
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
request.shouldReportPartialResults = true
|
|
if recognizer.supportsOnDeviceRecognition {
|
|
request.requiresOnDeviceRecognition = true
|
|
}
|
|
self.request = request
|
|
|
|
let inputNode = audioEngine.inputNode
|
|
let format = inputNode.outputFormat(forBus: 0)
|
|
inputNode.removeTap(onBus: 0)
|
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
|
self?.request?.append(buffer)
|
|
}
|
|
|
|
audioEngine.prepare()
|
|
try audioEngine.start()
|
|
state = .recording
|
|
|
|
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
if let result {
|
|
self.transcript = result.bestTranscription.formattedString
|
|
}
|
|
if error != nil || (result?.isFinal ?? false) {
|
|
self.teardownAudio()
|
|
if self.state == .recording { self.state = .idle }
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "speech_dictation_start")
|
|
teardownAudio()
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
/// Stops capture, keeping the last transcript, and returns the final text.
|
|
@discardableResult
|
|
func stop() -> String {
|
|
teardownAudio()
|
|
if state == .recording { state = .idle }
|
|
return transcript
|
|
}
|
|
|
|
private func teardownAudio() {
|
|
if audioEngine.isRunning {
|
|
audioEngine.stop()
|
|
audioEngine.inputNode.removeTap(onBus: 0)
|
|
}
|
|
request?.endAudio()
|
|
task?.cancel()
|
|
request = nil
|
|
task = nil
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
}
|