Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Loading