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:
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationModels)
|
||||
import FoundationModels
|
||||
#endif
|
||||
|
||||
/// Splits a single free-form phrase (typically dictated, e.g. "two onions, half
|
||||
/// a litre of milk and some olive oil") into individual, normalized ingredient
|
||||
/// lines. Uses Apple's on-device Foundation Models when available for smart
|
||||
/// parsing of quantities and natural-language separators; otherwise falls back
|
||||
/// to a lightweight heuristic splitter. Fully local and never throws.
|
||||
enum IngredientParser {
|
||||
|
||||
/// Parses `text` into clean ingredient lines, kept in `language`.
|
||||
static func parse(_ text: String, language: AppLanguage) async -> [String] {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
#if canImport(FoundationModels)
|
||||
if #available(iOS 26.0, *), case .available = SystemLanguageModel.default.availability {
|
||||
let langName = language.resolved().displayName
|
||||
let instructions = """
|
||||
You split a spoken grocery list into individual ingredients. The user \
|
||||
dictates several ingredients in one sentence, separated by commas, the \
|
||||
word "and", or natural pauses. Output ONE ingredient per line, keeping \
|
||||
any quantity the user actually said (e.g. "2 onions", "500 g flour"). \
|
||||
Do not invent ingredients, do not add quantities that were not said, \
|
||||
do not number or bullet the lines, and add no commentary. Keep the \
|
||||
ingredients written in \(langName).
|
||||
"""
|
||||
do {
|
||||
let session = LanguageModelSession(instructions: instructions)
|
||||
let response = try await session.respond(to: trimmed)
|
||||
let lines = IngredientGenerator.parseLines(response.content)
|
||||
if !lines.isEmpty { return lines }
|
||||
} catch {
|
||||
CrashlyticsService.record(error, context: "ingredient_parsing")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return heuristicSplit(trimmed)
|
||||
}
|
||||
|
||||
/// Splits on newlines, commas, semicolons and standalone conjunctions
|
||||
/// ("and"/"y"/"e"/"et"/"und") when the on-device model is unavailable.
|
||||
static func heuristicSplit(_ text: String) -> [String] {
|
||||
let separators = CharacterSet(charactersIn: ",;\n•")
|
||||
let conjunctions = [" y ", " e ", " and ", " et ", " und "]
|
||||
return text
|
||||
.components(separatedBy: separators)
|
||||
.flatMap { piece -> [String] in
|
||||
var parts = [piece]
|
||||
for conjunction in conjunctions {
|
||||
parts = parts.flatMap { $0.components(separatedBy: conjunction) }
|
||||
}
|
||||
return parts
|
||||
}
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user