Files
Spanish/Conjuga/Conjuga/Services/PronunciationService.swift
Trey t a663bc03cd Add 6 new practice features, offline dictionary, and feature reference
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>
2026-04-13 16:12:36 -05:00

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 }
}
}