2.0: dictado de platos/ingredientes + calendario más alto en iPad/Mac

- 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
This commit is contained in:
alexandrev-tibco
2026-07-22 18:16:51 +02:00
parent cbcccd66ed
commit 8e4d0c2d89
14 changed files with 442 additions and 52 deletions
@@ -0,0 +1,123 @@
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)
}
}