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/dist/FeedbackModal.js +318 -61
- package/dist/MachinePickerScreen.js +14 -5
- package/dist/P2PClient.d.ts +31 -1
- package/dist/P2PClient.js +59 -0
- package/dist/VibeChatScreen.d.ts +6 -1
- package/dist/VibeChatScreen.js +204 -1
- package/dist/capture.d.ts +6 -0
- package/dist/capture.js +53 -0
- package/dist/types.d.ts +57 -2
- package/dist/voice.d.ts +61 -0
- package/dist/voice.js +246 -0
- package/package.json +14 -3
- package/src/FeedbackModal.tsx +388 -74
- package/src/MachinePickerScreen.tsx +12 -3
- package/src/P2PClient.ts +79 -0
- package/src/VibeChatScreen.tsx +219 -0
- package/src/capture.ts +51 -0
- package/src/types.ts +53 -2
- package/src/voice.ts +270 -0
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
|
|
package/src/VibeChatScreen.tsx
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
}
|