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