yaver-feedback-react-native 0.7.14 → 0.7.16

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 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)
@@ -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. Audio-note / voice-command
11
- * recording was removed in 0.7.0 see FeedbackModal for the new
12
- * 5-button surface.
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. Audio-note / voice-command
12
- * recording was removed in 0.7.0 see FeedbackModal for the new
13
- * 5-button surface.
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
+ }
@@ -19,7 +19,13 @@ class YaverHotReload: NSObject {
19
19
  static let bundleFile = "main.jsbundle"
20
20
  static let reloadNotification = Notification.Name("YaverHotReloadBundle")
21
21
 
22
- override static func requiresMainQueueSetup() -> Bool { return true }
22
+ // `requiresMainQueueSetup` is an RCTBridgeModule protocol method,
23
+ // not an NSObject method — so it must not be marked `override`.
24
+ // Modern React Native discovers it via the Objective-C runtime
25
+ // (the Swift-generated ObjC interface plus the .m file's
26
+ // RCT_EXPORT_MODULE macro). Marking it `override` errors with
27
+ // "does not override any method from its superclass" on Swift 5+.
28
+ @objc static func requiresMainQueueSetup() -> Bool { return true }
23
29
 
24
30
  /// Download a Hermes bundle from the agent and trigger a bridge reload.
25
31
  @objc func loadBundle(_ urlString: String,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
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",
@@ -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. Audio-note / voice-command
11
- * recording was removed in 0.7.0 see FeedbackModal for the new
12
- * 5-button surface.
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
+ }