yaver-feedback-react-native 0.8.13 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/P2PClient.ts CHANGED
@@ -3,8 +3,10 @@ import {
3
3
  CapabilitySnapshot,
4
4
  FeedbackBundle,
5
5
  IncidentEvent,
6
+ OpenCodeConfigSummary,
6
7
  OperationState,
7
8
  RunnerBrowserAuthSession,
9
+ RunnerAuthStatusRow,
8
10
  TestSession,
9
11
  VoiceCapability,
10
12
  } from './types';
@@ -154,6 +156,25 @@ export class P2PClient {
154
156
  this.relayPassword = password;
155
157
  }
156
158
 
159
+ /** Read-only base URL — used by the voice vibe-coding path to probe
160
+ * GET /voice/status before opening the stream. */
161
+ get agentBaseUrl(): string {
162
+ return this.baseUrl;
163
+ }
164
+
165
+ /** WebSocket URL for the agent's voice stream (WS /voice/stream). The
166
+ * voice vibe-coding loop streams mic audio here and receives the
167
+ * transcript + agent task + TTS frames back. */
168
+ voiceStreamUrl(): string {
169
+ return this.baseUrl.replace(/^http/, 'ws') + '/voice/stream';
170
+ }
171
+
172
+ /** Auth headers for the voice WS + status probe — same bearer (and
173
+ * relay password) as every other agent request. */
174
+ voiceAuthHeaders(): Record<string, string> {
175
+ return this.authHeaders();
176
+ }
177
+
157
178
  /** Merge in Authorization + (optional) X-Relay-Password on top of a header block. */
158
179
  private authHeaders(extra: Record<string, string> = {}): Record<string, string> {
159
180
  const h: Record<string, string> = { ...extra };
@@ -222,6 +243,60 @@ export class P2PClient {
222
243
  return data.session as RunnerBrowserAuthSession;
223
244
  }
224
245
 
246
+ async getRunnerAuthStatus(): Promise<RunnerAuthStatusRow[]> {
247
+ const resp = await fetch(`${this.baseUrl}/runner-auth/status`, {
248
+ headers: this.authHeaders(),
249
+ });
250
+ if (!resp.ok) {
251
+ const text = await resp.text().catch(() => '');
252
+ throw new Error(`getRunnerAuthStatus HTTP ${resp.status}: ${text}`);
253
+ }
254
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
255
+ return Array.isArray(data.runners) ? (data.runners as RunnerAuthStatusRow[]) : [];
256
+ }
257
+
258
+ async getOpenCodeConfig(): Promise<OpenCodeConfigSummary | null> {
259
+ const resp = await fetch(`${this.baseUrl}/runner/opencode/config`, {
260
+ headers: this.authHeaders(),
261
+ });
262
+ if (!resp.ok) {
263
+ const text = await resp.text().catch(() => '');
264
+ throw new Error(`getOpenCodeConfig HTTP ${resp.status}: ${text}`);
265
+ }
266
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
267
+ return (data.config ?? null) as OpenCodeConfigSummary | null;
268
+ }
269
+
270
+ async saveOpenCodeConfig(patch: {
271
+ defaultAgent?: string;
272
+ model?: string;
273
+ smallModel?: string;
274
+ buildModel?: string;
275
+ planModel?: string;
276
+ providers?: Array<{
277
+ id: string;
278
+ name?: string;
279
+ baseUrl?: string;
280
+ apiKey?: string;
281
+ delete?: boolean;
282
+ }>;
283
+ }): Promise<{ ok: boolean; config?: OpenCodeConfigSummary; error?: string }> {
284
+ try {
285
+ const resp = await fetch(`${this.baseUrl}/runner/opencode/config`, {
286
+ method: 'POST',
287
+ headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
288
+ body: JSON.stringify(patch),
289
+ });
290
+ const data = await resp.json().catch(() => ({} as Record<string, unknown>));
291
+ if (!resp.ok) {
292
+ return { ok: false, error: (data.error as string | undefined) || `HTTP ${resp.status}` };
293
+ }
294
+ return { ok: true, config: data.config as OpenCodeConfigSummary | undefined };
295
+ } catch (err) {
296
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
297
+ }
298
+ }
299
+
225
300
  async capabilitySnapshot(): Promise<CapabilitySnapshot | null> {
226
301
  try {
227
302
  const resp = await fetch(`${this.baseUrl}/capabilities/snapshot`, { headers: this.authHeaders() });
@@ -422,6 +497,10 @@ export class P2PClient {
422
497
  s2sReady: data.s2sReady ?? false,
423
498
  sttProvider: data.sttProvider ?? undefined,
424
499
  sttReady: data.sttReady ?? false,
500
+ ttsProvider: data.ttsProvider ?? undefined,
501
+ ttsReady: data.ttsReady ?? false,
502
+ enabled: data.enabled ?? false,
503
+ defaultProject: data.defaultProject ?? undefined,
425
504
  };
426
505
  }
427
506
 
@@ -27,9 +27,13 @@ import {
27
27
  View,
28
28
  } from 'react-native';
29
29
  import type { P2PClient } from './P2PClient';
30
+ import { SDKVoiceSession, pcmToTempWavURI, isVoiceStreamSupported } from './voice';
31
+ import { startPcmRecording, stopPcmRecording, isVoiceCaptureSupported } from './capture';
30
32
 
31
33
  export type VibeTurnRole = 'user' | 'assistant' | 'status';
32
34
 
35
+ type VoiceState = 'idle' | 'recording' | 'uploading' | 'thinking' | 'speaking';
36
+
33
37
  export interface VibeTurn {
34
38
  id: string;
35
39
  role: VibeTurnRole;
@@ -45,6 +49,11 @@ interface Props {
45
49
  /** Called when the user taps Reload after a task completes — uses
46
50
  * P2PClient.reloadApp() with the active project context. */
47
51
  onReload?: () => Promise<void>;
52
+ /** Optional context forwarded to the voice stream so the agent runs
53
+ * the task against the right project / runner / model. */
54
+ project?: string;
55
+ model?: string;
56
+ runner?: string;
48
57
  }
49
58
 
50
59
  export function VibeChatScreen({
@@ -53,6 +62,9 @@ export function VibeChatScreen({
53
62
  initialUserPrompt,
54
63
  onClose,
55
64
  onReload,
65
+ project,
66
+ model,
67
+ runner,
56
68
  }: Props) {
57
69
  const [taskId, setTaskId] = useState(initialTaskId);
58
70
  const [turns, setTurns] = useState<VibeTurn[]>(() => [
@@ -77,6 +89,17 @@ export function VibeChatScreen({
77
89
  const scrollRef = useRef<ScrollView | null>(null);
78
90
  const abortRef = useRef<(() => void) | null>(null);
79
91
 
92
+ // ── Voice vibe coding ──────────────────────────────────────────────
93
+ const [voiceState, setVoiceState] = useState<VoiceState>('idle');
94
+ const [voiceAvailable, setVoiceAvailable] = useState(false);
95
+ // "local" = whisper.cpp on the host (free, private); "flux" = Deepgram
96
+ // nova-3 streaming. fluxAvailable gates the toggle on whether the agent
97
+ // has a Deepgram key. activeEngine is echoed back by the agent.
98
+ const [voiceMode, setVoiceMode] = useState<'local' | 'flux'>('local');
99
+ const [fluxAvailable, setFluxAvailable] = useState(false);
100
+ const [activeEngine, setActiveEngine] = useState<string>('');
101
+ const voiceSessionRef = useRef<SDKVoiceSession | null>(null);
102
+
80
103
  // Subscribe to the current task's SSE stream. Re-runs whenever the
81
104
  // taskId changes (resumeTask reuses the same id, so this only fires
82
105
  // once per task — which is fine).
@@ -192,6 +215,157 @@ export function VibeChatScreen({
192
215
  }
193
216
  }, [isReloading, onReload]);
194
217
 
218
+ // Probe whether voice is usable: deps present (expo-av + expo-file-
219
+ // system + buffer) AND the agent reports STT/TTS ready. Hide the mic
220
+ // entirely otherwise so users never tap a dead button.
221
+ useEffect(() => {
222
+ let cancelled = false;
223
+ (async () => {
224
+ if (!isVoiceCaptureSupported() || !isVoiceStreamSupported()) return;
225
+ try {
226
+ const res = await fetch(`${client.agentBaseUrl}/voice/status`, { headers: client.voiceAuthHeaders() });
227
+ if (!res.ok) return;
228
+ const body = await res.json();
229
+ if (cancelled) return;
230
+ // Local whisper is always usable when voice is enabled; Flux needs
231
+ // a Deepgram key on the agent. Show the mic if either path works.
232
+ const localOk = !!body?.enabled;
233
+ const fluxOk = !!body?.enabled && !!body?.deepgramSet;
234
+ if (localOk || fluxOk) setVoiceAvailable(true);
235
+ setFluxAvailable(fluxOk);
236
+ if (!localOk && fluxOk) setVoiceMode('flux');
237
+ } catch { /* leave hidden */ }
238
+ })();
239
+ return () => { cancelled = true; };
240
+ }, [client]);
241
+
242
+ useEffect(() => () => { voiceSessionRef.current?.close(); }, []);
243
+
244
+ // Local TTS: the agent streams no audio for "local"/"device" engines,
245
+ // so the client speaks the result text with the device synthesizer.
246
+ // expo-speech is optional — if absent, the text is still shown.
247
+ const speakLocalText = useCallback((text: string) => {
248
+ if (!text) return;
249
+ try {
250
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
251
+ const Speech = require('expo-speech');
252
+ const headline = text.length > 280 ? `${text.slice(0, 280)} — see screen for the rest.` : text;
253
+ Speech.stop?.();
254
+ Speech.speak?.(headline);
255
+ } catch { /* expo-speech not installed — text remains visible */ }
256
+ }, []);
257
+
258
+ const playTTS = useCallback(async (pcm: Uint8Array, sampleRate: number) => {
259
+ try {
260
+ const wavUri = await pcmToTempWavURI(pcm, sampleRate);
261
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
262
+ const { Audio } = require('expo-av');
263
+ const { sound } = await Audio.Sound.createAsync({ uri: wavUri }, { shouldPlay: true });
264
+ sound.setOnPlaybackStatusUpdate((st: any) => {
265
+ if (st.didJustFinish) sound.unloadAsync().catch(() => {});
266
+ });
267
+ } catch { /* playback best-effort */ }
268
+ }, []);
269
+
270
+ const stopVoiceAndProcess = useCallback(async () => {
271
+ setVoiceState('uploading');
272
+ let uri: string | null = null;
273
+ try {
274
+ uri = await stopPcmRecording();
275
+ } catch (e) {
276
+ setVoiceState('idle');
277
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
278
+ return;
279
+ }
280
+ if (!uri) { setVoiceState('idle'); return; }
281
+
282
+ const useFlux = voiceMode === 'flux' && fluxAvailable;
283
+ const session = new SDKVoiceSession({
284
+ onProviders: (stt, tts) => setActiveEngine(stt === 'deepgram' ? 'Flux (Deepgram)' : stt === 'local' ? 'Local (whisper)' : stt),
285
+ onTranscriptPartial: (t) => {
286
+ setTurns((prev) => {
287
+ const next = prev.filter((x) => x.id !== 'voice-partial');
288
+ next.push({ id: 'voice-partial', role: 'status', text: `🎙 ${t}`, timestamp: Date.now() });
289
+ return next;
290
+ });
291
+ },
292
+ onTranscriptFinal: (t) => {
293
+ setVoiceState('thinking');
294
+ setTurns((prev) => [
295
+ ...prev.filter((x) => x.id !== 'voice-partial'),
296
+ { id: `user-voice-${Date.now()}`, role: 'user', text: t, timestamp: Date.now() },
297
+ { id: `status-${Date.now()}`, role: 'status', text: 'thinking…', timestamp: Date.now() },
298
+ ]);
299
+ },
300
+ onTaskCreated: (id) => {
301
+ // Hand the chat's SSE subscription the new task so its agent
302
+ // output streams into the transcript exactly like a typed turn.
303
+ if (id) { setStatus('running'); setStreamBuffer(''); setTaskId(id); }
304
+ },
305
+ onTaskResult: (_id, text) => {
306
+ setVoiceState('speaking');
307
+ // Local TTS path: agent sends no audio frames, so speak here.
308
+ if (!useFlux) speakLocalText(text);
309
+ },
310
+ onTTSReady: (pcm, sr) => { void playTTS(pcm, sr); },
311
+ onDone: () => setTimeout(() => setVoiceState('idle'), 1200),
312
+ onError: (msg) => {
313
+ setVoiceState('idle');
314
+ setTurns((prev) => [...prev.filter((x) => x.id !== 'voice-partial'), { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${msg}`, timestamp: Date.now() }]);
315
+ },
316
+ });
317
+ voiceSessionRef.current = session;
318
+ try {
319
+ await session.start({
320
+ wsUrl: client.voiceStreamUrl(),
321
+ headers: client.voiceAuthHeaders(),
322
+ project,
323
+ model,
324
+ runner,
325
+ surface: 'feedback-sdk',
326
+ ttsBudget: 280,
327
+ // Local: whisper.cpp on the host + device synth. Flux: Deepgram
328
+ // nova-3 STT + Aura TTS streamed back as PCM.
329
+ sttProvider: useFlux ? 'deepgram' : 'local',
330
+ ttsProvider: useFlux ? 'deepgram' : 'local',
331
+ });
332
+ await session.streamAudioFile(uri, { skipWavHeader: true });
333
+ session.finalize();
334
+ } catch (e) {
335
+ setVoiceState('idle');
336
+ session.close();
337
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
338
+ }
339
+ }, [client, project, model, runner, playTTS, voiceMode, fluxAvailable, speakLocalText]);
340
+
341
+ const handleVoicePress = useCallback(async () => {
342
+ if (voiceState === 'recording') {
343
+ void stopVoiceAndProcess();
344
+ return;
345
+ }
346
+ if (voiceState !== 'idle') {
347
+ // Mid-flow tap cancels.
348
+ voiceSessionRef.current?.close();
349
+ voiceSessionRef.current = null;
350
+ setVoiceState('idle');
351
+ return;
352
+ }
353
+ try {
354
+ await startPcmRecording();
355
+ setVoiceState('recording');
356
+ } catch (e) {
357
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
358
+ }
359
+ }, [voiceState, stopVoiceAndProcess]);
360
+
361
+ const voiceLabel: Record<VoiceState, string> = {
362
+ idle: '🎙 speak',
363
+ recording: '■ stop',
364
+ uploading: 'sending…',
365
+ thinking: 'thinking…',
366
+ speaking: 'speaking…',
367
+ };
368
+
195
369
  return (
196
370
  <View style={styles.container}>
197
371
  <View style={styles.header}>
@@ -240,6 +414,12 @@ export function VibeChatScreen({
240
414
  </ScrollView>
241
415
 
242
416
  <View style={styles.footer}>
417
+ {voiceAvailable && voiceState !== 'idle' && (
418
+ <Text style={styles.engineCaption}>
419
+ {voiceState === 'recording' ? 'listening' : voiceState === 'uploading' ? 'sending' : voiceState === 'thinking' ? 'agent working' : 'speaking'}
420
+ {activeEngine ? ` · ${activeEngine}` : ` · ${voiceMode === 'flux' ? 'Flux (Deepgram)' : 'Local (whisper)'}`}
421
+ </Text>
422
+ )}
243
423
  <TextInput
244
424
  style={styles.input}
245
425
  value={followUp}
@@ -250,6 +430,40 @@ export function VibeChatScreen({
250
430
  multiline
251
431
  />
252
432
  <View style={styles.actions}>
433
+ {voiceAvailable && (
434
+ <>
435
+ {/* Local ↔ Flux engine toggle. Only shows Flux when the
436
+ agent has a Deepgram key; otherwise the label just
437
+ states "Local" so the active engine is always clear. */}
438
+ {fluxAvailable ? (
439
+ <TouchableOpacity
440
+ style={[styles.actionBtn, styles.engineToggle]}
441
+ onPress={() => setVoiceMode((m) => (m === 'local' ? 'flux' : 'local'))}
442
+ disabled={voiceState !== 'idle'}
443
+ accessibilityLabel="Toggle voice engine"
444
+ >
445
+ <Text style={styles.engineToggleText}>{voiceMode === 'flux' ? '⚡ Flux' : '🔒 Local'}</Text>
446
+ </TouchableOpacity>
447
+ ) : (
448
+ <View style={[styles.actionBtn, styles.engineToggle]}>
449
+ <Text style={styles.engineToggleText}>🔒 Local</Text>
450
+ </View>
451
+ )}
452
+ <TouchableOpacity
453
+ style={[
454
+ styles.actionBtn,
455
+ styles.voiceBtn,
456
+ voiceState === 'recording' && styles.voiceBtnActive,
457
+ (voiceState === 'uploading' || voiceState === 'thinking' || voiceState === 'speaking') && styles.actionBtnDisabled,
458
+ ]}
459
+ onPress={handleVoicePress}
460
+ disabled={voiceState === 'uploading' || voiceState === 'thinking' || voiceState === 'speaking'}
461
+ accessibilityLabel="Vibe code by voice"
462
+ >
463
+ <Text style={styles.actionText}>{voiceLabel[voiceState]}</Text>
464
+ </TouchableOpacity>
465
+ </>
466
+ )}
253
467
  {onReload && (
254
468
  <TouchableOpacity
255
469
  style={[
@@ -357,6 +571,11 @@ const styles = StyleSheet.create({
357
571
  },
358
572
  actionBtnDisabled: { opacity: 0.5 },
359
573
  reloadBtn: { backgroundColor: 'rgba(255,255,255,0.08)' },
574
+ voiceBtn: { backgroundColor: 'rgba(16,185,129,0.18)' },
575
+ voiceBtnActive: { backgroundColor: '#ef4444' },
576
+ engineToggle: { backgroundColor: 'rgba(255,255,255,0.06)', marginRight: 'auto', marginLeft: 0 },
577
+ engineToggleText: { color: '#cbd5e1', fontSize: 12, fontWeight: '600' },
578
+ engineCaption: { color: '#9ca3af', fontSize: 11, marginBottom: 6, marginLeft: 4 },
360
579
  sendBtn: { backgroundColor: '#7582f5' },
361
580
  actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
362
581
  });
package/src/capture.ts CHANGED
@@ -336,3 +336,54 @@ export async function stopAudioRecording(): Promise<{ path: string; duration: nu
336
336
  export function isAudioRecording(): boolean {
337
337
  return audioRecorderActive;
338
338
  }
339
+
340
+ // ── Voice-stream recording (raw LPCM WAV) ──────────────────────────────
341
+ // The voice vibe-coding path streams audio to the agent's STT WS, which
342
+ // expects raw 16-bit / 16 kHz mono PCM (we strip the WAV header on the
343
+ // way out). That's a different format from the HIGH_QUALITY m4a recorder
344
+ // above — a compressed .m4a can't be streamed to Deepgram/whisper — so
345
+ // this uses its own recording options, mirroring the Yaver app's
346
+ // AgentVoiceButton.
347
+
348
+ let pcmRecorderRef: any = null;
349
+ let pcmRecorderActive = false;
350
+
351
+ // Raw LPCM 16-bit LE, 16 kHz mono. iOS uses lpcm; Android records WAV.
352
+ const PCM_RECORDING_OPTIONS: any = {
353
+ android: { extension: '.wav', outputFormat: 2, audioEncoder: 3, sampleRate: 16000, numberOfChannels: 1, bitRate: 256000 },
354
+ ios: {
355
+ extension: '.wav', outputFormat: 'lpcm', audioQuality: 0x40, sampleRate: 16000,
356
+ numberOfChannels: 1, bitRate: 256000, linearPCMBitDepth: 16, linearPCMIsBigEndian: false, linearPCMIsFloat: false,
357
+ },
358
+ web: { mimeType: 'audio/wav', bitsPerSecond: 256000 },
359
+ };
360
+
361
+ /** Begin a raw-PCM recording for the voice stream. */
362
+ export async function startPcmRecording(): Promise<void> {
363
+ if (pcmRecorderActive) throw new Error('[YaverFeedback] A voice recording is already in progress.');
364
+ const ExpoAv = loadExpoAvOrThrow();
365
+ const { Audio } = ExpoAv;
366
+ const perm = await Audio.requestPermissionsAsync();
367
+ if (!perm.granted) {
368
+ throw new Error('[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.');
369
+ }
370
+ await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, staysActiveInBackground: false });
371
+ const { recording } = await Audio.Recording.createAsync(PCM_RECORDING_OPTIONS);
372
+ pcmRecorderRef = recording;
373
+ pcmRecorderActive = true;
374
+ }
375
+
376
+ /** Stop the voice recording; returns the WAV file:// URI (or null). */
377
+ export async function stopPcmRecording(): Promise<string | null> {
378
+ if (!pcmRecorderActive || !pcmRecorderRef) return null;
379
+ const recording = pcmRecorderRef;
380
+ pcmRecorderRef = null;
381
+ pcmRecorderActive = false;
382
+ try { await recording.stopAndUnloadAsync(); } catch { /* already stopped */ }
383
+ return typeof recording.getURI === 'function' ? recording.getURI() : null;
384
+ }
385
+
386
+ /** Whether a voice-stream recording is currently active. */
387
+ export function isPcmRecording(): boolean {
388
+ return pcmRecorderActive;
389
+ }
package/src/types.ts CHANGED
@@ -20,6 +20,49 @@ export interface RunnerBrowserAuthSession {
20
20
  completedAt?: number;
21
21
  }
22
22
 
23
+ export interface RunnerAuthStatusRow {
24
+ id: string;
25
+ name: string;
26
+ installed: boolean;
27
+ ready: boolean;
28
+ authConfigured: boolean;
29
+ authSource?: string;
30
+ warning?: string;
31
+ error?: string;
32
+ path?: string;
33
+ detail?: string;
34
+ version?: string;
35
+ }
36
+
37
+ export interface OpenCodeProviderSummary {
38
+ id: string;
39
+ name?: string;
40
+ hasApiKey?: boolean;
41
+ baseUrl?: string;
42
+ models?: Array<{ id: string; name?: string; provider?: string }>;
43
+ }
44
+
45
+ export interface OpenCodeAgentSummary {
46
+ name: string;
47
+ model?: string;
48
+ description?: string;
49
+ isBuiltin?: boolean;
50
+ }
51
+
52
+ export interface OpenCodeConfigSummary {
53
+ path: string;
54
+ exists: boolean;
55
+ defaultAgent?: string;
56
+ model?: string;
57
+ smallModel?: string;
58
+ buildModel?: string;
59
+ planModel?: string;
60
+ providers?: OpenCodeProviderSummary[];
61
+ models?: Array<{ id: string; name?: string; provider?: string }>;
62
+ agents?: OpenCodeAgentSummary[];
63
+ diagnostics?: string[];
64
+ }
65
+
23
66
  export interface IncidentEvent {
24
67
  id: string;
25
68
  timestamp: number;
@@ -451,12 +494,20 @@ export interface TestSession {
451
494
  export interface VoiceCapability {
452
495
  /** Always true — mobile can always record and send audio. */
453
496
  voiceInputEnabled: boolean;
454
- /** Speech-to-speech provider (e.g. "personaplex", "openai"), or null. */
497
+ /** Speech-to-speech provider (legacy), or null. */
455
498
  s2sProvider?: string;
456
499
  /** Whether the S2S provider is ready for real-time sessions. */
457
500
  s2sReady?: boolean;
458
- /** Speech-to-text provider for transcription (e.g. "whisper", "openai"). */
501
+ /** Speech-to-text provider for transcription, e.g. "deepgram" for Deepgram Flux. */
459
502
  sttProvider?: string;
460
503
  /** Whether STT is ready (auto-transcription of voice input). */
461
504
  sttReady?: boolean;
505
+ /** Text-to-speech provider for readback, e.g. "cartesia". */
506
+ ttsProvider?: string;
507
+ /** Whether TTS readback is ready. */
508
+ ttsReady?: boolean;
509
+ /** Whether the agent-side hands-free task loop is enabled. */
510
+ enabled?: boolean;
511
+ /** Default project slug used by the agent voice loop. */
512
+ defaultProject?: string;
462
513
  }