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.
@@ -53,7 +53,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
53
53
  exports.VibeChatScreen = VibeChatScreen;
54
54
  const react_1 = __importStar(require("react"));
55
55
  const react_native_1 = require("react-native");
56
- function VibeChatScreen({ client, initialTaskId, initialUserPrompt, onClose, onReload, }) {
56
+ const voice_1 = require("./voice");
57
+ const capture_1 = require("./capture");
58
+ function VibeChatScreen({ client, initialTaskId, initialUserPrompt, onClose, onReload, project, model, runner, }) {
57
59
  const [taskId, setTaskId] = (0, react_1.useState)(initialTaskId);
58
60
  const [turns, setTurns] = (0, react_1.useState)(() => [
59
61
  {
@@ -76,6 +78,16 @@ function VibeChatScreen({ client, initialTaskId, initialUserPrompt, onClose, onR
76
78
  const [isReloading, setIsReloading] = (0, react_1.useState)(false);
77
79
  const scrollRef = (0, react_1.useRef)(null);
78
80
  const abortRef = (0, react_1.useRef)(null);
81
+ // ── Voice vibe coding ──────────────────────────────────────────────
82
+ const [voiceState, setVoiceState] = (0, react_1.useState)('idle');
83
+ const [voiceAvailable, setVoiceAvailable] = (0, react_1.useState)(false);
84
+ // "local" = whisper.cpp on the host (free, private); "flux" = Deepgram
85
+ // nova-3 streaming. fluxAvailable gates the toggle on whether the agent
86
+ // has a Deepgram key. activeEngine is echoed back by the agent.
87
+ const [voiceMode, setVoiceMode] = (0, react_1.useState)('local');
88
+ const [fluxAvailable, setFluxAvailable] = (0, react_1.useState)(false);
89
+ const [activeEngine, setActiveEngine] = (0, react_1.useState)('');
90
+ const voiceSessionRef = (0, react_1.useRef)(null);
79
91
  // Subscribe to the current task's SSE stream. Re-runs whenever the
80
92
  // taskId changes (resumeTask reuses the same id, so this only fires
81
93
  // once per task — which is fine).
@@ -195,6 +207,170 @@ function VibeChatScreen({ client, initialTaskId, initialUserPrompt, onClose, onR
195
207
  setIsReloading(false);
196
208
  }
197
209
  }, [isReloading, onReload]);
210
+ // Probe whether voice is usable: deps present (expo-av + expo-file-
211
+ // system + buffer) AND the agent reports STT/TTS ready. Hide the mic
212
+ // entirely otherwise so users never tap a dead button.
213
+ (0, react_1.useEffect)(() => {
214
+ let cancelled = false;
215
+ (async () => {
216
+ if (!(0, capture_1.isVoiceCaptureSupported)() || !(0, voice_1.isVoiceStreamSupported)())
217
+ return;
218
+ try {
219
+ const res = await fetch(`${client.agentBaseUrl}/voice/status`, { headers: client.voiceAuthHeaders() });
220
+ if (!res.ok)
221
+ return;
222
+ const body = await res.json();
223
+ if (cancelled)
224
+ return;
225
+ // Local whisper is always usable when voice is enabled; Flux needs
226
+ // a Deepgram key on the agent. Show the mic if either path works.
227
+ const localOk = !!body?.enabled;
228
+ const fluxOk = !!body?.enabled && !!body?.deepgramSet;
229
+ if (localOk || fluxOk)
230
+ setVoiceAvailable(true);
231
+ setFluxAvailable(fluxOk);
232
+ if (!localOk && fluxOk)
233
+ setVoiceMode('flux');
234
+ }
235
+ catch { /* leave hidden */ }
236
+ })();
237
+ return () => { cancelled = true; };
238
+ }, [client]);
239
+ (0, react_1.useEffect)(() => () => { voiceSessionRef.current?.close(); }, []);
240
+ // Local TTS: the agent streams no audio for "local"/"device" engines,
241
+ // so the client speaks the result text with the device synthesizer.
242
+ // expo-speech is optional — if absent, the text is still shown.
243
+ const speakLocalText = (0, react_1.useCallback)((text) => {
244
+ if (!text)
245
+ return;
246
+ try {
247
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
248
+ const Speech = require('expo-speech');
249
+ const headline = text.length > 280 ? `${text.slice(0, 280)} — see screen for the rest.` : text;
250
+ Speech.stop?.();
251
+ Speech.speak?.(headline);
252
+ }
253
+ catch { /* expo-speech not installed — text remains visible */ }
254
+ }, []);
255
+ const playTTS = (0, react_1.useCallback)(async (pcm, sampleRate) => {
256
+ try {
257
+ const wavUri = await (0, voice_1.pcmToTempWavURI)(pcm, sampleRate);
258
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
259
+ const { Audio } = require('expo-av');
260
+ const { sound } = await Audio.Sound.createAsync({ uri: wavUri }, { shouldPlay: true });
261
+ sound.setOnPlaybackStatusUpdate((st) => {
262
+ if (st.didJustFinish)
263
+ sound.unloadAsync().catch(() => { });
264
+ });
265
+ }
266
+ catch { /* playback best-effort */ }
267
+ }, []);
268
+ const stopVoiceAndProcess = (0, react_1.useCallback)(async () => {
269
+ setVoiceState('uploading');
270
+ let uri = null;
271
+ try {
272
+ uri = await (0, capture_1.stopPcmRecording)();
273
+ }
274
+ catch (e) {
275
+ setVoiceState('idle');
276
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
277
+ return;
278
+ }
279
+ if (!uri) {
280
+ setVoiceState('idle');
281
+ return;
282
+ }
283
+ const useFlux = voiceMode === 'flux' && fluxAvailable;
284
+ const session = new voice_1.SDKVoiceSession({
285
+ onProviders: (stt, tts) => setActiveEngine(stt === 'deepgram' ? 'Flux (Deepgram)' : stt === 'local' ? 'Local (whisper)' : stt),
286
+ onTranscriptPartial: (t) => {
287
+ setTurns((prev) => {
288
+ const next = prev.filter((x) => x.id !== 'voice-partial');
289
+ next.push({ id: 'voice-partial', role: 'status', text: `🎙 ${t}`, timestamp: Date.now() });
290
+ return next;
291
+ });
292
+ },
293
+ onTranscriptFinal: (t) => {
294
+ setVoiceState('thinking');
295
+ setTurns((prev) => [
296
+ ...prev.filter((x) => x.id !== 'voice-partial'),
297
+ { id: `user-voice-${Date.now()}`, role: 'user', text: t, timestamp: Date.now() },
298
+ { id: `status-${Date.now()}`, role: 'status', text: 'thinking…', timestamp: Date.now() },
299
+ ]);
300
+ },
301
+ onTaskCreated: (id) => {
302
+ // Hand the chat's SSE subscription the new task so its agent
303
+ // output streams into the transcript exactly like a typed turn.
304
+ if (id) {
305
+ setStatus('running');
306
+ setStreamBuffer('');
307
+ setTaskId(id);
308
+ }
309
+ },
310
+ onTaskResult: (_id, text) => {
311
+ setVoiceState('speaking');
312
+ // Local TTS path: agent sends no audio frames, so speak here.
313
+ if (!useFlux)
314
+ speakLocalText(text);
315
+ },
316
+ onTTSReady: (pcm, sr) => { void playTTS(pcm, sr); },
317
+ onDone: () => setTimeout(() => setVoiceState('idle'), 1200),
318
+ onError: (msg) => {
319
+ setVoiceState('idle');
320
+ setTurns((prev) => [...prev.filter((x) => x.id !== 'voice-partial'), { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${msg}`, timestamp: Date.now() }]);
321
+ },
322
+ });
323
+ voiceSessionRef.current = session;
324
+ try {
325
+ await session.start({
326
+ wsUrl: client.voiceStreamUrl(),
327
+ headers: client.voiceAuthHeaders(),
328
+ project,
329
+ model,
330
+ runner,
331
+ surface: 'feedback-sdk',
332
+ ttsBudget: 280,
333
+ // Local: whisper.cpp on the host + device synth. Flux: Deepgram
334
+ // nova-3 STT + Aura TTS streamed back as PCM.
335
+ sttProvider: useFlux ? 'deepgram' : 'local',
336
+ ttsProvider: useFlux ? 'deepgram' : 'local',
337
+ });
338
+ await session.streamAudioFile(uri, { skipWavHeader: true });
339
+ session.finalize();
340
+ }
341
+ catch (e) {
342
+ setVoiceState('idle');
343
+ session.close();
344
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
345
+ }
346
+ }, [client, project, model, runner, playTTS, voiceMode, fluxAvailable, speakLocalText]);
347
+ const handleVoicePress = (0, react_1.useCallback)(async () => {
348
+ if (voiceState === 'recording') {
349
+ void stopVoiceAndProcess();
350
+ return;
351
+ }
352
+ if (voiceState !== 'idle') {
353
+ // Mid-flow tap cancels.
354
+ voiceSessionRef.current?.close();
355
+ voiceSessionRef.current = null;
356
+ setVoiceState('idle');
357
+ return;
358
+ }
359
+ try {
360
+ await (0, capture_1.startPcmRecording)();
361
+ setVoiceState('recording');
362
+ }
363
+ catch (e) {
364
+ setTurns((prev) => [...prev, { id: `status-verr-${Date.now()}`, role: 'status', text: `voice: ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now() }]);
365
+ }
366
+ }, [voiceState, stopVoiceAndProcess]);
367
+ const voiceLabel = {
368
+ idle: '🎙 speak',
369
+ recording: '■ stop',
370
+ uploading: 'sending…',
371
+ thinking: 'thinking…',
372
+ speaking: 'speaking…',
373
+ };
198
374
  return (<react_native_1.View style={styles.container}>
199
375
  <react_native_1.View style={styles.header}>
200
376
  <react_native_1.Text style={styles.title}>Vibe</react_native_1.Text>
@@ -226,8 +402,30 @@ function VibeChatScreen({ client, initialTaskId, initialUserPrompt, onClose, onR
226
402
  </react_native_1.ScrollView>
227
403
 
228
404
  <react_native_1.View style={styles.footer}>
405
+ {voiceAvailable && voiceState !== 'idle' && (<react_native_1.Text style={styles.engineCaption}>
406
+ {voiceState === 'recording' ? 'listening' : voiceState === 'uploading' ? 'sending' : voiceState === 'thinking' ? 'agent working' : 'speaking'}
407
+ {activeEngine ? ` · ${activeEngine}` : ` · ${voiceMode === 'flux' ? 'Flux (Deepgram)' : 'Local (whisper)'}`}
408
+ </react_native_1.Text>)}
229
409
  <react_native_1.TextInput style={styles.input} value={followUp} onChangeText={setFollowUp} placeholder={status === 'running' ? 'wait for the agent…' : 'follow up…'} placeholderTextColor="#666" editable={status !== 'running' && !isResuming} multiline/>
230
410
  <react_native_1.View style={styles.actions}>
411
+ {voiceAvailable && (<>
412
+ {/* Local ↔ Flux engine toggle. Only shows Flux when the
413
+ agent has a Deepgram key; otherwise the label just
414
+ states "Local" so the active engine is always clear. */}
415
+ {fluxAvailable ? (<react_native_1.TouchableOpacity style={[styles.actionBtn, styles.engineToggle]} onPress={() => setVoiceMode((m) => (m === 'local' ? 'flux' : 'local'))} disabled={voiceState !== 'idle'} accessibilityLabel="Toggle voice engine">
416
+ <react_native_1.Text style={styles.engineToggleText}>{voiceMode === 'flux' ? '⚡ Flux' : '🔒 Local'}</react_native_1.Text>
417
+ </react_native_1.TouchableOpacity>) : (<react_native_1.View style={[styles.actionBtn, styles.engineToggle]}>
418
+ <react_native_1.Text style={styles.engineToggleText}>🔒 Local</react_native_1.Text>
419
+ </react_native_1.View>)}
420
+ <react_native_1.TouchableOpacity style={[
421
+ styles.actionBtn,
422
+ styles.voiceBtn,
423
+ voiceState === 'recording' && styles.voiceBtnActive,
424
+ (voiceState === 'uploading' || voiceState === 'thinking' || voiceState === 'speaking') && styles.actionBtnDisabled,
425
+ ]} onPress={handleVoicePress} disabled={voiceState === 'uploading' || voiceState === 'thinking' || voiceState === 'speaking'} accessibilityLabel="Vibe code by voice">
426
+ <react_native_1.Text style={styles.actionText}>{voiceLabel[voiceState]}</react_native_1.Text>
427
+ </react_native_1.TouchableOpacity>
428
+ </>)}
231
429
  {onReload && (<react_native_1.TouchableOpacity style={[
232
430
  styles.actionBtn,
233
431
  styles.reloadBtn,
@@ -323,6 +521,11 @@ const styles = react_native_1.StyleSheet.create({
323
521
  },
324
522
  actionBtnDisabled: { opacity: 0.5 },
325
523
  reloadBtn: { backgroundColor: 'rgba(255,255,255,0.08)' },
524
+ voiceBtn: { backgroundColor: 'rgba(16,185,129,0.18)' },
525
+ voiceBtnActive: { backgroundColor: '#ef4444' },
526
+ engineToggle: { backgroundColor: 'rgba(255,255,255,0.06)', marginRight: 'auto', marginLeft: 0 },
527
+ engineToggleText: { color: '#cbd5e1', fontSize: 12, fontWeight: '600' },
528
+ engineCaption: { color: '#9ca3af', fontSize: 11, marginBottom: 6, marginLeft: 4 },
326
529
  sendBtn: { backgroundColor: '#7582f5' },
327
530
  actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
328
531
  });
package/dist/capture.d.ts CHANGED
@@ -88,3 +88,9 @@ export declare function stopAudioRecording(): Promise<{
88
88
  } | null>;
89
89
  /** Whether a voice-note recording is currently active. */
90
90
  export declare function isAudioRecording(): boolean;
91
+ /** Begin a raw-PCM recording for the voice stream. */
92
+ export declare function startPcmRecording(): Promise<void>;
93
+ /** Stop the voice recording; returns the WAV file:// URI (or null). */
94
+ export declare function stopPcmRecording(): Promise<string | null>;
95
+ /** Whether a voice-stream recording is currently active. */
96
+ export declare function isPcmRecording(): boolean;
package/dist/capture.js CHANGED
@@ -25,6 +25,9 @@ exports.isVoiceCaptureSupported = isVoiceCaptureSupported;
25
25
  exports.startAudioRecording = startAudioRecording;
26
26
  exports.stopAudioRecording = stopAudioRecording;
27
27
  exports.isAudioRecording = isAudioRecording;
28
+ exports.startPcmRecording = startPcmRecording;
29
+ exports.stopPcmRecording = stopPcmRecording;
30
+ exports.isPcmRecording = isPcmRecording;
28
31
  /**
29
32
  * Capture the current screen as a PNG image.
30
33
  * Requires `react-native-view-shot` to be installed.
@@ -307,3 +310,53 @@ async function stopAudioRecording() {
307
310
  function isAudioRecording() {
308
311
  return audioRecorderActive;
309
312
  }
313
+ // ── Voice-stream recording (raw LPCM WAV) ──────────────────────────────
314
+ // The voice vibe-coding path streams audio to the agent's STT WS, which
315
+ // expects raw 16-bit / 16 kHz mono PCM (we strip the WAV header on the
316
+ // way out). That's a different format from the HIGH_QUALITY m4a recorder
317
+ // above — a compressed .m4a can't be streamed to Deepgram/whisper — so
318
+ // this uses its own recording options, mirroring the Yaver app's
319
+ // AgentVoiceButton.
320
+ let pcmRecorderRef = null;
321
+ let pcmRecorderActive = false;
322
+ // Raw LPCM 16-bit LE, 16 kHz mono. iOS uses lpcm; Android records WAV.
323
+ const PCM_RECORDING_OPTIONS = {
324
+ android: { extension: '.wav', outputFormat: 2, audioEncoder: 3, sampleRate: 16000, numberOfChannels: 1, bitRate: 256000 },
325
+ ios: {
326
+ extension: '.wav', outputFormat: 'lpcm', audioQuality: 0x40, sampleRate: 16000,
327
+ numberOfChannels: 1, bitRate: 256000, linearPCMBitDepth: 16, linearPCMIsBigEndian: false, linearPCMIsFloat: false,
328
+ },
329
+ web: { mimeType: 'audio/wav', bitsPerSecond: 256000 },
330
+ };
331
+ /** Begin a raw-PCM recording for the voice stream. */
332
+ async function startPcmRecording() {
333
+ if (pcmRecorderActive)
334
+ throw new Error('[YaverFeedback] A voice recording is already in progress.');
335
+ const ExpoAv = loadExpoAvOrThrow();
336
+ const { Audio } = ExpoAv;
337
+ const perm = await Audio.requestPermissionsAsync();
338
+ if (!perm.granted) {
339
+ throw new Error('[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.');
340
+ }
341
+ await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, staysActiveInBackground: false });
342
+ const { recording } = await Audio.Recording.createAsync(PCM_RECORDING_OPTIONS);
343
+ pcmRecorderRef = recording;
344
+ pcmRecorderActive = true;
345
+ }
346
+ /** Stop the voice recording; returns the WAV file:// URI (or null). */
347
+ async function stopPcmRecording() {
348
+ if (!pcmRecorderActive || !pcmRecorderRef)
349
+ return null;
350
+ const recording = pcmRecorderRef;
351
+ pcmRecorderRef = null;
352
+ pcmRecorderActive = false;
353
+ try {
354
+ await recording.stopAndUnloadAsync();
355
+ }
356
+ catch { /* already stopped */ }
357
+ return typeof recording.getURI === 'function' ? recording.getURI() : null;
358
+ }
359
+ /** Whether a voice-stream recording is currently active. */
360
+ function isPcmRecording() {
361
+ return pcmRecorderActive;
362
+ }
package/dist/types.d.ts CHANGED
@@ -19,6 +19,53 @@ export interface RunnerBrowserAuthSession {
19
19
  updatedAt: number;
20
20
  completedAt?: number;
21
21
  }
22
+ export interface RunnerAuthStatusRow {
23
+ id: string;
24
+ name: string;
25
+ installed: boolean;
26
+ ready: boolean;
27
+ authConfigured: boolean;
28
+ authSource?: string;
29
+ warning?: string;
30
+ error?: string;
31
+ path?: string;
32
+ detail?: string;
33
+ version?: string;
34
+ }
35
+ export interface OpenCodeProviderSummary {
36
+ id: string;
37
+ name?: string;
38
+ hasApiKey?: boolean;
39
+ baseUrl?: string;
40
+ models?: Array<{
41
+ id: string;
42
+ name?: string;
43
+ provider?: string;
44
+ }>;
45
+ }
46
+ export interface OpenCodeAgentSummary {
47
+ name: string;
48
+ model?: string;
49
+ description?: string;
50
+ isBuiltin?: boolean;
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<{
62
+ id: string;
63
+ name?: string;
64
+ provider?: string;
65
+ }>;
66
+ agents?: OpenCodeAgentSummary[];
67
+ diagnostics?: string[];
68
+ }
22
69
  export interface IncidentEvent {
23
70
  id: string;
24
71
  timestamp: number;
@@ -438,12 +485,20 @@ export interface TestSession {
438
485
  export interface VoiceCapability {
439
486
  /** Always true — mobile can always record and send audio. */
440
487
  voiceInputEnabled: boolean;
441
- /** Speech-to-speech provider (e.g. "personaplex", "openai"), or null. */
488
+ /** Speech-to-speech provider (legacy), or null. */
442
489
  s2sProvider?: string;
443
490
  /** Whether the S2S provider is ready for real-time sessions. */
444
491
  s2sReady?: boolean;
445
- /** Speech-to-text provider for transcription (e.g. "whisper", "openai"). */
492
+ /** Speech-to-text provider for transcription, e.g. "deepgram" for Deepgram Flux. */
446
493
  sttProvider?: string;
447
494
  /** Whether STT is ready (auto-transcription of voice input). */
448
495
  sttReady?: boolean;
496
+ /** Text-to-speech provider for readback, e.g. "cartesia". */
497
+ ttsProvider?: string;
498
+ /** Whether TTS readback is ready. */
499
+ ttsReady?: boolean;
500
+ /** Whether the agent-side hands-free task loop is enabled. */
501
+ enabled?: boolean;
502
+ /** Default project slug used by the agent voice loop. */
503
+ defaultProject?: string;
449
504
  }
@@ -0,0 +1,61 @@
1
+ export interface SDKVoiceStartOpts {
2
+ /** WS URL for the agent voice stream — P2PClient.voiceStreamUrl(). */
3
+ wsUrl: string;
4
+ /** Auth headers — P2PClient.voiceAuthHeaders(). */
5
+ headers: Record<string, string>;
6
+ project?: string;
7
+ model?: string;
8
+ runner?: string;
9
+ /** Surface hint for the agent's prompt wrapper. */
10
+ surface?: string;
11
+ /** Max chars for the spoken readback (Cartesia default ~280). */
12
+ ttsBudget?: number;
13
+ /** Per-session STT engine. "" = agent default. "local" = free
14
+ * whisper.cpp on the host; "deepgram" = Flux nova-3 streaming. */
15
+ sttProvider?: string;
16
+ /** Per-session TTS engine. "" = agent default. "local"/"device" =
17
+ * client synthesizes from the result text; cloud engines stream PCM. */
18
+ ttsProvider?: string;
19
+ }
20
+ export interface SDKVoiceCallbacks {
21
+ /** Active engines, echoed by the agent right after start — lets the UI
22
+ * show "Local" vs "Flux". */
23
+ onProviders?: (stt: string, tts: string) => void;
24
+ onTranscriptPartial?: (text: string) => void;
25
+ onTranscriptFinal?: (text: string) => void;
26
+ onTaskCreated?: (taskId: string) => void;
27
+ onTaskResult?: (taskId: string, text: string, status: string) => void;
28
+ onTTSReady?: (pcm: Uint8Array, sampleRate: number) => void;
29
+ onError?: (msg: string) => void;
30
+ onDone?: () => void;
31
+ }
32
+ /** True when the deps the voice stream needs (expo-file-system + buffer)
33
+ * are installed. The feedback UI hides the mic button otherwise. */
34
+ export declare function isVoiceStreamSupported(): boolean;
35
+ export declare class SDKVoiceSession {
36
+ private ws;
37
+ private callbacks;
38
+ private ttsChunks;
39
+ private ttsTotalBytes;
40
+ private ttsSampleRate;
41
+ private closed;
42
+ constructor(callbacks: SDKVoiceCallbacks);
43
+ /** Open the WS and send the start frame. Resolves once open. */
44
+ start(opts: SDKVoiceStartOpts): Promise<void>;
45
+ /** Stream a recorded WAV/PCM file as binary frames. Records produced by
46
+ * recordPcmWav() are LPCM 16-bit/16kHz mono with a 44-byte RIFF header
47
+ * we strip here — the exact shape the backend → STT expects. */
48
+ streamAudioFile(uri: string, opts?: {
49
+ skipWavHeader?: boolean;
50
+ chunkBytes?: number;
51
+ }): Promise<void>;
52
+ /** Done speaking — flush STT and create the agent task. */
53
+ finalize(): void;
54
+ close(): void;
55
+ private handleMessage;
56
+ }
57
+ /** Wrap raw PCM (signed 16-bit LE) in a minimal WAV container for
58
+ * expo-av playback. 44-byte header + samples. */
59
+ export declare function wrapPCMAsWAV(pcm: Uint8Array, sampleRate: number, channels?: number): Uint8Array;
60
+ /** Write a PCM buffer to a temp WAV file; returns its file:// URI. */
61
+ export declare function pcmToTempWavURI(pcm: Uint8Array, sampleRate: number): Promise<string>;