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}