Skip to content

Commit aa36c48

Browse files
srv-adminsrv-admin
authored andcommitted
add text-to-speech for assistant replies
Speaking a reply is chunked by sentence so playback starts after the first chunk instead of waiting on synthesis of the whole message, and so a reply that is still streaming can be spoken as it arrives. Chunks are cached on disk by content, keyed on model and voice, so replaying costs nothing. Code blocks are skipped and markdown markers stripped before synthesis, and an unclosed fence holds back the tail until the stream closes it.
1 parent 79f328c commit aa36c48

8 files changed

Lines changed: 610 additions & 5 deletions

File tree

cai/cai.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
BB000001000000000000BC05 /* atomic_stubs.S in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB05 /* atomic_stubs.S */; };
2020
BB000001000000000000BC06 /* compat_shims.c in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB06 /* compat_shims.c */; settings = {COMPILER_FLAGS = "-fno-builtin"; }; };
2121
BB000001000000000000BC20 /* OpenRouterAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB20 /* OpenRouterAPI.swift */; };
22+
BB000001000000000000BC44 /* TTSAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB44 /* TTSAPI.swift */; };
2223
BB000001000000000000BC29 /* SSHClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB29 /* SSHClient.swift */; };
2324
BB000001000000000000BC21 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB21 /* ChatMessage.swift */; };
2425
BB000001000000000000BC22 /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB22 /* Conversation.swift */; };
2526
BB000001000000000000BC23 /* ConversationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB23 /* ConversationStore.swift */; };
2627
BB000001000000000000BC24 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB24 /* Settings.swift */; };
28+
BB000001000000000000BC45 /* TTSPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB45 /* TTSPlayer.swift */; };
2729
BB000001000000000000BC30 /* RemoteHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB30 /* RemoteHost.swift */; };
2830
BB000001000000000000BC31 /* RemoteHostStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB31 /* RemoteHostStore.swift */; };
2931
BB000001000000000000BC32 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BB32 /* Keychain.swift */; };
@@ -71,11 +73,13 @@
7173
BB000001000000000000BB05 /* atomic_stubs.S */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.asm; path = atomic_stubs.S; sourceTree = "<group>"; };
7274
BB000001000000000000BB06 /* compat_shims.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = compat_shims.c; sourceTree = "<group>"; };
7375
BB000001000000000000BB20 /* OpenRouterAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRouterAPI.swift; sourceTree = "<group>"; };
76+
BB000001000000000000BB44 /* TTSAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSAPI.swift; sourceTree = "<group>"; };
7477
BB000001000000000000BB29 /* SSHClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHClient.swift; sourceTree = "<group>"; };
7578
BB000001000000000000BB21 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
7679
BB000001000000000000BB22 /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = "<group>"; };
7780
BB000001000000000000BB23 /* ConversationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationStore.swift; sourceTree = "<group>"; };
7881
BB000001000000000000BB24 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
82+
BB000001000000000000BB45 /* TTSPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSPlayer.swift; sourceTree = "<group>"; };
7983
BB000001000000000000BB30 /* RemoteHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHost.swift; sourceTree = "<group>"; };
8084
BB000001000000000000BB31 /* RemoteHostStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHostStore.swift; sourceTree = "<group>"; };
8185
BB000001000000000000BB32 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
@@ -170,6 +174,7 @@
170174
isa = PBXGroup;
171175
children = (
172176
BB000001000000000000BB20 /* OpenRouterAPI.swift */,
177+
BB000001000000000000BB44 /* TTSAPI.swift */,
173178
BB000001000000000000BB29 /* SSHClient.swift */,
174179
BB000001000000000000BB02 /* CurlFetcher.swift */,
175180
BB000001000000000000BB03 /* curl_bridge.c */,
@@ -188,6 +193,7 @@
188193
BB000001000000000000BB22 /* Conversation.swift */,
189194
BB000001000000000000BB23 /* ConversationStore.swift */,
190195
BB000001000000000000BB24 /* Settings.swift */,
196+
BB000001000000000000BB45 /* TTSPlayer.swift */,
191197
BB000001000000000000BB30 /* RemoteHost.swift */,
192198
BB000001000000000000BB31 /* RemoteHostStore.swift */,
193199
BB000001000000000000BB32 /* Keychain.swift */,
@@ -357,11 +363,13 @@
357363
BB000001000000000000BC05 /* atomic_stubs.S in Sources */,
358364
BB000001000000000000BC06 /* compat_shims.c in Sources */,
359365
BB000001000000000000BC20 /* OpenRouterAPI.swift in Sources */,
366+
BB000001000000000000BC44 /* TTSAPI.swift in Sources */,
360367
BB000001000000000000BC29 /* SSHClient.swift in Sources */,
361368
BB000001000000000000BC21 /* ChatMessage.swift in Sources */,
362369
BB000001000000000000BC22 /* Conversation.swift in Sources */,
363370
BB000001000000000000BC23 /* ConversationStore.swift in Sources */,
364371
BB000001000000000000BC24 /* Settings.swift in Sources */,
372+
BB000001000000000000BC45 /* TTSPlayer.swift in Sources */,
365373
BB000001000000000000BC30 /* RemoteHost.swift in Sources */,
366374
BB000001000000000000BC31 /* RemoteHostStore.swift in Sources */,
367375
BB000001000000000000BC32 /* Keychain.swift in Sources */,

cai/cai/Models/Settings.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ enum Settings {
55
private static let keyAPIKey = "openrouter_api_key"
66
private static let keyDefaultModel = "default_model"
77
private static let keySystemPrompt = "system_prompt"
8+
private static let keyTTSModel = "tts_model"
9+
private static let keyTTSVoice = "tts_voice"
810

911
// A free model so users aren't billed by default. Editable in Settings.
1012
static let fallbackModel = "openrouter/free"
1113

14+
// Speech models do NOT appear in /api/v1/models, so the slug can't be discovered at
15+
// runtime and these defaults will go stale. Both are editable in Settings.
16+
// Confirmed working on device; ":free" so speaking a reply isn't billed by default.
17+
static let fallbackTTSModel = "deepgram/flux-tts:free"
18+
static let fallbackTTSVoice = "flux-bree-en"
19+
1220
static var apiKey: String {
1321
get { UserDefaults.standard.string(forKey: keyAPIKey) ?? "" }
1422
set {
@@ -37,6 +45,30 @@ enum Settings {
3745
}
3846
}
3947

48+
// Model used to synthesize spoken replies.
49+
static var ttsModel: String {
50+
get {
51+
let m = UserDefaults.standard.string(forKey: keyTTSModel) ?? ""
52+
return m.isEmpty ? fallbackTTSModel : m
53+
}
54+
set {
55+
UserDefaults.standard.set(newValue, forKey: keyTTSModel)
56+
UserDefaults.standard.synchronize()
57+
}
58+
}
59+
60+
// Voice name passed to the speech endpoint (provider-specific, e.g. "alloy").
61+
static var ttsVoice: String {
62+
get {
63+
let v = UserDefaults.standard.string(forKey: keyTTSVoice) ?? ""
64+
return v.isEmpty ? fallbackTTSVoice : v
65+
}
66+
set {
67+
UserDefaults.standard.set(newValue, forKey: keyTTSVoice)
68+
UserDefaults.standard.synchronize()
69+
}
70+
}
71+
4072
static var hasAPIKey: Bool {
4173
return !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
4274
}

cai/cai/Models/TTSPlayer.swift

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import Foundation
2+
import AVFoundation
3+
4+
// Speaks an assistant reply out loud, one sentence-sized chunk at a time.
5+
//
6+
// Chunking matters for two reasons: synthesis latency scales with input length (a whole
7+
// reply would take many seconds before the first sound), and a reply that is still
8+
// streaming has no "whole" to send yet. So text is cut into chunks, each chunk is
9+
// synthesized to an MP3 while the previous one plays, and `append` lets the caller keep
10+
// feeding text as tokens arrive.
11+
//
12+
// Everything here is main-thread only: CurlFetcher delivers its completion on the main
13+
// queue and AVAudioPlayer's delegate callbacks also land there, so no locking is needed.
14+
final class TTSPlayer: NSObject, AVAudioPlayerDelegate {
15+
static let shared = TTSPlayer()
16+
17+
// Don't synthesize a chunk shorter than this unless the text is finished — a 3-word
18+
// request costs a full round trip and sounds clipped.
19+
private static let minChunk = 80
20+
// Hard ceiling so a wall of text with no punctuation still gets spoken promptly.
21+
private static let maxChunk = 500
22+
// How many synthesized chunks to keep queued ahead of the one playing.
23+
private static let readyAhead = 2
24+
25+
// Called whenever the speaking message changes, so the UI can retitle its buttons.
26+
var onStateChange: (() -> Void)?
27+
var onError: ((String) -> Void)?
28+
29+
// Identifies the message being spoken ("<conversationID>:<row>"); nil = idle.
30+
private(set) var speakingKey: String?
31+
32+
private var full: [Character] = []
33+
private var cursor = 0 // how much of `full` has been cut into chunks
34+
private var pending: [String] = [] // cut, not yet synthesized
35+
private var ready: [String] = [] // synthesized file paths, not yet played
36+
private var isSynthesizing = false
37+
private var streamComplete = false
38+
private var player: AVAudioPlayer?
39+
40+
// Bumped by stop(); in-flight synthesis callbacks compare against it and bail out.
41+
private var generation = 0
42+
43+
private static let cacheDir: String = {
44+
let docs = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
45+
let dir = (docs as NSString).appendingPathComponent("tts")
46+
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: nil)
47+
return dir
48+
}()
49+
50+
private override init() { super.init() }
51+
52+
// MARK: - Public API
53+
54+
func isSpeaking(key: String) -> Bool {
55+
return speakingKey == key
56+
}
57+
58+
// Starts speaking `text`. If it belongs to a reply that is still streaming, pass
59+
// isComplete: false and keep calling append() as more text arrives.
60+
func speak(key: String, text: String, isComplete: Bool) {
61+
stop()
62+
speakingKey = key
63+
full = Array(text)
64+
streamComplete = isComplete
65+
activateSession()
66+
drain()
67+
pump()
68+
onStateChange?()
69+
}
70+
71+
// Feeds the latest full text of the message being spoken. Ignored for other messages.
72+
func append(key: String, fullText: String) {
73+
guard speakingKey == key, !streamComplete else { return }
74+
full = Array(fullText)
75+
drain()
76+
pump()
77+
}
78+
79+
// Marks the streaming reply as finished; flushes whatever tail is left.
80+
func finish(key: String) {
81+
guard speakingKey == key else { return }
82+
streamComplete = true
83+
drain()
84+
pump()
85+
}
86+
87+
func stop() {
88+
generation += 1
89+
player?.stop()
90+
player = nil
91+
speakingKey = nil
92+
full = []
93+
cursor = 0
94+
pending = []
95+
ready = []
96+
isSynthesizing = false
97+
streamComplete = false
98+
onStateChange?()
99+
}
100+
101+
// MARK: - Chunking
102+
103+
// Cuts as much of the un-chunked tail of `full` into speakable chunks as it can.
104+
private func drain() {
105+
while cursor < full.count {
106+
let remaining = full.count - cursor
107+
var end = -1
108+
109+
// Prefer a sentence boundary at least minChunk in.
110+
var i = cursor + TTSPlayer.minChunk
111+
while i < full.count && i < cursor + TTSPlayer.maxChunk {
112+
let c = full[i]
113+
if c == "." || c == "!" || c == "?" || c == "\n" {
114+
end = i
115+
break
116+
}
117+
i += 1
118+
}
119+
120+
if end < 0 && remaining >= TTSPlayer.maxChunk {
121+
// No punctuation in range — break on the last space instead of mid-word.
122+
var j = cursor + TTSPlayer.maxChunk - 1
123+
while j > cursor + TTSPlayer.minChunk && full[j] != " " { j -= 1 }
124+
end = j
125+
}
126+
127+
if end < 0 {
128+
// Not enough text yet. Once the stream is done, speak the tail as-is.
129+
if streamComplete && remaining > 0 { end = full.count - 1 } else { return }
130+
}
131+
132+
let chunk = String(full[cursor...end]).trimmingCharacters(in: .whitespacesAndNewlines)
133+
cursor = end + 1
134+
if !chunk.isEmpty { pending.append(chunk) }
135+
}
136+
}
137+
138+
// MARK: - Pipeline
139+
140+
private func pump() {
141+
pumpSynthesis()
142+
pumpPlayback()
143+
checkDone()
144+
}
145+
146+
private func pumpSynthesis() {
147+
guard !isSynthesizing, !pending.isEmpty, ready.count < TTSPlayer.readyAhead else { return }
148+
149+
let model = Settings.ttsModel
150+
let voice = Settings.ttsVoice
151+
let chunk = pending.removeFirst()
152+
let path = cachePath(for: chunk, model: model, voice: voice)
153+
154+
if FileManager.default.fileExists(atPath: path) {
155+
ready.append(path)
156+
pumpSynthesis()
157+
pumpPlayback()
158+
return
159+
}
160+
161+
isSynthesizing = true
162+
let gen = generation
163+
TTSAPI.synthesize(text: chunk, model: model, voice: voice, apiKey: Settings.apiKey) { [weak self] data, error in
164+
guard let self = self, gen == self.generation else { return }
165+
self.isSynthesizing = false
166+
if let data = data, (data as NSData).write(toFile: path, atomically: true) {
167+
self.ready.append(path)
168+
} else {
169+
// One failed chunk shouldn't kill the rest of the reply — report and skip.
170+
self.onError?(error ?? "Speech synthesis failed.")
171+
}
172+
self.pump()
173+
}
174+
}
175+
176+
private func pumpPlayback() {
177+
guard player == nil, !ready.isEmpty else { return }
178+
let path = ready.removeFirst()
179+
do {
180+
let p = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
181+
p.delegate = self
182+
player = p
183+
p.prepareToPlay()
184+
if !p.play() {
185+
player = nil
186+
onError?("Could not play synthesized audio.")
187+
}
188+
} catch {
189+
player = nil
190+
onError?("Could not open synthesized audio.")
191+
}
192+
}
193+
194+
// Speaking is over only when the stream ended AND the whole pipeline has drained.
195+
private func checkDone() {
196+
guard speakingKey != nil, streamComplete, player == nil,
197+
ready.isEmpty, pending.isEmpty, !isSynthesizing, cursor >= full.count else { return }
198+
stop()
199+
}
200+
201+
func audioPlayerDidFinishPlaying(_ p: AVAudioPlayer, successfully flag: Bool) {
202+
guard p === player else { return }
203+
player = nil
204+
pump()
205+
}
206+
207+
func audioPlayerDecodeErrorDidOccur(_ p: AVAudioPlayer, error: Error?) {
208+
guard p === player else { return }
209+
player = nil
210+
pump()
211+
}
212+
213+
// MARK: - Cache
214+
215+
private func activateSession() {
216+
let session = AVAudioSession.sharedInstance()
217+
try? session.setCategory(.playback)
218+
try? session.setActive(true)
219+
}
220+
221+
// Content-addressed so identical text is never synthesized (or billed) twice.
222+
private func cachePath(for text: String, model: String, voice: String) -> String {
223+
let key = "\(model)|\(voice)|\(text)"
224+
let name = String(format: "%08x-%d.mp3", TTSPlayer.hash(key), key.utf8.count)
225+
return (TTSPlayer.cacheDir as NSString).appendingPathComponent(name)
226+
}
227+
228+
// djb2 — CommonCrypto isn't bridged here and a collision only costs a wrong cache hit,
229+
// which the length suffix already makes vanishingly unlikely.
230+
private static func hash(_ s: String) -> UInt32 {
231+
var h: UInt32 = 5381
232+
for b in s.utf8 { h = (h &* 33) &+ UInt32(b) }
233+
return h
234+
}
235+
}

cai/cai/Networking/CurlFetcher.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ class CurlFetcher {
6464
// Serial gate: holds back dispatch to curlQueue until a slot frees, so we never spawn
6565
// a worker thread per queued request (iPhone 4S has a hard thread/stack-memory budget).
6666
private static let gateQueue = DispatchQueue(label: "com.cai.curl.gate")
67-
// Caps concurrent in-flight transfers.
68-
private static let curlLimit = DispatchSemaphore(value: 2)
67+
// Caps concurrent in-flight transfers. A live SSE chat stream occupies one slot for its
68+
// whole duration, so keep room for speech synthesis plus one other request alongside it.
69+
private static let curlLimit = DispatchSemaphore(value: 3)
6970
// dispatch_once via static let — runs exactly once even under concurrent first access
7071
// (Swift lazy-static init is thread-safe), so curl_global_init is never called concurrently.
7172
private static let curlGlobalInit: Bool = { curl_bridge_global_init(); return true }()

0 commit comments

Comments
 (0)