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}