yaver-feedback-react-native 0.7.14 → 0.7.15
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 +8 -1
- package/dist/FeedbackModal.js +104 -0
- package/dist/capture.d.ts +29 -4
- package/dist/capture.js +112 -4
- package/package.json +1 -1
- package/src/FeedbackModal.tsx +120 -1
- package/src/capture.ts +117 -4
package/app.plugin.js
CHANGED
|
@@ -270,8 +270,15 @@ function withYaverAppDelegateHook(config) {
|
|
|
270
270
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
271
271
|
guard let self = self else { return }
|
|
272
272
|
|
|
273
|
+
// No explicit override needed — the new delegate's bundleURL()
|
|
274
|
+
// has a patched branch that calls YaverHotReload.bundleURL()
|
|
275
|
+
// first, which returns the file we just saved. That avoids
|
|
276
|
+
// relying on a non-existent overrideBundleURL property on
|
|
277
|
+
// ExpoReactNativeFactoryDelegate (which is what errored out in
|
|
278
|
+
// 0.7.14's build on Expo SDK 54+).
|
|
279
|
+
_ = bundleURL // silence "unused" warning; the file path was
|
|
280
|
+
// baked into YaverHotReload by loadBundle() above
|
|
273
281
|
let delegate = ReactNativeDelegate()
|
|
274
|
-
delegate.overrideBundleURL = bundleURL
|
|
275
282
|
delegate.dependencyProvider = RCTAppDependencyProvider()
|
|
276
283
|
|
|
277
284
|
let factory = ExpoReactNativeFactory(delegate: delegate)
|
package/dist/FeedbackModal.js
CHANGED
|
@@ -47,6 +47,10 @@ const FeedbackModal = () => {
|
|
|
47
47
|
const [toast, setToast] = (0, react_1.useState)(null);
|
|
48
48
|
const [progress, setProgress] = (0, react_1.useState)(null);
|
|
49
49
|
const [isRecordingVideo, setIsRecordingVideo] = (0, react_1.useState)(false);
|
|
50
|
+
const [isRecordingVoice, setIsRecordingVoice] = (0, react_1.useState)(false);
|
|
51
|
+
// Cached once on mount — bare-RN apps without expo-av get a clean
|
|
52
|
+
// hidden button instead of a runtime error.
|
|
53
|
+
const voiceSupported = (0, react_1.useRef)((0, capture_1.isVoiceCaptureSupported)()).current;
|
|
50
54
|
const [lastVideo, setLastVideo] = (0, react_1.useState)(null);
|
|
51
55
|
const mountedRef = (0, react_1.useRef)(true);
|
|
52
56
|
(0, react_1.useEffect)(() => {
|
|
@@ -324,6 +328,97 @@ const FeedbackModal = () => {
|
|
|
324
328
|
}
|
|
325
329
|
}
|
|
326
330
|
}, [isRecordingVideo]);
|
|
331
|
+
// ─── Voice note: record → transcribe → send as feedback ───────────
|
|
332
|
+
// Tap once to start; tap again to stop. On stop: upload the audio
|
|
333
|
+
// to the agent's /voice/transcribe (which routes through whichever
|
|
334
|
+
// STT provider is configured — Whisper / Deepgram / OpenAI / etc.),
|
|
335
|
+
// then file the transcript as a bug report. The audio file itself
|
|
336
|
+
// is also attached to the feedback bundle so the agent can re-play
|
|
337
|
+
// it if the transcript is wrong.
|
|
338
|
+
const handleToggleVoice = (0, react_1.useCallback)(async () => {
|
|
339
|
+
setError(null);
|
|
340
|
+
if (!isRecordingVoice) {
|
|
341
|
+
try {
|
|
342
|
+
await (0, capture_1.startAudioRecording)();
|
|
343
|
+
if (mountedRef.current) {
|
|
344
|
+
setIsRecordingVoice(true);
|
|
345
|
+
setAction('recording-voice');
|
|
346
|
+
setToast('Recording voice note…');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
setIsRecordingVoice(false);
|
|
351
|
+
setAction('idle');
|
|
352
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Stopping → transcribe → send.
|
|
357
|
+
try {
|
|
358
|
+
const audio = await (0, capture_1.stopAudioRecording)();
|
|
359
|
+
setIsRecordingVoice(false);
|
|
360
|
+
if (!audio) {
|
|
361
|
+
setAction('idle');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
setAction('transcribing-voice');
|
|
365
|
+
setToast('Transcribing…');
|
|
366
|
+
const config = YaverFeedback_1.YaverFeedback.getConfig();
|
|
367
|
+
if (!config?.agentUrl) {
|
|
368
|
+
setError('Not connected to the agent yet.');
|
|
369
|
+
setAction('idle');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let transcript = '';
|
|
373
|
+
try {
|
|
374
|
+
const client = YaverFeedback_1.YaverFeedback.getP2PClient();
|
|
375
|
+
if (client) {
|
|
376
|
+
const res = await client.transcribeVoice(audio.path);
|
|
377
|
+
transcript = res.text ?? '';
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Transcription can fail (no STT provider configured on the
|
|
382
|
+
// agent, network blip, etc.). Don't block the flow — ship
|
|
383
|
+
// the raw audio file with a "[no transcript]" note so the
|
|
384
|
+
// agent + human reviewer can still play it back.
|
|
385
|
+
}
|
|
386
|
+
const { Dimensions } = require('react-native');
|
|
387
|
+
const { width, height } = Dimensions.get('window');
|
|
388
|
+
const deviceInfo = {
|
|
389
|
+
platform: react_native_1.Platform.OS,
|
|
390
|
+
osVersion: String(react_native_1.Platform.Version),
|
|
391
|
+
model: react_native_1.Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
|
|
392
|
+
screenWidth: width,
|
|
393
|
+
screenHeight: height,
|
|
394
|
+
};
|
|
395
|
+
const bundle = {
|
|
396
|
+
metadata: {
|
|
397
|
+
timestamp: new Date().toISOString(),
|
|
398
|
+
device: deviceInfo,
|
|
399
|
+
app: {},
|
|
400
|
+
userNote: transcript.length > 0
|
|
401
|
+
? `[Voice note] ${transcript}`
|
|
402
|
+
: `[Voice note · ${Math.round(audio.duration)}s — transcription unavailable]`,
|
|
403
|
+
},
|
|
404
|
+
screenshots: [],
|
|
405
|
+
audio: audio.path,
|
|
406
|
+
errors: YaverFeedback_1.YaverFeedback.getCapturedErrors().length
|
|
407
|
+
? YaverFeedback_1.YaverFeedback.getCapturedErrors()
|
|
408
|
+
: undefined,
|
|
409
|
+
};
|
|
410
|
+
await (0, upload_1.uploadFeedback)(config.agentUrl, config.authToken ?? '', bundle);
|
|
411
|
+
setToast(transcript ? `Sent: "${transcript.slice(0, 60)}${transcript.length > 60 ? '…' : ''}"` : 'Voice note sent');
|
|
412
|
+
closeSoon(1800);
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
if (mountedRef.current)
|
|
419
|
+
setAction('idle');
|
|
420
|
+
}
|
|
421
|
+
}, [isRecordingVoice, closeSoon]);
|
|
327
422
|
// ─── 5. Send the last recorded video ───────────────────────────────
|
|
328
423
|
const handleSendVideo = (0, react_1.useCallback)(async () => {
|
|
329
424
|
const config = YaverFeedback_1.YaverFeedback.getConfig();
|
|
@@ -397,6 +492,15 @@ const FeedbackModal = () => {
|
|
|
397
492
|
{/* 3. Vibing */}
|
|
398
493
|
<ActionRow label={action === 'vibing' ? 'Starting…' : 'Vibing'} tint="#818cf8" onPress={handleVibing} disabled={busy} busy={action === 'vibing'}/>
|
|
399
494
|
|
|
495
|
+
{/* Voice note — only rendered when expo-av is installed.
|
|
496
|
+
Tap to start, tap again to stop → transcribes via
|
|
497
|
+
the agent and files as a feedback report. */}
|
|
498
|
+
{voiceSupported && (<ActionRow label={action === 'transcribing-voice'
|
|
499
|
+
? 'Transcribing…'
|
|
500
|
+
: isRecordingVoice
|
|
501
|
+
? 'Stop & Send Voice'
|
|
502
|
+
: 'Voice Note'} tint={isRecordingVoice ? '#ef4444' : '#f472b6'} onPress={handleToggleVoice} disabled={busy && action !== 'recording-voice' && action !== 'idle'} busy={action === 'transcribing-voice'}/>)}
|
|
503
|
+
|
|
400
504
|
{/* 4. Start/Stop Recording */}
|
|
401
505
|
<ActionRow label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'} tint={isRecordingVideo ? '#ef4444' : '#60a5fa'} onPress={handleToggleRecording} disabled={busy && action !== 'idle' && !isRecordingVideo}/>
|
|
402
506
|
|
package/dist/capture.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Screen capture helpers — screenshot + video recording.
|
|
2
|
+
* Screen capture helpers — screenshot + video + voice recording.
|
|
3
3
|
*
|
|
4
4
|
* Peer deps (all optional — loaded lazily):
|
|
5
5
|
* - `react-native-view-shot` — screenshot
|
|
6
6
|
* - `react-native-record-screen` — video recording (iOS ReplayKit /
|
|
7
7
|
* Android MediaProjection)
|
|
8
|
+
* - `expo-av` — audio recording for voice notes
|
|
8
9
|
*
|
|
9
10
|
* Each helper surfaces a clear error if the module is missing so a host
|
|
10
|
-
* app knows exactly which peer dep to add.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* app knows exactly which peer dep to add. Voice-note capture was
|
|
12
|
+
* removed in 0.7.0 and re-added in 0.7.14 as a narrow, "press to
|
|
13
|
+
* record → stop → transcribe via the agent → attach to feedback" flow.
|
|
14
|
+
* The broader live/narrated/batch modes from pre-0.7.0 stay removed.
|
|
13
15
|
*/
|
|
14
16
|
/**
|
|
15
17
|
* Capture the current screen as a PNG image.
|
|
@@ -38,3 +40,26 @@ export declare function stopVideoRecording(): Promise<{
|
|
|
38
40
|
}>;
|
|
39
41
|
/** Whether a video recording is currently active. */
|
|
40
42
|
export declare function isVideoRecording(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Returns true if `expo-av` is installed and Audio.Recording is
|
|
45
|
+
* available — lets the modal hide the voice button in apps that
|
|
46
|
+
* haven't installed the peer dep, instead of throwing on tap.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isVoiceCaptureSupported(): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Begin recording audio from the device microphone. Requests
|
|
51
|
+
* microphone permission on first use. Resolves once recording has
|
|
52
|
+
* actually started, so the UI can flip to "Stop" immediately.
|
|
53
|
+
*/
|
|
54
|
+
export declare function startAudioRecording(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Stop the current audio recording and return the on-device file path
|
|
57
|
+
* (usually a .m4a on iOS / .3gp on Android). Returns null if no
|
|
58
|
+
* recording was active.
|
|
59
|
+
*/
|
|
60
|
+
export declare function stopAudioRecording(): Promise<{
|
|
61
|
+
path: string;
|
|
62
|
+
duration: number;
|
|
63
|
+
} | null>;
|
|
64
|
+
/** Whether a voice-note recording is currently active. */
|
|
65
|
+
export declare function isAudioRecording(): boolean;
|
package/dist/capture.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Screen capture helpers — screenshot + video recording.
|
|
3
|
+
* Screen capture helpers — screenshot + video + voice recording.
|
|
4
4
|
*
|
|
5
5
|
* Peer deps (all optional — loaded lazily):
|
|
6
6
|
* - `react-native-view-shot` — screenshot
|
|
7
7
|
* - `react-native-record-screen` — video recording (iOS ReplayKit /
|
|
8
8
|
* Android MediaProjection)
|
|
9
|
+
* - `expo-av` — audio recording for voice notes
|
|
9
10
|
*
|
|
10
11
|
* Each helper surfaces a clear error if the module is missing so a host
|
|
11
|
-
* app knows exactly which peer dep to add.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* app knows exactly which peer dep to add. Voice-note capture was
|
|
13
|
+
* removed in 0.7.0 and re-added in 0.7.14 as a narrow, "press to
|
|
14
|
+
* record → stop → transcribe via the agent → attach to feedback" flow.
|
|
15
|
+
* The broader live/narrated/batch modes from pre-0.7.0 stay removed.
|
|
14
16
|
*/
|
|
15
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
18
|
exports.captureScreenshot = captureScreenshot;
|
|
17
19
|
exports.startVideoRecording = startVideoRecording;
|
|
18
20
|
exports.stopVideoRecording = stopVideoRecording;
|
|
19
21
|
exports.isVideoRecording = isVideoRecording;
|
|
22
|
+
exports.isVoiceCaptureSupported = isVoiceCaptureSupported;
|
|
23
|
+
exports.startAudioRecording = startAudioRecording;
|
|
24
|
+
exports.stopAudioRecording = stopAudioRecording;
|
|
25
|
+
exports.isAudioRecording = isAudioRecording;
|
|
20
26
|
/**
|
|
21
27
|
* Capture the current screen as a PNG image.
|
|
22
28
|
* Requires `react-native-view-shot` to be installed.
|
|
@@ -111,3 +117,105 @@ async function stopVideoRecording() {
|
|
|
111
117
|
function isVideoRecording() {
|
|
112
118
|
return videoRecordingActive;
|
|
113
119
|
}
|
|
120
|
+
// ── Voice note recording ────────────────────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// Short audio recording, stopped on user tap. Lazy-loaded via expo-av —
|
|
123
|
+
// the SDK doesn't declare a hard dep on it so bare-RN apps that don't
|
|
124
|
+
// have Expo can still install the SDK. If expo-av is missing, the
|
|
125
|
+
// helpers throw a clear error and the modal's voice button gracefully
|
|
126
|
+
// hides itself.
|
|
127
|
+
let audioRecorderRef = null;
|
|
128
|
+
let audioRecorderActive = false;
|
|
129
|
+
function loadExpoAvOrThrow() {
|
|
130
|
+
try {
|
|
131
|
+
const mod = require('expo-av');
|
|
132
|
+
if (!mod?.Audio?.Recording) {
|
|
133
|
+
throw new Error('expo-av is installed but missing Audio.Recording');
|
|
134
|
+
}
|
|
135
|
+
return mod;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw new Error('[YaverFeedback] Voice notes need `expo-av` as a peer dependency. ' +
|
|
139
|
+
'Add it with `npx expo install expo-av` and rebuild. ' +
|
|
140
|
+
String(err));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Returns true if `expo-av` is installed and Audio.Recording is
|
|
145
|
+
* available — lets the modal hide the voice button in apps that
|
|
146
|
+
* haven't installed the peer dep, instead of throwing on tap.
|
|
147
|
+
*/
|
|
148
|
+
function isVoiceCaptureSupported() {
|
|
149
|
+
try {
|
|
150
|
+
const mod = require('expo-av');
|
|
151
|
+
return !!mod?.Audio?.Recording;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Begin recording audio from the device microphone. Requests
|
|
159
|
+
* microphone permission on first use. Resolves once recording has
|
|
160
|
+
* actually started, so the UI can flip to "Stop" immediately.
|
|
161
|
+
*/
|
|
162
|
+
async function startAudioRecording() {
|
|
163
|
+
if (audioRecorderActive) {
|
|
164
|
+
throw new Error('[YaverFeedback] An audio recording is already in progress.');
|
|
165
|
+
}
|
|
166
|
+
const ExpoAv = loadExpoAvOrThrow();
|
|
167
|
+
const { Audio } = ExpoAv;
|
|
168
|
+
const perm = await Audio.requestPermissionsAsync();
|
|
169
|
+
if (!perm.granted) {
|
|
170
|
+
throw new Error('[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.');
|
|
171
|
+
}
|
|
172
|
+
// Use the iOS/Android high-quality preset — transcription providers
|
|
173
|
+
// (Whisper / Deepgram / OpenAI) prefer 16 kHz+ mono but also handle
|
|
174
|
+
// the higher sample rates fine. Default preset is portable.
|
|
175
|
+
await Audio.setAudioModeAsync({
|
|
176
|
+
allowsRecordingIOS: true,
|
|
177
|
+
playsInSilentModeIOS: true,
|
|
178
|
+
staysActiveInBackground: false,
|
|
179
|
+
});
|
|
180
|
+
const recording = new Audio.Recording();
|
|
181
|
+
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
|
182
|
+
await recording.startAsync();
|
|
183
|
+
audioRecorderRef = recording;
|
|
184
|
+
audioRecorderActive = true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Stop the current audio recording and return the on-device file path
|
|
188
|
+
* (usually a .m4a on iOS / .3gp on Android). Returns null if no
|
|
189
|
+
* recording was active.
|
|
190
|
+
*/
|
|
191
|
+
async function stopAudioRecording() {
|
|
192
|
+
if (!audioRecorderActive || !audioRecorderRef)
|
|
193
|
+
return null;
|
|
194
|
+
const recording = audioRecorderRef;
|
|
195
|
+
audioRecorderRef = null;
|
|
196
|
+
audioRecorderActive = false;
|
|
197
|
+
try {
|
|
198
|
+
await recording.stopAndUnloadAsync();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Second stop calls throw; ignore and use whatever state we have.
|
|
202
|
+
}
|
|
203
|
+
const uri = typeof recording.getURI === 'function' ? recording.getURI() : null;
|
|
204
|
+
if (!uri) {
|
|
205
|
+
throw new Error('[YaverFeedback] Audio recording produced no file.');
|
|
206
|
+
}
|
|
207
|
+
let durationMs = 0;
|
|
208
|
+
try {
|
|
209
|
+
const status = await recording.getStatusAsync();
|
|
210
|
+
durationMs = status?.durationMillis ?? 0;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Status can fail after unload; leave duration at 0, transcription
|
|
214
|
+
// still works.
|
|
215
|
+
}
|
|
216
|
+
return { path: uri, duration: durationMs / 1000 };
|
|
217
|
+
}
|
|
218
|
+
/** Whether a voice-note recording is currently active. */
|
|
219
|
+
function isAudioRecording() {
|
|
220
|
+
return audioRecorderActive;
|
|
221
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaver-feedback-react-native",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.15",
|
|
4
4
|
"description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice annotations, and local-first developer workflows",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/src/FeedbackModal.tsx
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
captureScreenshot,
|
|
15
15
|
startVideoRecording,
|
|
16
16
|
stopVideoRecording,
|
|
17
|
+
startAudioRecording,
|
|
18
|
+
stopAudioRecording,
|
|
19
|
+
isVoiceCaptureSupported,
|
|
17
20
|
} from './capture';
|
|
18
21
|
import { uploadFeedback } from './upload';
|
|
19
22
|
import { DeviceInfo, FeedbackBundle } from './types';
|
|
@@ -46,7 +49,9 @@ type ActionState =
|
|
|
46
49
|
| 'hot-reloading'
|
|
47
50
|
| 'capturing'
|
|
48
51
|
| 'vibing'
|
|
49
|
-
| 'sending-video'
|
|
52
|
+
| 'sending-video'
|
|
53
|
+
| 'recording-voice'
|
|
54
|
+
| 'transcribing-voice';
|
|
50
55
|
|
|
51
56
|
export const FeedbackModal: React.FC = () => {
|
|
52
57
|
const [visible, setVisible] = useState(false);
|
|
@@ -55,6 +60,10 @@ export const FeedbackModal: React.FC = () => {
|
|
|
55
60
|
const [toast, setToast] = useState<string | null>(null);
|
|
56
61
|
const [progress, setProgress] = useState<number | null>(null);
|
|
57
62
|
const [isRecordingVideo, setIsRecordingVideo] = useState(false);
|
|
63
|
+
const [isRecordingVoice, setIsRecordingVoice] = useState(false);
|
|
64
|
+
// Cached once on mount — bare-RN apps without expo-av get a clean
|
|
65
|
+
// hidden button instead of a runtime error.
|
|
66
|
+
const voiceSupported = useRef<boolean>(isVoiceCaptureSupported()).current;
|
|
58
67
|
const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
|
|
59
68
|
const mountedRef = useRef(true);
|
|
60
69
|
|
|
@@ -339,6 +348,97 @@ export const FeedbackModal: React.FC = () => {
|
|
|
339
348
|
}
|
|
340
349
|
}, [isRecordingVideo]);
|
|
341
350
|
|
|
351
|
+
// ─── Voice note: record → transcribe → send as feedback ───────────
|
|
352
|
+
// Tap once to start; tap again to stop. On stop: upload the audio
|
|
353
|
+
// to the agent's /voice/transcribe (which routes through whichever
|
|
354
|
+
// STT provider is configured — Whisper / Deepgram / OpenAI / etc.),
|
|
355
|
+
// then file the transcript as a bug report. The audio file itself
|
|
356
|
+
// is also attached to the feedback bundle so the agent can re-play
|
|
357
|
+
// it if the transcript is wrong.
|
|
358
|
+
const handleToggleVoice = useCallback(async () => {
|
|
359
|
+
setError(null);
|
|
360
|
+
if (!isRecordingVoice) {
|
|
361
|
+
try {
|
|
362
|
+
await startAudioRecording();
|
|
363
|
+
if (mountedRef.current) {
|
|
364
|
+
setIsRecordingVoice(true);
|
|
365
|
+
setAction('recording-voice');
|
|
366
|
+
setToast('Recording voice note…');
|
|
367
|
+
}
|
|
368
|
+
} catch (err: unknown) {
|
|
369
|
+
setIsRecordingVoice(false);
|
|
370
|
+
setAction('idle');
|
|
371
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Stopping → transcribe → send.
|
|
377
|
+
try {
|
|
378
|
+
const audio = await stopAudioRecording();
|
|
379
|
+
setIsRecordingVoice(false);
|
|
380
|
+
if (!audio) {
|
|
381
|
+
setAction('idle');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
setAction('transcribing-voice');
|
|
385
|
+
setToast('Transcribing…');
|
|
386
|
+
|
|
387
|
+
const config = YaverFeedback.getConfig();
|
|
388
|
+
if (!config?.agentUrl) {
|
|
389
|
+
setError('Not connected to the agent yet.');
|
|
390
|
+
setAction('idle');
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
let transcript = '';
|
|
394
|
+
try {
|
|
395
|
+
const client = YaverFeedback.getP2PClient();
|
|
396
|
+
if (client) {
|
|
397
|
+
const res = await client.transcribeVoice(audio.path);
|
|
398
|
+
transcript = res.text ?? '';
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
// Transcription can fail (no STT provider configured on the
|
|
402
|
+
// agent, network blip, etc.). Don't block the flow — ship
|
|
403
|
+
// the raw audio file with a "[no transcript]" note so the
|
|
404
|
+
// agent + human reviewer can still play it back.
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const { Dimensions } = require('react-native');
|
|
408
|
+
const { width, height } = Dimensions.get('window');
|
|
409
|
+
const deviceInfo: DeviceInfo = {
|
|
410
|
+
platform: Platform.OS,
|
|
411
|
+
osVersion: String(Platform.Version),
|
|
412
|
+
model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
|
|
413
|
+
screenWidth: width,
|
|
414
|
+
screenHeight: height,
|
|
415
|
+
};
|
|
416
|
+
const bundle: FeedbackBundle = {
|
|
417
|
+
metadata: {
|
|
418
|
+
timestamp: new Date().toISOString(),
|
|
419
|
+
device: deviceInfo,
|
|
420
|
+
app: {},
|
|
421
|
+
userNote:
|
|
422
|
+
transcript.length > 0
|
|
423
|
+
? `[Voice note] ${transcript}`
|
|
424
|
+
: `[Voice note · ${Math.round(audio.duration)}s — transcription unavailable]`,
|
|
425
|
+
},
|
|
426
|
+
screenshots: [],
|
|
427
|
+
audio: audio.path,
|
|
428
|
+
errors: YaverFeedback.getCapturedErrors().length
|
|
429
|
+
? YaverFeedback.getCapturedErrors()
|
|
430
|
+
: undefined,
|
|
431
|
+
};
|
|
432
|
+
await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
|
|
433
|
+
setToast(transcript ? `Sent: "${transcript.slice(0, 60)}${transcript.length > 60 ? '…' : ''}"` : 'Voice note sent');
|
|
434
|
+
closeSoon(1800);
|
|
435
|
+
} catch (err: unknown) {
|
|
436
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
437
|
+
} finally {
|
|
438
|
+
if (mountedRef.current) setAction('idle');
|
|
439
|
+
}
|
|
440
|
+
}, [isRecordingVoice, closeSoon]);
|
|
441
|
+
|
|
342
442
|
// ─── 5. Send the last recorded video ───────────────────────────────
|
|
343
443
|
const handleSendVideo = useCallback(async () => {
|
|
344
444
|
const config = YaverFeedback.getConfig();
|
|
@@ -446,6 +546,25 @@ export const FeedbackModal: React.FC = () => {
|
|
|
446
546
|
busy={action === 'vibing'}
|
|
447
547
|
/>
|
|
448
548
|
|
|
549
|
+
{/* Voice note — only rendered when expo-av is installed.
|
|
550
|
+
Tap to start, tap again to stop → transcribes via
|
|
551
|
+
the agent and files as a feedback report. */}
|
|
552
|
+
{voiceSupported && (
|
|
553
|
+
<ActionRow
|
|
554
|
+
label={
|
|
555
|
+
action === 'transcribing-voice'
|
|
556
|
+
? 'Transcribing…'
|
|
557
|
+
: isRecordingVoice
|
|
558
|
+
? 'Stop & Send Voice'
|
|
559
|
+
: 'Voice Note'
|
|
560
|
+
}
|
|
561
|
+
tint={isRecordingVoice ? '#ef4444' : '#f472b6'}
|
|
562
|
+
onPress={handleToggleVoice}
|
|
563
|
+
disabled={busy && action !== 'recording-voice' && action !== 'idle'}
|
|
564
|
+
busy={action === 'transcribing-voice'}
|
|
565
|
+
/>
|
|
566
|
+
)}
|
|
567
|
+
|
|
449
568
|
{/* 4. Start/Stop Recording */}
|
|
450
569
|
<ActionRow
|
|
451
570
|
label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'}
|
package/src/capture.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Screen capture helpers — screenshot + video recording.
|
|
2
|
+
* Screen capture helpers — screenshot + video + voice recording.
|
|
3
3
|
*
|
|
4
4
|
* Peer deps (all optional — loaded lazily):
|
|
5
5
|
* - `react-native-view-shot` — screenshot
|
|
6
6
|
* - `react-native-record-screen` — video recording (iOS ReplayKit /
|
|
7
7
|
* Android MediaProjection)
|
|
8
|
+
* - `expo-av` — audio recording for voice notes
|
|
8
9
|
*
|
|
9
10
|
* Each helper surfaces a clear error if the module is missing so a host
|
|
10
|
-
* app knows exactly which peer dep to add.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* app knows exactly which peer dep to add. Voice-note capture was
|
|
12
|
+
* removed in 0.7.0 and re-added in 0.7.14 as a narrow, "press to
|
|
13
|
+
* record → stop → transcribe via the agent → attach to feedback" flow.
|
|
14
|
+
* The broader live/narrated/batch modes from pre-0.7.0 stay removed.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -118,3 +120,114 @@ export async function stopVideoRecording(): Promise<{
|
|
|
118
120
|
export function isVideoRecording(): boolean {
|
|
119
121
|
return videoRecordingActive;
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
// ── Voice note recording ────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// Short audio recording, stopped on user tap. Lazy-loaded via expo-av —
|
|
127
|
+
// the SDK doesn't declare a hard dep on it so bare-RN apps that don't
|
|
128
|
+
// have Expo can still install the SDK. If expo-av is missing, the
|
|
129
|
+
// helpers throw a clear error and the modal's voice button gracefully
|
|
130
|
+
// hides itself.
|
|
131
|
+
|
|
132
|
+
let audioRecorderRef: any = null;
|
|
133
|
+
let audioRecorderActive = false;
|
|
134
|
+
|
|
135
|
+
function loadExpoAvOrThrow(): any {
|
|
136
|
+
try {
|
|
137
|
+
const mod = require('expo-av');
|
|
138
|
+
if (!mod?.Audio?.Recording) {
|
|
139
|
+
throw new Error('expo-av is installed but missing Audio.Recording');
|
|
140
|
+
}
|
|
141
|
+
return mod;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'[YaverFeedback] Voice notes need `expo-av` as a peer dependency. ' +
|
|
145
|
+
'Add it with `npx expo install expo-av` and rebuild. ' +
|
|
146
|
+
String(err),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns true if `expo-av` is installed and Audio.Recording is
|
|
153
|
+
* available — lets the modal hide the voice button in apps that
|
|
154
|
+
* haven't installed the peer dep, instead of throwing on tap.
|
|
155
|
+
*/
|
|
156
|
+
export function isVoiceCaptureSupported(): boolean {
|
|
157
|
+
try {
|
|
158
|
+
const mod = require('expo-av');
|
|
159
|
+
return !!mod?.Audio?.Recording;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Begin recording audio from the device microphone. Requests
|
|
167
|
+
* microphone permission on first use. Resolves once recording has
|
|
168
|
+
* actually started, so the UI can flip to "Stop" immediately.
|
|
169
|
+
*/
|
|
170
|
+
export async function startAudioRecording(): Promise<void> {
|
|
171
|
+
if (audioRecorderActive) {
|
|
172
|
+
throw new Error('[YaverFeedback] An audio recording is already in progress.');
|
|
173
|
+
}
|
|
174
|
+
const ExpoAv = loadExpoAvOrThrow();
|
|
175
|
+
const { Audio } = ExpoAv;
|
|
176
|
+
|
|
177
|
+
const perm = await Audio.requestPermissionsAsync();
|
|
178
|
+
if (!perm.granted) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Use the iOS/Android high-quality preset — transcription providers
|
|
185
|
+
// (Whisper / Deepgram / OpenAI) prefer 16 kHz+ mono but also handle
|
|
186
|
+
// the higher sample rates fine. Default preset is portable.
|
|
187
|
+
await Audio.setAudioModeAsync({
|
|
188
|
+
allowsRecordingIOS: true,
|
|
189
|
+
playsInSilentModeIOS: true,
|
|
190
|
+
staysActiveInBackground: false,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const recording = new Audio.Recording();
|
|
194
|
+
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
|
195
|
+
await recording.startAsync();
|
|
196
|
+
audioRecorderRef = recording;
|
|
197
|
+
audioRecorderActive = true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stop the current audio recording and return the on-device file path
|
|
202
|
+
* (usually a .m4a on iOS / .3gp on Android). Returns null if no
|
|
203
|
+
* recording was active.
|
|
204
|
+
*/
|
|
205
|
+
export async function stopAudioRecording(): Promise<{ path: string; duration: number } | null> {
|
|
206
|
+
if (!audioRecorderActive || !audioRecorderRef) return null;
|
|
207
|
+
const recording = audioRecorderRef;
|
|
208
|
+
audioRecorderRef = null;
|
|
209
|
+
audioRecorderActive = false;
|
|
210
|
+
try {
|
|
211
|
+
await recording.stopAndUnloadAsync();
|
|
212
|
+
} catch {
|
|
213
|
+
// Second stop calls throw; ignore and use whatever state we have.
|
|
214
|
+
}
|
|
215
|
+
const uri = typeof recording.getURI === 'function' ? recording.getURI() : null;
|
|
216
|
+
if (!uri) {
|
|
217
|
+
throw new Error('[YaverFeedback] Audio recording produced no file.');
|
|
218
|
+
}
|
|
219
|
+
let durationMs = 0;
|
|
220
|
+
try {
|
|
221
|
+
const status = await recording.getStatusAsync();
|
|
222
|
+
durationMs = status?.durationMillis ?? 0;
|
|
223
|
+
} catch {
|
|
224
|
+
// Status can fail after unload; leave duration at 0, transcription
|
|
225
|
+
// still works.
|
|
226
|
+
}
|
|
227
|
+
return { path: uri, duration: durationMs / 1000 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Whether a voice-note recording is currently active. */
|
|
231
|
+
export function isAudioRecording(): boolean {
|
|
232
|
+
return audioRecorderActive;
|
|
233
|
+
}
|