krz/daybyday

iOS app for children to learn days of the week, months, and seasons with audio assist.

clone: git clone https://gitbay.org/krz/daybyday.git

v1.3.2: DayByDay/SpeechSynthesizer.swift · raw

 1//
 2//  SpeechSynthesizer.swift
 3//  DayByDay
 4//
 5
 6import AVFoundation
 7
 8/// Wrapper around AVSpeechSynthesizer that selects the highest-quality
 9/// en-US neural voice available on the device. Falls back gracefully if
10/// premium/enhanced voices haven't been downloaded yet.
11final class SpeechSynthesizer {
12    static let shared = SpeechSynthesizer()
13
14    private let synthesizer = AVSpeechSynthesizer()
15    private let voice: AVSpeechSynthesisVoice?
16
17    private init() {
18        self.voice = Self.bestAvailableVoice()
19    }
20
21    /// Speaks a pre-configured utterance directly. Used for warmup at launch.
22    func speakUtterance(_ utterance: AVSpeechUtterance) {
23        synthesizer.speak(utterance)
24    }
25
26    func speak(_ text: String) {
27        // Ensure speech plays through the speaker even when the silent switch is on.
28        try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
29        try? AVAudioSession.sharedInstance().setActive(true)
30
31        if synthesizer.isSpeaking {
32            synthesizer.stopSpeaking(at: .immediate)
33        }
34
35        let utterance = AVSpeechUtterance(string: text)
36        utterance.voice = voice
37        utterance.rate = 0.4
38        utterance.pitchMultiplier = 1.0
39        utterance.preUtteranceDelay = 0
40        synthesizer.speak(utterance)
41    }
42
43    /// Picks the best en-US voice on the device.
44    /// Filters for en-US, excludes novelty voices, then sorts by quality
45    /// descending so premium (3) > enhanced (2) > default (1).
46    private static func bestAvailableVoice() -> AVSpeechSynthesisVoice? {
47        let best = AVSpeechSynthesisVoice.speechVoices()
48            .filter { voice in
49                voice.language.hasPrefix("en-US")
50                && !voice.voiceTraits.contains(.isNoveltyVoice)
51            }
52            .sorted { $0.quality.rawValue > $1.quality.rawValue }
53            .first
54
55        let selected = best ?? AVSpeechSynthesisVoice(language: "en-US")
56        print("[DayByDay] Selected voice: \(selected?.name ?? "nil"), quality: \(selected?.quality.rawValue ?? -1)")
57        return selected
58    }
59}