New features: - Offline Dictionary: reverse index of 175K verb forms + 200 common words, cached to disk, powers instant word lookups in Stories - Vocab SRS Review: spaced repetition for course vocabulary cards with due count badge and Again/Hard/Good/Easy rating - Cloze Practice: fill-in-the-blank using SentenceQuizEngine with distractor generation from vocabulary pool - Grammar Exercises: interactive quizzes for 5 grammar topics (ser/estar, por/para, preterite/imperfect, subjunctive, personal a) with "Practice This" button on grammar note detail - Listening Practice: listen-and-type + pronunciation check modes using Speech framework with word-by-word match scoring - Conversational Practice: AI chat partner via Foundation Models with 10 scenario types, saved to cloud container Other changes: - Add Conversation model to SharedModels and cloud container - Add Info.plist keys for speech recognition and microphone - Skip speech auth on simulator to prevent crash - Fix preparing data screen to only show during seed/migration - Extract courseDataVersion to static property on DataLoader - Add "How Features Work" reference page in Settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
126 lines
4.2 KiB
Swift
126 lines
4.2 KiB
Swift
import Foundation
|
|
import Speech
|
|
import AVFoundation
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class PronunciationService {
|
|
var isRecording = false
|
|
var transcript = ""
|
|
var isAuthorized = false
|
|
|
|
private var recognizer: SFSpeechRecognizer?
|
|
private var audioEngine: AVAudioEngine?
|
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
|
private var task: SFSpeechRecognitionTask?
|
|
private var recognizerResolved = false
|
|
|
|
func requestAuthorization() {
|
|
// SFSpeechRecognizer.requestAuthorization crashes on simulators
|
|
// without speech services. Check availability first.
|
|
guard SFSpeechRecognizer.self != nil else { return }
|
|
|
|
#if targetEnvironment(simulator)
|
|
print("[PronunciationService] skipping speech auth on simulator")
|
|
isAuthorized = false
|
|
#else
|
|
print("[PronunciationService] requesting speech authorization...")
|
|
SFSpeechRecognizer.requestAuthorization { [weak self] status in
|
|
print("[PronunciationService] authorization status: \(status.rawValue)")
|
|
Task { @MainActor in
|
|
self?.isAuthorized = (status == .authorized)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func resolveRecognizerIfNeeded() {
|
|
guard !recognizerResolved else { return }
|
|
recognizerResolved = true
|
|
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
|
|
}
|
|
|
|
func startRecording() throws {
|
|
guard isAuthorized else { return }
|
|
resolveRecognizerIfNeeded()
|
|
guard let recognizer, recognizer.isAvailable else { return }
|
|
|
|
stopRecording()
|
|
|
|
let audioSession = AVAudioSession.sharedInstance()
|
|
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
|
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
|
|
|
audioEngine = AVAudioEngine()
|
|
request = SFSpeechAudioBufferRecognitionRequest()
|
|
|
|
guard let audioEngine, let request else { return }
|
|
request.shouldReportPartialResults = true
|
|
|
|
let inputNode = audioEngine.inputNode
|
|
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
|
|
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
|
|
request.append(buffer)
|
|
}
|
|
|
|
audioEngine.prepare()
|
|
try audioEngine.start()
|
|
|
|
transcript = ""
|
|
isRecording = true
|
|
|
|
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
|
Task { @MainActor in
|
|
if let result {
|
|
self?.transcript = result.bestTranscription.formattedString
|
|
}
|
|
if error != nil || (result?.isFinal == true) {
|
|
self?.stopRecording()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func stopRecording() {
|
|
audioEngine?.stop()
|
|
audioEngine?.inputNode.removeTap(onBus: 0)
|
|
request?.endAudio()
|
|
task?.cancel()
|
|
task = nil
|
|
request = nil
|
|
audioEngine = nil
|
|
isRecording = false
|
|
}
|
|
|
|
/// Compare spoken transcript against expected text, returns matched word ratio (0.0-1.0).
|
|
static func scoreMatch(expected: String, spoken: String) -> (score: Double, matches: [WordMatch]) {
|
|
let expectedWords = expected.lowercased()
|
|
.components(separatedBy: .whitespacesAndNewlines)
|
|
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
let spokenWords = spoken.lowercased()
|
|
.components(separatedBy: .whitespacesAndNewlines)
|
|
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
let spokenSet = Set(spokenWords)
|
|
var matches: [WordMatch] = []
|
|
|
|
for word in expectedWords {
|
|
matches.append(WordMatch(word: word, matched: spokenSet.contains(word)))
|
|
}
|
|
|
|
let matchCount = matches.filter(\.matched).count
|
|
let score = expectedWords.isEmpty ? 0 : Double(matchCount) / Double(expectedWords.count)
|
|
return (score, matches)
|
|
}
|
|
|
|
struct WordMatch: Identifiable {
|
|
let word: String
|
|
let matched: Bool
|
|
var id: String { word }
|
|
}
|
|
}
|