summaryrefslogtreecommitdiff
path: root/DayByDay/SpeechSynthesizer.swift
blob: cac5caf658c98a81d4397ac36b60f4298982b675 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//
//  SpeechSynthesizer.swift
//  DayByDay
//

import AVFoundation

/// Wrapper around AVSpeechSynthesizer that selects the highest-quality
/// en-US neural voice available on the device. Falls back gracefully if
/// premium/enhanced voices haven't been downloaded yet.
final class SpeechSynthesizer {
    static let shared = SpeechSynthesizer()

    private let synthesizer = AVSpeechSynthesizer()
    private let voice: AVSpeechSynthesisVoice?

    private init() {
        self.voice = Self.bestAvailableVoice()
    }

    func speak(_ text: String) {
        if synthesizer.isSpeaking {
            synthesizer.stopSpeaking(at: .immediate)
        }

        let utterance = AVSpeechUtterance(string: text)
        utterance.voice = voice
        utterance.rate = 0.4
        utterance.pitchMultiplier = 1.0
        utterance.preUtteranceDelay = 0
        synthesizer.speak(utterance)
    }

    /// Picks the best en-US voice on the device.
    /// Preference order: premium > enhanced > default quality.
    /// Skips novelty voices so the child always hears a natural-sounding voice.
    private static func bestAvailableVoice() -> AVSpeechSynthesisVoice? {
        let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in
            voice.language.hasPrefix("en-US")
            && !voice.voiceTraits.contains(.isNoveltyVoice)
        }

        if let premium = candidates.first(where: { $0.quality == .premium }) {
            return premium
        }
        if let enhanced = candidates.first(where: { $0.quality == .enhanced }) {
            return enhanced
        }
        // Fall back to the best default-quality voice.
        return candidates.first ?? AVSpeechSynthesisVoice(language: "en-US")
    }
}