Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions OpenVision.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
1D0AEF3C7A810FF410C8F2E5 /* SessionRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06B8E5499FB1BA51EF6F1741 /* SessionRecorder.swift */; };
1EEA4B0E379A76881244FAB9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE51DA812A9394A5064F60E /* SettingsView.swift */; };
223474C328BCC3C81ADCB93D /* OpenAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDF4A693D2CEB248757FBD3 /* OpenAIService.swift */; };
8A6467FB1A9E725FBEF8B262 /* CloudTTSFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D73C5CCFE6382A4B4234D4A /* CloudTTSFallbackTests.swift */; };
2241E9878F41D5D5856C9502 /* HermesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641FA355FB2C0B37F5575730 /* HermesTests.swift */; };
23D894B8F06FA0DBE93C658B /* NeuralSpeech.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82F52D647139EC398E043BF /* NeuralSpeech.swift */; };
2947D6E5D6D5B7080C53D478 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27052ECD2FFC79652B5CDB3 /* OnboardingView.swift */; };
Expand Down Expand Up @@ -178,6 +179,7 @@
60E2210A5A20177647BDB5AD /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
6102B753E2737E698B780C8E /* HermesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesSettingsView.swift; sourceTree = "<group>"; };
6188DD26571BACB30096654D /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = "<group>"; };
5D73C5CCFE6382A4B4234D4A /* CloudTTSFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTTSFallbackTests.swift; sourceTree = "<group>"; };
641FA355FB2C0B37F5575730 /* HermesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesTests.swift; sourceTree = "<group>"; };
669EB2820345D230AB0F9FAC /* MetricsCollector+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetricsCollector+Configuration.swift"; sourceTree = "<group>"; };
6BD412B34D09394FE85F0181 /* NeuralVoiceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeuralVoiceListView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -662,6 +664,7 @@
39552315579536D518404FED /* DocumentChunkingTests.swift */,
B88E1A3CDAF6C7B3D7DF36FE /* FrameChangeTests.swift */,
3A5609ACD2D7FE78F322E775 /* GrokTests.swift */,
5D73C5CCFE6382A4B4234D4A /* CloudTTSFallbackTests.swift */,
641FA355FB2C0B37F5575730 /* HermesTests.swift */,
DD3DFE96152AAE9145F104D6 /* LocalAgentRouteTests.swift */,
BE4D7E31C137C29246EB9281 /* NativeToolSupportTests.swift */,
Expand Down Expand Up @@ -836,6 +839,7 @@
E9628B20FB4654FBBFC61EF9 /* DocumentChunkingTests.swift in Sources */,
9A7811CF026E8DF74F74C2A2 /* FrameChangeTests.swift in Sources */,
41DD47E61F53A95E861466C1 /* GrokTests.swift in Sources */,
8A6467FB1A9E725FBEF8B262 /* CloudTTSFallbackTests.swift in Sources */,
2241E9878F41D5D5856C9502 /* HermesTests.swift in Sources */,
DC6776A7CBFBB8E151FB6B85 /* LocalAgentRouteTests.swift in Sources */,
5F19226406CF5B705B57BACF /* NativeToolSupportTests.swift in Sources */,
Expand Down
143 changes: 133 additions & 10 deletions OpenVision/Services/TTS/CloudTTSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
//
// The engine is provider-agnostic; a CloudVoiceProvider only says whether it can speak, which
// voice is selected, and how to turn one sentence into WAV bytes.
//
// A sentence whose request fails (a 429, a timeout, no network) is spoken in the Apple voice in
// its place in the queue, so the reply has no silent hole. After two failures in a row the rest
// of the reply goes to the Apple voice outright rather than alternating per sentence.

import AVFoundation
import Foundation
Expand Down Expand Up @@ -45,23 +49,38 @@ final class CloudTTSService: ObservableObject {
/// The format the player node is connected with (the first clip's, normally 24 kHz mono).
private var playerFormat: AVAudioFormat?

private init(provider: CloudVoiceProvider) {
/// Speaks failed sentences. Its own synthesizer, not TTSService: the voice agent reads
/// TTSService finishing as the end of the reply.
private let fallback = AppleFallbackSpeaker()
/// Whether the current utterance falls back to the Apple voice (replies yes, voice previews
/// no: a failed preview shows `lastFailure` instead).
private var fallbackEnabled = true
/// Failed sentences in a row; at two, the rest of the reply uses the Apple voice.
private var consecutiveFailures = 0
private var appleForRest = false
/// Test hook: each sentence as it starts playing, and whether it's in the Apple voice.
var onSentenceStarted: ((_ sentence: String, _ appleVoice: Bool) -> Void)?
/// Test hook: how many sentences the Apple voice has actually been asked to speak.
var fallbackUtterances: Int { fallback.utterances }

init(provider: CloudVoiceProvider) {
self.provider = provider
}

// MARK: - Speak

/// Speak a whole reply, replacing anything playing.
func speak(_ text: String) async {
await speak(text, voice: provider.selectedVoice())
await speak(text, voice: provider.selectedVoice(), appleFallback: true)
}

/// Speak in a specific voice — used to preview voices in Settings before choosing one.
func speak(_ text: String, voice: String) async {
func speak(_ text: String, voice: String, appleFallback: Bool = false) async {
let clean = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty, isReady else { return }
stop()
beginStreaming()
fallbackEnabled = appleFallback
for sentence in TextChunking.sentences(clean) { enqueue(sentence, voice: voice) }
endStreaming()
}
Expand All @@ -72,11 +91,12 @@ final class CloudTTSService: ObservableObject {
streamStarted = false
utteranceCancelled = true // in-flight synthesis discards its output
pendingSentences.removeAll()
clipQueue.forEach { $0.cancel() }
clipQueue.forEach { $0.clip.cancel() }
clipQueue.removeAll()
pendingBuffers = 0
playerEpoch += 1
if audioReady { playerNode.stop() }
fallback.stop()
}

// MARK: - Streaming (speak sentences as the model produces them)
Expand All @@ -88,7 +108,7 @@ final class CloudTTSService: ObservableObject {
/// Synthesis tasks in sentence order, at most `maxInFlight` at a time (enough to stay ahead of
/// playback without a 15-sentence reply firing 15 requests and tripping rate limits). ONE
/// drainer awaits them in order, so playback order is structural.
private var clipQueue: [Task<AVAudioPCMBuffer?, Never>] = []
private var clipQueue: [(text: String, clip: Task<AVAudioPCMBuffer?, Never>)] = []
/// Sentences waiting for a synthesis slot.
private var pendingSentences: [(text: String, voice: String)] = []
private let maxInFlight = 3
Expand Down Expand Up @@ -123,6 +143,9 @@ final class CloudTTSService: ObservableObject {
utteranceCancelled = false
generationActive = true
utteranceGeneration += 1
fallbackEnabled = true
consecutiveFailures = 0
appleForRest = false
isSpeaking = true // pauses the recognizer for the whole utterance
}

Expand All @@ -142,7 +165,9 @@ final class CloudTTSService: ObservableObject {
private func startSynthesis() {
while clipQueue.count < maxInFlight, !pendingSentences.isEmpty {
let next = pendingSentences.removeFirst()
clipQueue.append(Task { await self.synthesize(next.text, voice: next.voice) })
// Once the reply has moved to the Apple voice, don't request it from the cloud.
let skip = appleForRest
clipQueue.append((next.text, Task { skip ? nil : await self.synthesize(next.text, voice: next.voice) }))
}
}

Expand All @@ -155,16 +180,24 @@ final class CloudTTSService: ObservableObject {
private func drainIfNeeded() {
guard drainTask == nil else { return }
drainTask = Task { [weak self] in
while let self, let task = self.clipQueue.first {
let clip = await task.value
while let self, let head = self.clipQueue.first {
let clip = await head.clip.value
// stop() (and maybe a new reply) may have replaced the queue while we waited —
// only play the clip if it's still the head of the CURRENT utterance.
guard self.clipQueue.first == task, !self.utteranceCancelled else { continue }
guard self.clipQueue.first?.clip == head.clip, !self.utteranceCancelled else { continue }
self.clipQueue.removeFirst()
let useCloud = clip != nil && !self.appleForRest
// Count the failure before refilling the queue, so a second failure in a row
// keeps the next sentence from being sent to the cloud.
if useCloud { self.consecutiveFailures = 0 } else if self.fallbackEnabled { self.noteFailure() }
self.startSynthesis()
if let clip {
if let clip, useCloud {
self.onSentenceStarted?(head.text, false)
guard !self.utteranceCancelled else { continue }
self.schedule(clip, restartPlayer: !self.streamStarted)
self.streamStarted = true
} else if self.fallbackEnabled {
await self.speakWithAppleVoice(head.text)
}
}
guard let self else { return }
Expand All @@ -173,6 +206,32 @@ final class CloudTTSService: ObservableObject {
}
}

/// Speak a sentence the cloud couldn't synthesize, in its place: after the clips queued before
/// it have played out, and before the next one. The drainer waits here, so the two audio
/// paths never overlap and `isSpeaking` stays true throughout.
private func speakWithAppleVoice(_ sentence: String) async {
// A stop, then a new reply, can happen during the wait: only speak for this one.
let generation = utteranceGeneration
var current: Bool { !utteranceCancelled && utteranceGeneration == generation }
while pendingBuffers > 0 && current {
try? await Task.sleep(for: .milliseconds(30))
}
guard current else { return }
onSentenceStarted?(sentence, true)
guard current else { return }
MetricsCollector.shared.markFirstAudio()
await fallback.speak(sentence)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private func noteFailure() {
consecutiveFailures += 1
guard consecutiveFailures >= 2, !appleForRest else { return }
// Probably offline: stop asking the cloud for the rest of this reply.
NSLog("[%@] two sentences failed in a row, using the Apple voice for the rest", provider.name)
appleForRest = true
clipQueue.forEach { $0.clip.cancel() } // requests already out come back as failures
}

/// Ambient narration (watch loop): speaks only into silence and drops itself if a reply
/// started while it was being synthesized.
func speakAmbient(_ text: String) async {
Expand Down Expand Up @@ -286,6 +345,70 @@ final class CloudTTSService: ObservableObject {

extension CloudTTSService: NeuralSpeechEngine {}

// MARK: - Apple voice fallback

/// Speaks one sentence in the user's Apple voice and returns when it has finished or been stopped.
@MainActor
final class AppleFallbackSpeaker: NSObject, AVSpeechSynthesizerDelegate {
private let synthesizer = AVSpeechSynthesizer()
/// The utterance being spoken and its waiter. A stopped utterance reports didCancel later,
/// so callbacks only finish the utterance they're about.
private var current: (id: ObjectIdentifier, finished: CheckedContinuation<Void, Never>, watchdog: Task<Void, Never>)?
private(set) var utterances = 0

/// How long one sentence may take before we stop waiting for the synthesizer: the drainer
/// waits here with the recognizer paused, so a lost didFinish must not leave it deaf.
nonisolated static func deadline(for sentence: String) -> Duration {
.milliseconds(max(5_000, 120 * sentence.count))
}

override init() {
super.init()
synthesizer.delegate = self
}

func speak(_ sentence: String) async {
stop()
let utterance = AVSpeechUtterance(string: sentence)
utterance.voice = TTSService.userVoice
utterances += 1
let id = ObjectIdentifier(utterance)
await withCheckedContinuation { continuation in

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cloud path bounds its wait with clipTimeout precisely because a blocked drainer means the recognizer stays paused for the rest of the reply. This wait has no bound: if the synthesizer never delivers didFinish/didCancel for the utterance (another AVSpeechSynthesizer instance holding the speech queue, a session interruption mid-utterance), the continuation never resumes, drainTask never clears, isSpeaking stays true and the agent is deaf until a wake word.

Please cap it, e.g. start a Task here that sleeps max(5s, 0.12s × sentence.count) and then calls finish(id) (after stopSpeaking) if that utterance is still current; cancel the task from finish. A log line when it fires would help diagnose a real occurrence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 67f938e, as you suggested. AppleFallbackSpeaker.speak starts a watchdog that sleeps max(5 s, 120 ms × sentence.count). If the utterance is still current when it wakes, it logs "Apple voice never finished a sentence; moving on", calls stopSpeaking, and finishes it. finish cancels the watchdog. A test covers the deadline. I also took the nit: the voice choice is now TTSService.userVoice, used by both.

let watchdog = Task { [weak self] in
try? await Task.sleep(for: Self.deadline(for: sentence))
guard !Task.isCancelled, let self, self.current?.id == id else { return }
NSLog("[CloudTTS] Apple voice never finished a sentence; moving on")
self.synthesizer.stopSpeaking(at: .immediate)
self.finish(id)
}
current = (id, continuation, watchdog)
synthesizer.speak(utterance)
}
}

func stop() {
synthesizer.stopSpeaking(at: .immediate)
if let current { finish(current.id) }
}

private func finish(_ id: ObjectIdentifier) {
guard let current, current.id == id else { return }
self.current = nil
current.watchdog.cancel()
current.finished.resume()
}

nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
let id = ObjectIdentifier(utterance)
Task { @MainActor in self.finish(id) }
}

nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
let id = ObjectIdentifier(utterance)
Task { @MainActor in self.finish(id) }
}
}

// MARK: - Voices

struct CloudVoice: Identifiable, Hashable {
Expand Down
6 changes: 5 additions & 1 deletion OpenVision/Services/TTS/TTSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ final class TTSService: NSObject, ObservableObject {

// MARK: - Voice Selection

private var selectedVoice: AVSpeechSynthesisVoice? {
private var selectedVoice: AVSpeechSynthesisVoice? { Self.userVoice }

/// The Apple voice chosen in Settings, or the default English voice. Also used by the cloud
/// voices' Apple fallback, so the choice lives in one place.
static var userVoice: AVSpeechSynthesisVoice? {
// Check if user has selected a specific voice
if let identifier = SettingsManager.shared.settings.selectedVoiceIdentifier,
let voice = AVSpeechSynthesisVoice(identifier: identifier) {
Expand Down
Loading