Skip to content

Commit 70f2481

Browse files
committed
Add Read Aloud (TTS): speak selected text via hotkey, local + cloud
Mirror of the dictation flow. Press a global shortcut to read the current selection aloud through a uniform TTSProvider abstraction (parallel to the CloudProvider STT stack), with one AVAudioEngine PCM playback pipeline. Providers: Deepgram Aura-2 (default), Inworld TTS-1.5 Mini, ElevenLabs v3, Gemini 3.1 Flash TTS, OpenAI gpt-4o-mini-tts, Cartesia Sonic-3.5. Local Kokoro-82M (sherpa-onnx) is scaffolded behind the protocol and throws until the native lib lands (#210). - TextToSpeech/: TTSModels, TTSProvider (+registry, HTTP helper), TTSPlayer, TTSController (hotkey + SelectedTextService), per-provider files - Read Aloud settings tab (provider/voice/speed, key entry+verify, preview) - APIKeyManager: add inworld, cartesia keys - Reuses existing Accessibility grant for selected-text capture - Bump to 2.0.0 (build 200) Epic #220. Closes #207 #208 #209 #211 #212 #213 #214 #215 #216 #217
1 parent 4c8ed57 commit 70f2481

16 files changed

Lines changed: 1054 additions & 4 deletions

native-macos/Zerm.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@
477477
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
478478
CODE_SIGN_STYLE = Automatic;
479479
COMBINE_HIDPI_IMAGES = YES;
480-
CURRENT_PROJECT_VERSION = 100;
480+
CURRENT_PROJECT_VERSION = 200;
481481
DEVELOPMENT_ASSET_PATHS = "\"Zerm/Preview Content\"";
482482
DEVELOPMENT_TEAM = V6J6A3VWY2;
483483
ENABLE_HARDENED_RUNTIME = YES;
@@ -492,7 +492,7 @@
492492
"@executable_path/../Frameworks",
493493
);
494494
MACOSX_DEPLOYMENT_TARGET = 14.4;
495-
MARKETING_VERSION = 1.0.0;
495+
MARKETING_VERSION = 2.0.0;
496496
PRODUCT_BUNDLE_IDENTIFIER = com.arcusis.zerm;
497497
PRODUCT_NAME = "$(TARGET_NAME)";
498498
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG ENABLE_NATIVE_SPEECH_ANALYZER $(inherited)";
@@ -511,7 +511,7 @@
511511
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
512512
CODE_SIGN_STYLE = Automatic;
513513
COMBINE_HIDPI_IMAGES = YES;
514-
CURRENT_PROJECT_VERSION = 100;
514+
CURRENT_PROJECT_VERSION = 200;
515515
DEVELOPMENT_ASSET_PATHS = "\"Zerm/Preview Content\"";
516516
DEVELOPMENT_TEAM = V6J6A3VWY2;
517517
ENABLE_HARDENED_RUNTIME = YES;
@@ -526,7 +526,7 @@
526526
"@executable_path/../Frameworks",
527527
);
528528
MACOSX_DEPLOYMENT_TARGET = 14.4;
529-
MARKETING_VERSION = 1.0.0;
529+
MARKETING_VERSION = 2.0.0;
530530
PRODUCT_BUNDLE_IDENTIFIER = com.arcusis.zerm;
531531
PRODUCT_NAME = "$(TARGET_NAME)";
532532
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "ENABLE_NATIVE_SPEECH_ANALYZER $(inherited)";

native-macos/Zerm/Services/APIKeyManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ final class APIKeyManager {
2020
"speechmatics": "speechmaticsAPIKey",
2121
"xai": "xaiAPIKey",
2222
"openai": "openAIAPIKey",
23+
"inworld": "inworldAPIKey",
24+
"cartesia": "cartesiaAPIKey",
2325
"anthropic": "anthropicAPIKey",
2426
"openrouter": "openRouterAPIKey"
2527
]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Foundation
2+
3+
/// Cartesia Sonic-3.5 — low-latency cloud TTS.
4+
///
5+
/// Uses the simple HTTP bytes endpoint (not the WebSocket) and requests raw
6+
/// signed 16-bit little-endian PCM at 24 kHz so it drops straight into the
7+
/// shared `TTSPlayer` pipeline.
8+
/// Docs: POST https://api.cartesia.ai/tts/bytes (X-API-Key + Cartesia-Version).
9+
struct CartesiaTTSProvider: TTSProvider {
10+
let kind: TTSProviderKind = .cartesia
11+
let displayName = "Cartesia Sonic-3.5"
12+
13+
/// Pinned API version date. Cartesia requires this header on every request.
14+
private let apiVersion = "2025-04-16"
15+
private let modelID = "sonic-3.5"
16+
17+
let voices: [TTSVoice] = [
18+
TTSVoice(id: "f786b574-daa5-4673-aa0c-cbe3e8534c02", displayName: "Katie (US, female)", provider: .cartesia),
19+
TTSVoice(id: "db6b0ed5-d5d3-463d-ae85-518a07d3c2b4", displayName: "Skylar (US, female)", provider: .cartesia),
20+
TTSVoice(id: "a5136bf9-224c-4d76-b823-52bd5efcffcc", displayName: "Jameson (US, male)", provider: .cartesia),
21+
TTSVoice(id: "62ae83ad-4f6a-430b-af41-a9bede9286ca", displayName: "Gemma (UK, female)", provider: .cartesia, language: "en"),
22+
TTSVoice(id: "ef191366-f52f-447a-a398-ed8c0f2943a1", displayName: "Archie (UK, male)", provider: .cartesia, language: "en")
23+
]
24+
25+
func synthesize(text: String, voice: TTSVoice, speed: Double, apiKey: String) async throws -> TTSAudio {
26+
guard !apiKey.isEmpty else { throw TTSError.missingAPIKey(displayName) }
27+
28+
let body: [String: Any] = [
29+
"model_id": modelID,
30+
"transcript": text,
31+
"voice": [
32+
"mode": "id",
33+
"id": voice.id
34+
],
35+
"output_format": [
36+
"container": "raw",
37+
"encoding": "pcm_s16le",
38+
"sample_rate": 24000
39+
],
40+
"language": voice.language
41+
]
42+
43+
var request = URLRequest(url: URL(string: "https://api.cartesia.ai/tts/bytes")!)
44+
request.httpMethod = "POST"
45+
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
46+
request.setValue(apiVersion, forHTTPHeaderField: "Cartesia-Version")
47+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
48+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
49+
50+
let pcm = try await TTSHTTP.post(request)
51+
return TTSAudio(pcm: pcm, sampleRate: 24000, channels: 1)
52+
}
53+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
/// Deepgram Aura-2 — the default Read Aloud provider.
4+
///
5+
/// Reuses the Deepgram `Authorization: Token` key the app already stores for dictation,
6+
/// so it works with zero extra onboarding for existing Deepgram users.
7+
/// Docs: POST https://api.deepgram.com/v1/speak (linear16 PCM, sub-200ms).
8+
struct DeepgramTTSProvider: TTSProvider {
9+
let kind: TTSProviderKind = .deepgram
10+
let displayName = "Deepgram Aura-2"
11+
12+
let voices: [TTSVoice] = [
13+
TTSVoice(id: "aura-2-thalia-en", displayName: "Thalia (clear, confident)", provider: .deepgram),
14+
TTSVoice(id: "aura-2-andromeda-en", displayName: "Andromeda (casual, expressive)", provider: .deepgram),
15+
TTSVoice(id: "aura-2-helena-en", displayName: "Helena (caring, warm)", provider: .deepgram),
16+
TTSVoice(id: "aura-2-apollo-en", displayName: "Apollo (confident, comfortable)", provider: .deepgram),
17+
TTSVoice(id: "aura-2-arcas-en", displayName: "Arcas (natural, smooth)", provider: .deepgram),
18+
TTSVoice(id: "aura-2-aries-en", displayName: "Aries (warm, energetic)", provider: .deepgram),
19+
TTSVoice(id: "aura-2-orion-en", displayName: "Orion (approachable, calm)", provider: .deepgram),
20+
TTSVoice(id: "aura-2-luna-en", displayName: "Luna (friendly, natural)", provider: .deepgram)
21+
]
22+
23+
func synthesize(text: String, voice: TTSVoice, speed: Double, apiKey: String) async throws -> TTSAudio {
24+
guard !apiKey.isEmpty else { throw TTSError.missingAPIKey(displayName) }
25+
26+
var components = URLComponents(string: "https://api.deepgram.com/v1/speak")!
27+
components.queryItems = [
28+
URLQueryItem(name: "model", value: voice.id),
29+
URLQueryItem(name: "encoding", value: "linear16"),
30+
URLQueryItem(name: "sample_rate", value: "24000"),
31+
URLQueryItem(name: "container", value: "none")
32+
]
33+
34+
var request = URLRequest(url: components.url!)
35+
request.httpMethod = "POST"
36+
request.setValue("Token \(apiKey)", forHTTPHeaderField: "Authorization")
37+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
38+
request.httpBody = try JSONSerialization.data(withJSONObject: ["text": text])
39+
40+
let pcm = try await TTSHTTP.post(request)
41+
return TTSAudio(pcm: pcm, sampleRate: 24000, channels: 1)
42+
}
43+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
3+
/// ElevenLabs v3 — the most expressive cloud Read Aloud provider.
4+
///
5+
/// Uses the default premade voice library so it works the moment a user pastes an
6+
/// API key (no voice cloning or library setup required).
7+
/// Docs: POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}
8+
/// Header `xi-api-key`, `output_format=pcm_24000` yields headerless 24kHz/16-bit/mono signed LE PCM.
9+
struct ElevenLabsTTSProvider: TTSProvider {
10+
let kind: TTSProviderKind = .elevenLabs
11+
let displayName = "ElevenLabs v3"
12+
13+
/// The expressive premium model. `eleven_flash_v2_5` is available for low-latency use.
14+
private let modelID = "eleven_v3"
15+
16+
let voices: [TTSVoice] = [
17+
TTSVoice(id: "21m00Tcm4TlvDq8ikWAM", displayName: "Rachel (calm, narration)", provider: .elevenLabs, isPremium: true),
18+
TTSVoice(id: "EXAVITQu4vr4xnSDxMaL", displayName: "Bella (soft, young)", provider: .elevenLabs, isPremium: true),
19+
TTSVoice(id: "AZnzlk1XvdvUeBnXmlld", displayName: "Domi (strong, confident)", provider: .elevenLabs, isPremium: true),
20+
TTSVoice(id: "XB0fDUnXU5powFXDhCwa", displayName: "Charlotte (warm, expressive)", provider: .elevenLabs, isPremium: true),
21+
TTSVoice(id: "pNInz6obpgDQGcFmaJgB", displayName: "Adam (deep, narration)", provider: .elevenLabs, isPremium: true),
22+
TTSVoice(id: "ErXwobaYiN019PkySvjV", displayName: "Antoni (well-rounded)", provider: .elevenLabs, isPremium: true),
23+
TTSVoice(id: "TxGEqnHWrfWFTfGW9XjX", displayName: "Josh (deep, young)", provider: .elevenLabs, isPremium: true),
24+
TTSVoice(id: "VR6AewLTigWG4xSOukaG", displayName: "Arnold (crisp, firm)", provider: .elevenLabs, isPremium: true)
25+
]
26+
27+
func synthesize(text: String, voice: TTSVoice, speed: Double, apiKey: String) async throws -> TTSAudio {
28+
guard !apiKey.isEmpty else { throw TTSError.missingAPIKey(displayName) }
29+
30+
var components = URLComponents(string: "https://api.elevenlabs.io/v1/text-to-speech/\(voice.id)")!
31+
components.queryItems = [
32+
URLQueryItem(name: "output_format", value: "pcm_24000")
33+
]
34+
35+
var request = URLRequest(url: components.url!)
36+
request.httpMethod = "POST"
37+
request.setValue(apiKey, forHTTPHeaderField: "xi-api-key")
38+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
39+
request.httpBody = try JSONSerialization.data(withJSONObject: [
40+
"text": text,
41+
"model_id": modelID
42+
])
43+
44+
let pcm = try await TTSHTTP.post(request)
45+
return TTSAudio(pcm: pcm, sampleRate: 24000, channels: 1)
46+
}
47+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import Foundation
2+
3+
/// Google Gemini 3.1 Flash TTS — prompt-steerable speech via the `generateContent` API.
4+
///
5+
/// Unlike most providers, Gemini returns a JSON envelope rather than raw audio: the PCM
6+
/// is base64-encoded at `candidates[0].content.parts[0].inlineData.data`, decoded here to
7+
/// 24kHz / 16-bit / mono signed little-endian PCM.
8+
/// Docs: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
9+
struct GeminiTTSProvider: TTSProvider {
10+
let kind: TTSProviderKind = .gemini
11+
let displayName = "Gemini 3.1 Flash TTS"
12+
13+
private let model = "gemini-3.1-flash-tts"
14+
15+
let voices: [TTSVoice] = [
16+
TTSVoice(id: "Kore", displayName: "Kore (firm)", provider: .gemini),
17+
TTSVoice(id: "Puck", displayName: "Puck (upbeat)", provider: .gemini),
18+
TTSVoice(id: "Charon", displayName: "Charon (informative)", provider: .gemini),
19+
TTSVoice(id: "Aoede", displayName: "Aoede (breezy)", provider: .gemini),
20+
TTSVoice(id: "Fenrir", displayName: "Fenrir (excitable)", provider: .gemini),
21+
TTSVoice(id: "Leda", displayName: "Leda (youthful)", provider: .gemini),
22+
TTSVoice(id: "Orus", displayName: "Orus (firm)", provider: .gemini),
23+
TTSVoice(id: "Zephyr", displayName: "Zephyr (bright)", provider: .gemini),
24+
TTSVoice(id: "Callirrhoe", displayName: "Callirrhoe (easy-going)", provider: .gemini),
25+
TTSVoice(id: "Enceladus", displayName: "Enceladus (breathy)", provider: .gemini)
26+
]
27+
28+
// MARK: - Request / response shapes
29+
30+
private struct Request: Encodable {
31+
let contents: [Content]
32+
let generationConfig: GenerationConfig
33+
34+
struct Content: Encodable {
35+
let parts: [Part]
36+
}
37+
struct Part: Encodable {
38+
let text: String
39+
}
40+
struct GenerationConfig: Encodable {
41+
let responseModalities: [String]
42+
let speechConfig: SpeechConfig
43+
}
44+
struct SpeechConfig: Encodable {
45+
let voiceConfig: VoiceConfig
46+
}
47+
struct VoiceConfig: Encodable {
48+
let prebuiltVoiceConfig: PrebuiltVoiceConfig
49+
}
50+
struct PrebuiltVoiceConfig: Encodable {
51+
let voiceName: String
52+
}
53+
}
54+
55+
private struct Response: Decodable {
56+
let candidates: [Candidate]?
57+
58+
struct Candidate: Decodable {
59+
let content: Content?
60+
}
61+
struct Content: Decodable {
62+
let parts: [Part]?
63+
}
64+
struct Part: Decodable {
65+
let inlineData: InlineData?
66+
67+
enum CodingKeys: String, CodingKey {
68+
case inlineData
69+
}
70+
}
71+
struct InlineData: Decodable {
72+
let data: String?
73+
}
74+
}
75+
76+
func synthesize(text: String, voice: TTSVoice, speed: Double, apiKey: String) async throws -> TTSAudio {
77+
guard !apiKey.isEmpty else { throw TTSError.missingAPIKey(displayName) }
78+
79+
let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent")!
80+
81+
let payload = Request(
82+
contents: [.init(parts: [.init(text: text)])],
83+
generationConfig: .init(
84+
responseModalities: ["AUDIO"],
85+
speechConfig: .init(
86+
voiceConfig: .init(
87+
prebuiltVoiceConfig: .init(voiceName: voice.id)
88+
)
89+
)
90+
)
91+
)
92+
93+
var request = URLRequest(url: url)
94+
request.httpMethod = "POST"
95+
request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
96+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
97+
request.httpBody = try JSONEncoder().encode(payload)
98+
99+
let (data, response) = try await TTSHTTP.session.data(for: request)
100+
guard let http = response as? HTTPURLResponse else { throw TTSError.badResponse }
101+
guard (200...299).contains(http.statusCode) else {
102+
let message = String(data: data, encoding: .utf8)?.prefix(300).description ?? "unknown"
103+
throw TTSError.http(http.statusCode, message)
104+
}
105+
106+
let decoded = try JSONDecoder().decode(Response.self, from: data)
107+
guard let base64 = decoded.candidates?.first?.content?.parts?.first?.inlineData?.data else {
108+
throw TTSError.badResponse
109+
}
110+
guard let pcm = Data(base64Encoded: base64) else { throw TTSError.badResponse }
111+
guard !pcm.isEmpty else { throw TTSError.emptyAudio }
112+
113+
return TTSAudio(pcm: pcm, sampleRate: 24000, channels: 1)
114+
}
115+
}

0 commit comments

Comments
 (0)