yaver-feedback-react-native 0.8.12 → 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/app.plugin.js +13 -0
- package/dist/DeployPanel.d.ts +7 -0
- package/dist/DeployPanel.js +354 -0
- package/dist/FeedbackModal.js +408 -63
- package/dist/FloatingButton.js +25 -3
- package/dist/MachinePickerScreen.js +14 -5
- package/dist/P2PClient.d.ts +102 -1
- package/dist/P2PClient.js +313 -0
- package/dist/VibeChatScreen.d.ts +25 -0
- package/dist/VibeChatScreen.js +531 -0
- package/dist/_core/buildFeedbackPrompt.d.ts +13 -0
- package/dist/_core/buildFeedbackPrompt.js +77 -0
- package/dist/capture.d.ts +20 -0
- package/dist/capture.js +82 -0
- package/dist/preferences.d.ts +4 -0
- package/dist/preferences.js +62 -0
- package/dist/types.d.ts +66 -2
- package/dist/voice.d.ts +61 -0
- package/dist/voice.js +246 -0
- package/package.json +26 -3
- package/src/DeployPanel.tsx +403 -0
- package/src/FeedbackModal.tsx +510 -76
- package/src/FloatingButton.tsx +27 -3
- package/src/MachinePickerScreen.tsx +12 -3
- package/src/P2PClient.ts +326 -1
- package/src/VibeChatScreen.tsx +581 -0
- package/src/_core/buildFeedbackPrompt.ts +102 -0
- package/src/capture.ts +82 -0
- package/src/preferences.ts +56 -0
- package/src/types.ts +62 -2
- package/src/voice.ts +270 -0
package/src/capture.ts
CHANGED
|
@@ -38,6 +38,37 @@ export async function captureScreenshot(): Promise<string> {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Capture a screenshot AND return it as base64 + mime type, ready to
|
|
43
|
+
* embed in a `/tasks` payload's `images` array. Used by the converged
|
|
44
|
+
* vibe-feedback flow (FeedbackModal → P2PClient.createFeedbackTask).
|
|
45
|
+
*
|
|
46
|
+
* Returns null when capture isn't possible (peer dep missing / user
|
|
47
|
+
* permission denied / running in a context where view-shot can't
|
|
48
|
+
* grab the screen). Caller should treat null as "send without
|
|
49
|
+
* screenshot" rather than aborting the whole feedback.
|
|
50
|
+
*/
|
|
51
|
+
export async function captureScreenshotBase64(): Promise<{
|
|
52
|
+
base64: string;
|
|
53
|
+
mimeType: string;
|
|
54
|
+
} | null> {
|
|
55
|
+
try {
|
|
56
|
+
const ViewShot = require('react-native-view-shot');
|
|
57
|
+
const result = await ViewShot.captureScreen({
|
|
58
|
+
format: 'jpg',
|
|
59
|
+
quality: 0.7,
|
|
60
|
+
result: 'base64',
|
|
61
|
+
});
|
|
62
|
+
if (typeof result === 'string' && result.length > 0) {
|
|
63
|
+
// ViewShot returns a bare base64 string (no `data:` prefix).
|
|
64
|
+
return { base64: result, mimeType: 'image/jpeg' };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
41
72
|
export interface PickedFeedbackFile {
|
|
42
73
|
path: string;
|
|
43
74
|
name: string;
|
|
@@ -305,3 +336,54 @@ export async function stopAudioRecording(): Promise<{ path: string; duration: nu
|
|
|
305
336
|
export function isAudioRecording(): boolean {
|
|
306
337
|
return audioRecorderActive;
|
|
307
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/preferences.ts
CHANGED
|
@@ -149,3 +149,59 @@ export async function setQuickIconColorPreset(
|
|
|
149
149
|
export async function clearQuickIconColorPreset(): Promise<void> {
|
|
150
150
|
await setQuickIconColorPreset(null);
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
// ── Preferred coding agent + model (used by the standalone feedback
|
|
154
|
+
// SDK's vibe chat to mirror what Yaver mobile's Tasks tab would send.
|
|
155
|
+
// The agent on the remote DOES read userSettings.primaryRunnerByDevice
|
|
156
|
+
// from Convex, but the standalone SDK has no DeviceContext to push the
|
|
157
|
+
// per-device pick. We persist the user's last choice locally; first
|
|
158
|
+
// run picks whatever's signed-in via getRunnerStatus().)
|
|
159
|
+
|
|
160
|
+
const PREFERRED_RUNNER_KEY = 'yaver_feedback_preferred_runner';
|
|
161
|
+
const PREFERRED_MODEL_KEY = 'yaver_feedback_preferred_model';
|
|
162
|
+
|
|
163
|
+
export async function getPreferredRunner(): Promise<string | null> {
|
|
164
|
+
if (!AsyncStorage) return null;
|
|
165
|
+
try {
|
|
166
|
+
const v = await AsyncStorage.getItem(PREFERRED_RUNNER_KEY);
|
|
167
|
+
return v && v.trim() ? v.trim() : null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function setPreferredRunner(runner: string | null): Promise<void> {
|
|
174
|
+
if (!AsyncStorage) return;
|
|
175
|
+
try {
|
|
176
|
+
if (!runner || !runner.trim()) {
|
|
177
|
+
await AsyncStorage.removeItem(PREFERRED_RUNNER_KEY);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await AsyncStorage.setItem(PREFERRED_RUNNER_KEY, runner.trim());
|
|
181
|
+
} catch {
|
|
182
|
+
/* best-effort */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function getPreferredModel(): Promise<string | null> {
|
|
187
|
+
if (!AsyncStorage) return null;
|
|
188
|
+
try {
|
|
189
|
+
const v = await AsyncStorage.getItem(PREFERRED_MODEL_KEY);
|
|
190
|
+
return v && v.trim() ? v.trim() : null;
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function setPreferredModel(model: string | null): Promise<void> {
|
|
197
|
+
if (!AsyncStorage) return;
|
|
198
|
+
try {
|
|
199
|
+
if (!model || !model.trim()) {
|
|
200
|
+
await AsyncStorage.removeItem(PREFERRED_MODEL_KEY);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await AsyncStorage.setItem(PREFERRED_MODEL_KEY, model.trim());
|
|
204
|
+
} catch {
|
|
205
|
+
/* best-effort */
|
|
206
|
+
}
|
|
207
|
+
}
|
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;
|
|
@@ -123,6 +166,15 @@ export interface FeedbackConfig {
|
|
|
123
166
|
preferredDeviceId?: string;
|
|
124
167
|
/** How feedback collection is triggered */
|
|
125
168
|
trigger?: 'shake' | 'floating-button' | 'manual';
|
|
169
|
+
/**
|
|
170
|
+
* App slug used by the in-modal Deploy panel when calling the agent's
|
|
171
|
+
* `/fleet/deploy-options` and `/deploy/ship` endpoints. Should match an
|
|
172
|
+
* `apps[].name` entry in the agent's `yaver.workspace.yaml`. When omitted
|
|
173
|
+
* the panel falls back to the last dot-segment of `bundleId` (e.g.
|
|
174
|
+
* `io.yaver.sfmg` → `sfmg`). Set explicitly when the workspace name
|
|
175
|
+
* differs from the bundleId tail.
|
|
176
|
+
*/
|
|
177
|
+
deployAppSlug?: string;
|
|
126
178
|
/**
|
|
127
179
|
* Non-default escape hatch for host apps that want the SDK without
|
|
128
180
|
* shake gesture handling. When enabled:
|
|
@@ -442,12 +494,20 @@ export interface TestSession {
|
|
|
442
494
|
export interface VoiceCapability {
|
|
443
495
|
/** Always true — mobile can always record and send audio. */
|
|
444
496
|
voiceInputEnabled: boolean;
|
|
445
|
-
/** Speech-to-speech provider (
|
|
497
|
+
/** Speech-to-speech provider (legacy), or null. */
|
|
446
498
|
s2sProvider?: string;
|
|
447
499
|
/** Whether the S2S provider is ready for real-time sessions. */
|
|
448
500
|
s2sReady?: boolean;
|
|
449
|
-
/** Speech-to-text provider for transcription
|
|
501
|
+
/** Speech-to-text provider for transcription, e.g. "deepgram" for Deepgram Flux. */
|
|
450
502
|
sttProvider?: string;
|
|
451
503
|
/** Whether STT is ready (auto-transcription of voice input). */
|
|
452
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;
|
|
453
513
|
}
|
package/src/voice.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Voice vibe-coding for the feedback SDK — speak a change, the agent
|
|
2
|
+
// acts (full MCP), and reads back a spoken headline. Ported from the
|
|
3
|
+
// Yaver mobile app's AgentVoiceSession (mobile/src/lib/agentVoice.ts) but
|
|
4
|
+
// decoupled from the app: connection details (WS URL + auth headers) come
|
|
5
|
+
// from the SDK's P2PClient, so it works both inside the Yaver container
|
|
6
|
+
// (inherited auth) and standalone in a third-party app.
|
|
7
|
+
//
|
|
8
|
+
// Local vs remote STT/TTS is decided entirely agent-side (free local
|
|
9
|
+
// whisper.cpp by default, Deepgram/OpenAI/etc when keys are configured),
|
|
10
|
+
// so nothing model-related is bundled here — the SDK just streams mic
|
|
11
|
+
// audio and renders the transcript + TTS the agent sends back.
|
|
12
|
+
//
|
|
13
|
+
// expo-file-system + buffer are loaded lazily so a bare-RN app that
|
|
14
|
+
// hasn't installed them can still use the rest of the SDK; the voice
|
|
15
|
+
// button hides itself via isVoiceStreamSupported() when they're absent.
|
|
16
|
+
|
|
17
|
+
export interface SDKVoiceStartOpts {
|
|
18
|
+
/** WS URL for the agent voice stream — P2PClient.voiceStreamUrl(). */
|
|
19
|
+
wsUrl: string;
|
|
20
|
+
/** Auth headers — P2PClient.voiceAuthHeaders(). */
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
project?: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
runner?: string;
|
|
25
|
+
/** Surface hint for the agent's prompt wrapper. */
|
|
26
|
+
surface?: string;
|
|
27
|
+
/** Max chars for the spoken readback (Cartesia default ~280). */
|
|
28
|
+
ttsBudget?: number;
|
|
29
|
+
/** Per-session STT engine. "" = agent default. "local" = free
|
|
30
|
+
* whisper.cpp on the host; "deepgram" = Flux nova-3 streaming. */
|
|
31
|
+
sttProvider?: string;
|
|
32
|
+
/** Per-session TTS engine. "" = agent default. "local"/"device" =
|
|
33
|
+
* client synthesizes from the result text; cloud engines stream PCM. */
|
|
34
|
+
ttsProvider?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SDKVoiceCallbacks {
|
|
38
|
+
/** Active engines, echoed by the agent right after start — lets the UI
|
|
39
|
+
* show "Local" vs "Flux". */
|
|
40
|
+
onProviders?: (stt: string, tts: string) => void;
|
|
41
|
+
onTranscriptPartial?: (text: string) => void;
|
|
42
|
+
onTranscriptFinal?: (text: string) => void;
|
|
43
|
+
onTaskCreated?: (taskId: string) => void;
|
|
44
|
+
onTaskResult?: (taskId: string, text: string, status: string) => void;
|
|
45
|
+
onTTSReady?: (pcm: Uint8Array, sampleRate: number) => void;
|
|
46
|
+
onError?: (msg: string) => void;
|
|
47
|
+
onDone?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface WireMsg {
|
|
51
|
+
type: string;
|
|
52
|
+
text?: string;
|
|
53
|
+
taskId?: string;
|
|
54
|
+
status?: string;
|
|
55
|
+
pcm?: string;
|
|
56
|
+
sampleRate?: number;
|
|
57
|
+
error?: string;
|
|
58
|
+
stt?: string;
|
|
59
|
+
tts?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadBuffer(): any {
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
64
|
+
return require('buffer').Buffer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadFS(): any {
|
|
68
|
+
// expo-file-system v16+ moved the classic API under /legacy; fall back
|
|
69
|
+
// to the root export for older installs.
|
|
70
|
+
try {
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
72
|
+
return require('expo-file-system/legacy');
|
|
73
|
+
} catch {
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
75
|
+
return require('expo-file-system');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when the deps the voice stream needs (expo-file-system + buffer)
|
|
80
|
+
* are installed. The feedback UI hides the mic button otherwise. */
|
|
81
|
+
export function isVoiceStreamSupported(): boolean {
|
|
82
|
+
try {
|
|
83
|
+
loadBuffer();
|
|
84
|
+
const fs = loadFS();
|
|
85
|
+
return typeof fs?.readAsStringAsync === 'function';
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class SDKVoiceSession {
|
|
92
|
+
private ws: WebSocket | null = null;
|
|
93
|
+
private callbacks: SDKVoiceCallbacks;
|
|
94
|
+
private ttsChunks: Uint8Array[] = [];
|
|
95
|
+
private ttsTotalBytes = 0;
|
|
96
|
+
private ttsSampleRate = 22050;
|
|
97
|
+
private closed = false;
|
|
98
|
+
|
|
99
|
+
constructor(callbacks: SDKVoiceCallbacks) {
|
|
100
|
+
this.callbacks = callbacks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Open the WS and send the start frame. Resolves once open. */
|
|
104
|
+
async start(opts: SDKVoiceStartOpts): Promise<void> {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
let opened = false;
|
|
107
|
+
let ws: WebSocket;
|
|
108
|
+
try {
|
|
109
|
+
// RN-specific 3rd arg carries request headers.
|
|
110
|
+
ws = new (WebSocket as any)(opts.wsUrl, undefined, { headers: opts.headers });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
reject(e);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.ws = ws;
|
|
116
|
+
ws.onopen = () => {
|
|
117
|
+
opened = true;
|
|
118
|
+
try {
|
|
119
|
+
ws.send(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
type: 'start',
|
|
122
|
+
project: opts.project ?? '',
|
|
123
|
+
model: opts.model ?? '',
|
|
124
|
+
runner: opts.runner ?? '',
|
|
125
|
+
surface: opts.surface ?? '',
|
|
126
|
+
ttsBudget: opts.ttsBudget ?? 0,
|
|
127
|
+
sttProvider: opts.sttProvider ?? '',
|
|
128
|
+
ttsProvider: opts.ttsProvider ?? '',
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
resolve();
|
|
132
|
+
} catch (e) {
|
|
133
|
+
reject(e);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
ws.onmessage = (e: any) => this.handleMessage(e);
|
|
137
|
+
ws.onerror = () => {
|
|
138
|
+
if (!opened) reject(new Error('voice WS connect failed'));
|
|
139
|
+
else this.callbacks.onError?.('voice WS error');
|
|
140
|
+
};
|
|
141
|
+
ws.onclose = () => {
|
|
142
|
+
this.closed = true;
|
|
143
|
+
if (!opened) reject(new Error('voice WS closed before open'));
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Stream a recorded WAV/PCM file as binary frames. Records produced by
|
|
149
|
+
* recordPcmWav() are LPCM 16-bit/16kHz mono with a 44-byte RIFF header
|
|
150
|
+
* we strip here — the exact shape the backend → STT expects. */
|
|
151
|
+
async streamAudioFile(uri: string, opts?: { skipWavHeader?: boolean; chunkBytes?: number }): Promise<void> {
|
|
152
|
+
if (!this.ws || this.closed) throw new Error('voice WS not open');
|
|
153
|
+
const Buffer = loadBuffer();
|
|
154
|
+
const FileSystem = loadFS();
|
|
155
|
+
const skipWavHeader = opts?.skipWavHeader ?? true;
|
|
156
|
+
const chunkBytes = opts?.chunkBytes ?? 16384;
|
|
157
|
+
|
|
158
|
+
const b64 = await FileSystem.readAsStringAsync(uri, { encoding: 'base64' });
|
|
159
|
+
let buf = Buffer.from(b64, 'base64');
|
|
160
|
+
if (skipWavHeader && buf.length > 44 && buf.slice(0, 4).toString() === 'RIFF') {
|
|
161
|
+
buf = buf.slice(44);
|
|
162
|
+
}
|
|
163
|
+
for (let i = 0; i < buf.length; i += chunkBytes) {
|
|
164
|
+
if (this.closed) return;
|
|
165
|
+
const slice = buf.slice(i, Math.min(i + chunkBytes, buf.length));
|
|
166
|
+
this.ws.send(slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength));
|
|
167
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Done speaking — flush STT and create the agent task. */
|
|
172
|
+
finalize(): void {
|
|
173
|
+
if (this.ws && !this.closed) {
|
|
174
|
+
this.ws.send(JSON.stringify({ type: 'stop' }));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
close(): void {
|
|
179
|
+
this.closed = true;
|
|
180
|
+
try { this.ws?.close(); } catch { /* ignore */ }
|
|
181
|
+
this.ws = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private handleMessage(e: { data: any }): void {
|
|
185
|
+
let msg: WireMsg;
|
|
186
|
+
try {
|
|
187
|
+
msg = JSON.parse(typeof e.data === 'string' ? e.data : '');
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
switch (msg.type) {
|
|
192
|
+
case 'providers':
|
|
193
|
+
this.callbacks.onProviders?.(msg.stt ?? '', msg.tts ?? '');
|
|
194
|
+
break;
|
|
195
|
+
case 'transcript-partial':
|
|
196
|
+
this.callbacks.onTranscriptPartial?.(msg.text ?? '');
|
|
197
|
+
break;
|
|
198
|
+
case 'transcript-final':
|
|
199
|
+
this.callbacks.onTranscriptFinal?.(msg.text ?? '');
|
|
200
|
+
break;
|
|
201
|
+
case 'task-created':
|
|
202
|
+
this.callbacks.onTaskCreated?.(msg.taskId ?? '');
|
|
203
|
+
break;
|
|
204
|
+
case 'task-result':
|
|
205
|
+
this.callbacks.onTaskResult?.(msg.taskId ?? '', msg.text ?? '', msg.status ?? '');
|
|
206
|
+
break;
|
|
207
|
+
case 'tts-frame': {
|
|
208
|
+
if (!msg.pcm) break;
|
|
209
|
+
const Buffer = loadBuffer();
|
|
210
|
+
const arr = new Uint8Array(Buffer.from(msg.pcm, 'base64'));
|
|
211
|
+
this.ttsChunks.push(arr);
|
|
212
|
+
this.ttsTotalBytes += arr.length;
|
|
213
|
+
if (msg.sampleRate) this.ttsSampleRate = msg.sampleRate;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'done': {
|
|
217
|
+
if (this.ttsTotalBytes > 0) {
|
|
218
|
+
const pcm = new Uint8Array(this.ttsTotalBytes);
|
|
219
|
+
let off = 0;
|
|
220
|
+
for (const c of this.ttsChunks) { pcm.set(c, off); off += c.length; }
|
|
221
|
+
this.callbacks.onTTSReady?.(pcm, this.ttsSampleRate);
|
|
222
|
+
}
|
|
223
|
+
this.callbacks.onDone?.();
|
|
224
|
+
this.close();
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case 'error':
|
|
228
|
+
this.callbacks.onError?.(msg.error ?? 'voice error');
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Wrap raw PCM (signed 16-bit LE) in a minimal WAV container for
|
|
235
|
+
* expo-av playback. 44-byte header + samples. */
|
|
236
|
+
export function wrapPCMAsWAV(pcm: Uint8Array, sampleRate: number, channels = 1): Uint8Array {
|
|
237
|
+
const bitsPerSample = 16;
|
|
238
|
+
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
|
|
239
|
+
const blockAlign = (channels * bitsPerSample) / 8;
|
|
240
|
+
const dataSize = pcm.length;
|
|
241
|
+
const buf = new Uint8Array(44 + dataSize);
|
|
242
|
+
const dv = new DataView(buf.buffer);
|
|
243
|
+
buf[0] = 0x52; buf[1] = 0x49; buf[2] = 0x46; buf[3] = 0x46; // RIFF
|
|
244
|
+
dv.setUint32(4, 36 + dataSize, true);
|
|
245
|
+
buf[8] = 0x57; buf[9] = 0x41; buf[10] = 0x56; buf[11] = 0x45; // WAVE
|
|
246
|
+
buf[12] = 0x66; buf[13] = 0x6d; buf[14] = 0x74; buf[15] = 0x20; // "fmt "
|
|
247
|
+
dv.setUint32(16, 16, true);
|
|
248
|
+
dv.setUint16(20, 1, true);
|
|
249
|
+
dv.setUint16(22, channels, true);
|
|
250
|
+
dv.setUint32(24, sampleRate, true);
|
|
251
|
+
dv.setUint32(28, byteRate, true);
|
|
252
|
+
dv.setUint16(32, blockAlign, true);
|
|
253
|
+
dv.setUint16(34, bitsPerSample, true);
|
|
254
|
+
buf[36] = 0x64; buf[37] = 0x61; buf[38] = 0x74; buf[39] = 0x61; // data
|
|
255
|
+
dv.setUint32(40, dataSize, true);
|
|
256
|
+
buf.set(pcm, 44);
|
|
257
|
+
return buf;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Write a PCM buffer to a temp WAV file; returns its file:// URI. */
|
|
261
|
+
export async function pcmToTempWavURI(pcm: Uint8Array, sampleRate: number): Promise<string> {
|
|
262
|
+
const Buffer = loadBuffer();
|
|
263
|
+
const FileSystem = loadFS();
|
|
264
|
+
const wav = wrapPCMAsWAV(pcm, sampleRate);
|
|
265
|
+
const b64 = Buffer.from(wav).toString('base64');
|
|
266
|
+
const dir = FileSystem.cacheDirectory ?? FileSystem.documentDirectory ?? '';
|
|
267
|
+
const path = `${dir}yaver-voice-tts-${Date.now()}.wav`;
|
|
268
|
+
await FileSystem.writeAsStringAsync(path, b64, { encoding: 'base64' });
|
|
269
|
+
return path;
|
|
270
|
+
}
|