Skip to content

Commit 097b4c9

Browse files
authored
Add experimental floating bar voice answers (#6244)
1 parent ee36561 commit 097b4c9

12 files changed

Lines changed: 414 additions & 3 deletions

File tree

desktop/Backend-Rust/src/models/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub use user_settings::{
5757
UpdateLanguageRequest, UpdateNotificationSettingsRequest, UpdateTranscriptionPreferencesRequest,
5858
UpdateUserProfileRequest, UserLanguage, UserProfile, UserSettingsStatusResponse,
5959
AssistantSettingsData, SharedAssistantSettingsData, FocusSettingsData, TaskSettingsData,
60-
AdviceSettingsData, MemorySettingsData,
60+
AdviceSettingsData, MemorySettingsData, FloatingBarSettingsData,
6161
};
6262
pub use chat_session::{
6363
ChatSessionDB, ChatSessionStatusResponse, CreateChatSessionRequest, GetChatSessionsQuery,

desktop/Backend-Rust/src/models/user_settings.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ pub struct MemorySettingsData {
235235
pub excluded_apps: Option<Vec<String>>,
236236
}
237237

238+
/// Floating bar chat settings
239+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240+
pub struct FloatingBarSettingsData {
241+
pub voice_answers_enabled: Option<bool>,
242+
pub elevenlabs_api_key: Option<String>,
243+
pub elevenlabs_voice_id: Option<String>,
244+
}
245+
238246
/// All assistant settings (response and request — all fields optional for partial updates)
239247
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240248
pub struct AssistantSettingsData {
@@ -243,6 +251,7 @@ pub struct AssistantSettingsData {
243251
pub task: Option<TaskSettingsData>,
244252
pub advice: Option<AdviceSettingsData>,
245253
pub memory: Option<MemorySettingsData>,
254+
pub floating_bar: Option<FloatingBarSettingsData>,
246255
/// Remote override for the Sparkle update channel (top-level field on user doc, not in assistant_settings sub-map)
247256
#[serde(skip_serializing_if = "Option::is_none")]
248257
pub update_channel: Option<String>,

desktop/Backend-Rust/src/routes/users.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,20 @@ async fn update_assistant_settings(
541541
}
542542
}
543543
}
544+
if let Some(ref floating_bar) = request.floating_bar {
545+
if let Some(ref api_key) = floating_bar.elevenlabs_api_key {
546+
if api_key.len() > 512 {
547+
tracing::warn!("ElevenLabs API key too long: {} chars (max 512)", api_key.len());
548+
return Err(StatusCode::BAD_REQUEST);
549+
}
550+
}
551+
if let Some(ref voice_id) = floating_bar.elevenlabs_voice_id {
552+
if voice_id.len() > 128 {
553+
tracing::warn!("ElevenLabs voice id too long: {} chars (max 128)", voice_id.len());
554+
return Err(StatusCode::BAD_REQUEST);
555+
}
556+
}
557+
}
544558

545559
match state
546560
.firestore

desktop/Backend-Rust/src/services/firestore.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::models::{
2020
NotificationSettings, PersonaDB, Structured, TranscriptSegment, TranscriptionPreferences,
2121
AIUserProfile, UserProfile,
2222
AssistantSettingsData, SharedAssistantSettingsData, FocusSettingsData, TaskSettingsData,
23-
AdviceSettingsData, MemorySettingsData,
23+
AdviceSettingsData, MemorySettingsData, FloatingBarSettingsData,
2424
};
2525

2626
/// Service account credentials from JSON file
@@ -4907,6 +4907,12 @@ impl FirestoreService {
49074907
excluded_apps: Some(self.parse_string_array(f, "excluded_apps")),
49084908
});
49094909

4910+
let floating_bar = self.parse_sub_map(sf, "floating_bar").map(|f| FloatingBarSettingsData {
4911+
voice_answers_enabled: self.parse_bool(f, "voice_answers_enabled").ok(),
4912+
elevenlabs_api_key: self.parse_string(f, "elevenlabs_api_key"),
4913+
elevenlabs_voice_id: self.parse_string(f, "elevenlabs_voice_id"),
4914+
});
4915+
49104916
// Read top-level update_channel from user doc (not from assistant_settings sub-map)
49114917
let update_channel = self.parse_string(fields, "update_channel");
49124918

@@ -4916,6 +4922,7 @@ impl FirestoreService {
49164922
task,
49174923
advice,
49184924
memory,
4925+
floating_bar,
49194926
update_channel,
49204927
})
49214928
}
@@ -5036,6 +5043,21 @@ impl FirestoreService {
50365043
}
50375044
}
50385045

5046+
if data.floating_bar.is_some() || current.floating_bar.is_some() {
5047+
let cur = current.floating_bar.unwrap_or_default();
5048+
let new = data.floating_bar.clone().unwrap_or_default();
5049+
let mut m = serde_json::Map::new();
5050+
let vae = new.voice_answers_enabled.or(cur.voice_answers_enabled);
5051+
if let Some(v) = vae { m.insert("voice_answers_enabled".into(), json!({"booleanValue": v})); }
5052+
let api_key = new.elevenlabs_api_key.or(cur.elevenlabs_api_key);
5053+
if let Some(v) = api_key { m.insert("elevenlabs_api_key".into(), json!({"stringValue": v})); }
5054+
let voice_id = new.elevenlabs_voice_id.or(cur.elevenlabs_voice_id);
5055+
if let Some(v) = voice_id { m.insert("elevenlabs_voice_id".into(), json!({"stringValue": v})); }
5056+
if !m.is_empty() {
5057+
top_fields.insert("floating_bar".into(), self.build_sub_map_value(m));
5058+
}
5059+
}
5060+
50395061
if !top_fields.is_empty() {
50405062
let fields = json!({
50415063
"assistant_settings": {

desktop/Desktop/Sources/APIClient.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3893,16 +3893,30 @@ struct MemorySettingsResponse: Codable {
38933893
}
38943894
}
38953895

3896+
struct FloatingBarSettingsResponse: Codable {
3897+
var voiceAnswersEnabled: Bool?
3898+
var elevenLabsApiKey: String?
3899+
var elevenLabsVoiceID: String?
3900+
3901+
enum CodingKeys: String, CodingKey {
3902+
case voiceAnswersEnabled = "voice_answers_enabled"
3903+
case elevenLabsApiKey = "elevenlabs_api_key"
3904+
case elevenLabsVoiceID = "elevenlabs_voice_id"
3905+
}
3906+
}
3907+
38963908
struct AssistantSettingsResponse: Codable {
38973909
var shared: SharedAssistantSettingsResponse?
38983910
var focus: FocusSettingsResponse?
38993911
var task: TaskSettingsResponse?
39003912
var advice: AdviceSettingsResponse?
39013913
var memory: MemorySettingsResponse?
3914+
var floatingBar: FloatingBarSettingsResponse?
39023915
var updateChannel: String?
39033916

39043917
enum CodingKeys: String, CodingKey {
39053918
case shared, focus, task, advice, memory
3919+
case floatingBar = "floating_bar"
39063920
case updateChannel = "update_channel"
39073921
}
39083922
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import AVFoundation
2+
import Foundation
3+
4+
@MainActor
5+
final class FloatingBarVoicePlaybackService: NSObject {
6+
static let shared = FloatingBarVoicePlaybackService()
7+
8+
static let devAPIKeyDefaultsKey = "dev_elevenlabs_api_key"
9+
static let devVoiceIDDefaultsKey = "dev_elevenlabs_voice_id"
10+
11+
nonisolated private static let defaultVoiceID = "21m00Tcm4TlvDq8ikWAM" // Rachel
12+
nonisolated private static let defaultModelID = "eleven_multilingual_v2"
13+
14+
private var playbackTask: Task<Void, Never>?
15+
private var audioPlayer: AVAudioPlayer?
16+
private let speechSynthesizer = AVSpeechSynthesizer()
17+
18+
private override init() {}
19+
20+
func playResponseIfEnabled(_ message: ChatMessage?) {
21+
guard AnalyticsManager.isDevBuild else { return }
22+
guard ShortcutSettings.shared.floatingBarVoiceAnswersEnabled else { return }
23+
24+
let text = Self.cleanedPlaybackText(from: message)
25+
guard !text.isEmpty, Self.shouldSpeak(text) else { return }
26+
27+
let defaults = UserDefaults.standard
28+
guard let apiKey = defaults.string(forKey: Self.devAPIKeyDefaultsKey)?.trimmingCharacters(in: .whitespacesAndNewlines),
29+
!apiKey.isEmpty else {
30+
playSystemFallback(text)
31+
return
32+
}
33+
34+
let voiceID = defaults.string(forKey: Self.devVoiceIDDefaultsKey)?
35+
.trimmingCharacters(in: .whitespacesAndNewlines)
36+
let resolvedVoiceID = (voiceID?.isEmpty == false) ? voiceID! : Self.defaultVoiceID
37+
38+
stop()
39+
playbackTask = Task { [weak self] in
40+
do {
41+
let audioData = try await Self.synthesizeSpeech(text: text, apiKey: apiKey, voiceID: resolvedVoiceID)
42+
try Task.checkCancellation()
43+
await MainActor.run {
44+
self?.startPlayback(audioData)
45+
}
46+
} catch is CancellationError {
47+
} catch {
48+
await MainActor.run {
49+
log("FloatingBarVoicePlaybackService: ElevenLabs playback failed, falling back to system voice: \(error.localizedDescription)")
50+
self?.playSystemFallback(text)
51+
}
52+
}
53+
}
54+
}
55+
56+
func stop() {
57+
playbackTask?.cancel()
58+
playbackTask = nil
59+
audioPlayer?.stop()
60+
audioPlayer = nil
61+
speechSynthesizer.stopSpeaking(at: .immediate)
62+
}
63+
64+
private func startPlayback(_ data: Data) {
65+
do {
66+
let player = try AVAudioPlayer(data: data)
67+
player.prepareToPlay()
68+
player.play()
69+
audioPlayer = player
70+
} catch {
71+
log("FloatingBarVoicePlaybackService: could not start audio playback: \(error.localizedDescription)")
72+
}
73+
}
74+
75+
private func playSystemFallback(_ text: String) {
76+
speechSynthesizer.stopSpeaking(at: .immediate)
77+
let utterance = AVSpeechUtterance(string: text)
78+
utterance.rate = 0.47
79+
utterance.pitchMultiplier = 1.02
80+
utterance.volume = 1.0
81+
utterance.voice = preferredSystemVoice()
82+
speechSynthesizer.speak(utterance)
83+
}
84+
85+
private func preferredSystemVoice() -> AVSpeechSynthesisVoice? {
86+
let preferredNames = ["Samantha", "Karen", "Moira"]
87+
for name in preferredNames {
88+
if let voice = AVSpeechSynthesisVoice.speechVoices().first(where: { $0.name.localizedCaseInsensitiveContains(name) }) {
89+
return voice
90+
}
91+
}
92+
return AVSpeechSynthesisVoice(language: "en-US")
93+
}
94+
95+
private nonisolated static func synthesizeSpeech(text: String, apiKey: String, voiceID: String) async throws -> Data {
96+
var request = URLRequest(url: URL(string: "https://api.elevenlabs.io/v1/text-to-speech/\(voiceID)")!)
97+
request.httpMethod = "POST"
98+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
99+
request.setValue("audio/mpeg", forHTTPHeaderField: "Accept")
100+
request.setValue(apiKey, forHTTPHeaderField: "xi-api-key")
101+
request.timeoutInterval = 45
102+
103+
let body = ElevenLabsSpeechRequest(
104+
text: text,
105+
modelID: defaultModelID,
106+
outputFormat: "mp3_44100_128",
107+
voiceSettings: .init(
108+
stability: 0.42,
109+
similarityBoost: 0.82,
110+
style: 0.22,
111+
useSpeakerBoost: true
112+
)
113+
)
114+
request.httpBody = try JSONEncoder().encode(body)
115+
116+
let (data, response) = try await URLSession.shared.data(for: request)
117+
guard let httpResponse = response as? HTTPURLResponse else {
118+
throw FloatingBarVoicePlaybackError.invalidResponse
119+
}
120+
guard (200 ..< 300).contains(httpResponse.statusCode) else {
121+
let errorBody = String(data: data.prefix(300), encoding: .utf8) ?? "Unknown error"
122+
throw FloatingBarVoicePlaybackError.requestFailed(statusCode: httpResponse.statusCode, body: errorBody)
123+
}
124+
return data
125+
}
126+
127+
private nonisolated static func cleanedPlaybackText(from message: ChatMessage?) -> String {
128+
guard let message else { return "" }
129+
130+
let baseText: String
131+
if !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
132+
baseText = message.text
133+
} else {
134+
baseText = message.contentBlocks.compactMap { block in
135+
switch block {
136+
case .text(_, let text):
137+
return text
138+
case .discoveryCard(_, let title, let summary, _):
139+
return "\(title). \(summary)"
140+
case .toolCall, .thinking:
141+
return nil
142+
}
143+
}.joined(separator: "\n\n")
144+
}
145+
146+
let collapsedWhitespace = baseText.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
147+
return collapsedWhitespace.trimmingCharacters(in: .whitespacesAndNewlines)
148+
}
149+
150+
private nonisolated static func shouldSpeak(_ text: String) -> Bool {
151+
let lowercased = text.lowercased()
152+
if lowercased == "failed to get a response. please try again." {
153+
return false
154+
}
155+
if lowercased.hasPrefix("⚠️") || lowercased.hasPrefix("warning:") {
156+
return false
157+
}
158+
return true
159+
}
160+
}
161+
162+
private struct ElevenLabsSpeechRequest: Encodable {
163+
let text: String
164+
let modelID: String
165+
let outputFormat: String
166+
let voiceSettings: ElevenLabsVoiceSettings
167+
168+
enum CodingKeys: String, CodingKey {
169+
case text
170+
case modelID = "model_id"
171+
case outputFormat = "output_format"
172+
case voiceSettings = "voice_settings"
173+
}
174+
}
175+
176+
private struct ElevenLabsVoiceSettings: Encodable {
177+
let stability: Double
178+
let similarityBoost: Double
179+
let style: Double
180+
let useSpeakerBoost: Bool
181+
182+
enum CodingKeys: String, CodingKey {
183+
case stability
184+
case similarityBoost = "similarity_boost"
185+
case style
186+
case useSpeakerBoost = "use_speaker_boost"
187+
}
188+
}
189+
190+
private enum FloatingBarVoicePlaybackError: LocalizedError {
191+
case invalidResponse
192+
case requestFailed(statusCode: Int, body: String)
193+
194+
var errorDescription: String? {
195+
switch self {
196+
case .invalidResponse:
197+
return "Invalid ElevenLabs response"
198+
case .requestFailed(let statusCode, let body):
199+
return "ElevenLabs request failed (\(statusCode)): \(body)"
200+
}
201+
}
202+
}

desktop/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,7 @@ class FloatingControlBarManager {
983983
func cancelChat() {
984984
chatCancellable?.cancel()
985985
chatCancellable = nil
986+
FloatingBarVoicePlaybackService.shared.stop()
986987
}
987988

988989
/// Toggle visibility.
@@ -1180,6 +1181,8 @@ class FloatingControlBarManager {
11801181
// MARK: - AI Query
11811182

11821183
private func sendAIQuery(_ message: String, barWindow: FloatingControlBarWindow, provider: ChatProvider) async {
1184+
FloatingBarVoicePlaybackService.shared.stop()
1185+
11831186
// Hide the bar visually (without ordering it out) so we keep key-window ownership
11841187
// and avoid promoting the main Omi window while capturing a clean screenshot.
11851188
let previousAlpha = barWindow.alphaValue
@@ -1269,6 +1272,8 @@ class FloatingControlBarManager {
12691272
}
12701273
barWindow.resizeToResponseHeightPublic(animated: true)
12711274
}
1275+
1276+
FloatingBarVoicePlaybackService.shared.playResponseIfEnabled(barWindow.state.currentAIMessage)
12721277
}
12731278
}
12741279

desktop/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ class PushToTalkManager: ObservableObject {
206206
// MARK: - Listening Lifecycle
207207

208208
private func startListening() {
209+
FloatingBarVoicePlaybackService.shared.stop()
209210
state = .listening
210211
transcriptSegments = []
211212
lastInterimText = ""
@@ -235,6 +236,7 @@ class PushToTalkManager: ObservableObject {
235236
}
236237

237238
private func enterLockedListening() {
239+
FloatingBarVoicePlaybackService.shared.stop()
238240
finalizeWorkItem?.cancel()
239241
finalizeWorkItem = nil
240242
state = .lockedListening

0 commit comments

Comments
 (0)