@siteed/audio-studio 3.2.1-beta.1 → 3.2.1-beta.3

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +12 -1
  3. package/build/cjs/AudioStudio.types.js.map +1 -1
  4. package/build/cjs/useAudioRecorder.js +115 -93
  5. package/build/cjs/useAudioRecorder.js.map +1 -1
  6. package/build/cjs/utils/nativeRecordingOptions.js +13 -0
  7. package/build/cjs/utils/nativeRecordingOptions.js.map +1 -0
  8. package/build/cjs/utils/nativeRecordingOptions.test.js +30 -0
  9. package/build/cjs/utils/nativeRecordingOptions.test.js.map +1 -0
  10. package/build/esm/AudioStudio.types.js.map +1 -1
  11. package/build/esm/useAudioRecorder.js +115 -93
  12. package/build/esm/useAudioRecorder.js.map +1 -1
  13. package/build/esm/utils/nativeRecordingOptions.js +10 -0
  14. package/build/esm/utils/nativeRecordingOptions.js.map +1 -0
  15. package/build/esm/utils/nativeRecordingOptions.test.js +28 -0
  16. package/build/esm/utils/nativeRecordingOptions.test.js.map +1 -0
  17. package/build/types/AudioStudio.types.d.ts +19 -5
  18. package/build/types/AudioStudio.types.d.ts.map +1 -1
  19. package/build/types/useAudioRecorder.d.ts +2 -1
  20. package/build/types/useAudioRecorder.d.ts.map +1 -1
  21. package/build/types/utils/nativeRecordingOptions.d.ts +28 -0
  22. package/build/types/utils/nativeRecordingOptions.d.ts.map +1 -0
  23. package/build/types/utils/nativeRecordingOptions.test.d.ts +2 -0
  24. package/build/types/utils/nativeRecordingOptions.test.d.ts.map +1 -0
  25. package/package.json +2 -1
  26. package/plugin/build/index.cjs +79 -47
  27. package/plugin/build/index.d.cts +15 -0
  28. package/plugin/build/index.js +79 -47
  29. package/plugin/src/index.test.ts +78 -0
  30. package/plugin/src/index.ts +141 -59
  31. package/plugin/tsconfig.json +6 -1
  32. package/src/AudioStudio.types.ts +27 -5
  33. package/src/useAudioRecorder.tsx +161 -126
  34. package/src/utils/nativeRecordingOptions.test.ts +29 -0
  35. package/src/utils/nativeRecordingOptions.ts +20 -0
@@ -12,6 +12,7 @@ import {
12
12
  ConsoleLike,
13
13
  MaxDurationReachedEvent,
14
14
  RecordingConfig,
15
+ RecordingStopReason,
15
16
  StartRecordingResult,
16
17
  } from './AudioStudio.types'
17
18
  import AudioStudioModule from './AudioStudioModule'
@@ -23,7 +24,7 @@ import {
23
24
  addMaxDurationReachedListener,
24
25
  addRecordingInterruptionListener,
25
26
  } from './events'
26
- import { cleanNativeOptions } from './utils/cleanNativeOptions'
27
+ import { createNativeRecordingOptions } from './utils/nativeRecordingOptions'
27
28
 
28
29
  export interface UseAudioRecorderProps {
29
30
  logger?: ConsoleLike
@@ -45,6 +46,7 @@ export interface UseAudioRecorderState {
45
46
  analysisData?: AudioAnalysis
46
47
  maxDurationMs?: number
47
48
  maxDurationReached?: boolean
49
+ lastRecordingReason?: RecordingStopReason
48
50
  }
49
51
 
50
52
  interface RecorderReducerState {
@@ -56,10 +58,17 @@ interface RecorderReducerState {
56
58
  analysisData?: AudioAnalysis
57
59
  maxDurationMs?: number
58
60
  maxDurationReached?: boolean
61
+ lastRecordingReason?: RecordingStopReason
59
62
  }
60
63
 
61
64
  type RecorderAction =
62
- | { type: 'START' | 'STOP' | 'PAUSE' | 'RESUME' }
65
+ | { type: 'START' | 'PAUSE' | 'RESUME' }
66
+ | {
67
+ type: 'STOP'
68
+ payload: {
69
+ reason: RecordingStopReason
70
+ }
71
+ }
63
72
  | {
64
73
  type: 'UPDATE_RECORDING_STATE'
65
74
  payload: {
@@ -102,6 +111,42 @@ const defaultAnalysis: AudioAnalysis = {
102
111
  extractionTimeMs: 0,
103
112
  }
104
113
 
114
+ function finiteOrZero(value: number): number {
115
+ return Number.isFinite(value) ? value : 0
116
+ }
117
+
118
+ function sanitizeSerializableValue<T>(value: T): T {
119
+ if (typeof value === 'number') {
120
+ return finiteOrZero(value) as T
121
+ }
122
+
123
+ if (Array.isArray(value)) {
124
+ return value.map((item) => sanitizeSerializableValue(item)) as T
125
+ }
126
+
127
+ if (value && typeof value === 'object') {
128
+ const sanitized: Record<string, unknown> = {}
129
+
130
+ for (const [key, nestedValue] of Object.entries(
131
+ value as Record<string, unknown>
132
+ )) {
133
+ sanitized[key] = sanitizeSerializableValue(nestedValue)
134
+ }
135
+
136
+ return sanitized as T
137
+ }
138
+
139
+ return value
140
+ }
141
+
142
+ function createSerializableAnalysis(analysis: AudioAnalysis): AudioAnalysis {
143
+ return sanitizeSerializableValue(analysis)
144
+ }
145
+
146
+ function createRecordingSnapshot(recording: AudioRecording): AudioRecording {
147
+ return sanitizeSerializableValue(recording)
148
+ }
149
+
105
150
  function audioRecorderReducer(
106
151
  state: RecorderReducerState,
107
152
  action: RecorderAction
@@ -118,6 +163,7 @@ function audioRecorderReducer(
118
163
  analysisData: defaultAnalysis,
119
164
  maxDurationMs: undefined,
120
165
  maxDurationReached: false,
166
+ lastRecordingReason: undefined,
121
167
  }
122
168
  case 'STOP':
123
169
  return {
@@ -128,6 +174,7 @@ function audioRecorderReducer(
128
174
  size: 0,
129
175
  compression: undefined,
130
176
  analysisData: undefined,
177
+ lastRecordingReason: action.payload.reason,
131
178
  // Preserve max-duration state after stop so UI and agentic
132
179
  // validation can explain why recording ended. START resets it.
133
180
  }
@@ -202,6 +249,7 @@ export function useAudioRecorder({
202
249
  analysisData: undefined,
203
250
  maxDurationMs: undefined,
204
251
  maxDurationReached: false,
252
+ lastRecordingReason: undefined,
205
253
  })
206
254
 
207
255
  const startResultRef = useRef<StartRecordingResult | null>(null)
@@ -240,6 +288,7 @@ export function useAudioRecorder({
240
288
 
241
289
  const recordingConfigRef = useRef<RecordingConfig | null>(null)
242
290
  const maxDurationHandledRef = useRef(false)
291
+ const stopFinalizationRef = useRef<Promise<AudioRecording> | null>(null)
243
292
 
244
293
  // Generate unique instance ID for debugging
245
294
  const instanceId = useId().replace(/:/g, '').slice(0, 5)
@@ -471,6 +520,83 @@ export function useAudioRecorder({
471
520
  []
472
521
  )
473
522
 
523
+ const finalizeRecordingStop = useCallback(
524
+ async (reason: RecordingStopReason) => {
525
+ if (stopFinalizationRef.current) {
526
+ return stopFinalizationRef.current
527
+ }
528
+
529
+ const finalizePromise = (async () => {
530
+ const nativeStopResult: AudioRecording | null =
531
+ await audioStudio.stopRecording()
532
+
533
+ if (!nativeStopResult) {
534
+ throw new Error('Failed to stop recording')
535
+ }
536
+
537
+ const stopResult = createRecordingSnapshot(nativeStopResult)
538
+
539
+ if (shouldKeepFullAnalysis(recordingConfigRef.current)) {
540
+ stopResult.analysisData = createSerializableAnalysis(
541
+ fullAnalysisRef.current
542
+ )
543
+ } else {
544
+ // `keepFullAnalysis` is a hook-level retention policy. If a platform
545
+ // starts returning native analysisData in the future, keep opt-out
546
+ // semantics explicit and avoid leaking a full history here.
547
+ delete stopResult.analysisData
548
+ }
549
+
550
+ if (analysisListenerRef.current) {
551
+ analysisListenerRef.current.remove()
552
+ analysisListenerRef.current = null
553
+ }
554
+ onAudioStreamRef.current = null
555
+
556
+ stateRef.current.isRecording = false
557
+ stateRef.current.isPaused = false
558
+
559
+ // Note: We deliberately DON'T clear recordingConfigRef here to preserve callbacks.
560
+ logger?.debug(`recording stopped`, stopResult)
561
+ maxDurationHandledRef.current = false
562
+ dispatch({
563
+ type: 'STOP',
564
+ payload: { reason },
565
+ })
566
+
567
+ const stoppedCallback =
568
+ recordingConfigRef.current?.onRecordingStopped
569
+ if (stoppedCallback) {
570
+ try {
571
+ void Promise.resolve(
572
+ stoppedCallback(stopResult, reason)
573
+ ).catch((error) => {
574
+ logger?.error(
575
+ `Error in recording stopped callback:`,
576
+ error
577
+ )
578
+ })
579
+ } catch (error) {
580
+ logger?.error(
581
+ `Error in recording stopped callback:`,
582
+ error
583
+ )
584
+ }
585
+ }
586
+
587
+ return stopResult
588
+ })()
589
+
590
+ stopFinalizationRef.current = finalizePromise
591
+ try {
592
+ return await finalizePromise
593
+ } finally {
594
+ stopFinalizationRef.current = null
595
+ }
596
+ },
597
+ [audioStudio, dispatch, logger]
598
+ )
599
+
474
600
  const handleMaxDurationReached = useCallback(
475
601
  async (event: MaxDurationReachedEvent) => {
476
602
  if (maxDurationHandledRef.current) {
@@ -498,84 +624,15 @@ export function useAudioRecorder({
498
624
  logger?.error(`Error in max duration callback:`, error)
499
625
  }
500
626
 
501
- const finishStoppedState = () => {
502
- if (analysisListenerRef.current) {
503
- analysisListenerRef.current.remove()
504
- analysisListenerRef.current = null
505
- }
506
- onAudioStreamRef.current = null
507
- stateRef.current.isRecording = false
508
- stateRef.current.isPaused = false
509
- dispatch({ type: 'STOP' })
510
- }
511
-
512
- const waitForPlatformAutoStop = async () => {
513
- const timeoutMs = 3000
514
- const startedAt = Date.now()
515
- let lastStatus: AudioStreamStatus | undefined
516
-
517
- while (Date.now() - startedAt < timeoutMs) {
518
- await new Promise((resolve) => setTimeout(resolve, 50))
519
- try {
520
- const currentStatus: AudioStreamStatus =
521
- audioStudio.status()
522
- lastStatus = currentStatus
523
- if (
524
- !currentStatus.isRecording &&
525
- !currentStatus.isPaused
526
- ) {
527
- finishStoppedState()
528
- return
529
- }
530
- } catch (error) {
531
- logger?.warn(
532
- `Error checking status after max duration auto-stop:`,
533
- error
534
- )
535
- break
536
- }
537
- }
538
-
539
- if (
540
- lastStatus &&
541
- (lastStatus.isRecording || lastStatus.isPaused)
542
- ) {
543
- try {
544
- await audioStudio.stopRecording()
545
- } catch (error) {
546
- logger?.warn(
547
- `Error completing max duration auto-stop fallback:`,
548
- error
549
- )
550
- }
551
- }
552
- // At this point platform-owned auto-stop did not settle cleanly.
553
- // Clear hook state so the UI does not stay stuck as recording.
554
- finishStoppedState()
555
- }
556
-
557
- // Only the original event tells us whether the platform already
558
- // owns auto-stop. Keep stream callbacks alive until status confirms
559
- // stop completion so native final audio flushes can still reach JS.
560
- if (event.autoStopped && stateRef.current.isRecording) {
561
- await waitForPlatformAutoStop()
562
- return
563
- }
564
-
565
- if (
566
- config?.autoStopOnMaxDuration &&
567
- !event.autoStopped &&
568
- stateRef.current.isRecording
569
- ) {
627
+ if (config?.autoStopOnMaxDuration && stateRef.current.isRecording) {
570
628
  try {
571
- await audioStudio.stopRecording()
572
- finishStoppedState()
629
+ await finalizeRecordingStop('maxDuration')
573
630
  } catch (error) {
574
631
  logger?.error(`Error auto-stopping on max duration:`, error)
575
632
  }
576
633
  }
577
634
  },
578
- [audioStudio, dispatch, logger]
635
+ [dispatch, finalizeRecordingStop, logger]
579
636
  )
580
637
 
581
638
  const checkStatus = useCallback(async () => {
@@ -618,27 +675,38 @@ export function useAudioRecorder({
618
675
  })
619
676
  }
620
677
 
678
+ const statusMaxDurationReached = status.maxDurationReached ?? false
679
+ const preserveStoppedMaxDuration =
680
+ !status.isRecording &&
681
+ !status.isPaused &&
682
+ stateRef.current.maxDurationReached &&
683
+ !statusMaxDurationReached
684
+ const nextMaxDurationMs = preserveStoppedMaxDuration
685
+ ? stateRef.current.maxDurationMs
686
+ : status.maxDurationMs
687
+ const nextMaxDurationReached = preserveStoppedMaxDuration
688
+ ? true
689
+ : statusMaxDurationReached
690
+
621
691
  if (
622
692
  status.durationMs !== stateRef.current.durationMs ||
623
693
  status.size !== stateRef.current.size ||
624
- status.maxDurationMs !== stateRef.current.maxDurationMs ||
625
- status.maxDurationReached !==
626
- stateRef.current.maxDurationReached
694
+ nextMaxDurationMs !== stateRef.current.maxDurationMs ||
695
+ nextMaxDurationReached !== stateRef.current.maxDurationReached
627
696
  ) {
628
697
  stateRef.current.durationMs = status.durationMs
629
698
  stateRef.current.size = status.size
630
699
  stateRef.current.compression = status.compression
631
- stateRef.current.maxDurationMs = status.maxDurationMs
632
- stateRef.current.maxDurationReached =
633
- status.maxDurationReached ?? false
700
+ stateRef.current.maxDurationMs = nextMaxDurationMs
701
+ stateRef.current.maxDurationReached = nextMaxDurationReached
634
702
  dispatch({
635
703
  type: 'UPDATE_STATUS',
636
704
  payload: {
637
705
  durationMs: status.durationMs,
638
706
  size: status.size,
639
707
  compression: status.compression,
640
- maxDurationMs: status.maxDurationMs,
641
- maxDurationReached: status.maxDurationReached,
708
+ maxDurationMs: nextMaxDurationMs,
709
+ maxDurationReached: nextMaxDurationReached,
642
710
  },
643
711
  })
644
712
  }
@@ -697,15 +765,7 @@ export function useAudioRecorder({
697
765
 
698
766
  analysisRef.current = { ...defaultAnalysis } // Reset analysis data
699
767
  fullAnalysisRef.current = { ...defaultAnalysis }
700
- const {
701
- onAudioStream,
702
- onRecordingInterrupted,
703
- onMaxDurationReached,
704
- onAudioAnalysis,
705
- keepFullAnalysis: _keepFullAnalysis,
706
- ...options
707
- } = validatedOptions
708
- const { enableProcessing } = options
768
+ const { onAudioStream, enableProcessing } = validatedOptions
709
769
 
710
770
  const maxRecentDataDuration = 10000 // TODO compute maxRecentDataDuration based on screen dimensions
711
771
  if (typeof onAudioStream === 'function') {
@@ -714,8 +774,10 @@ export function useAudioRecorder({
714
774
  logger?.warn(`onAudioStream is not a function`, onAudioStream)
715
775
  onAudioStreamRef.current = null
716
776
  }
717
- // Strip undefined values and functions that can't cross the native bridge
718
- const cleanOptions = cleanNativeOptions(options)
777
+ // Strip hook-only values and undefineds that can't cross the native bridge.
778
+ // autoStopOnMaxDuration stays hook-owned so finalization can expose
779
+ // the same AudioRecording result as a manual stop.
780
+ const cleanOptions = createNativeRecordingOptions(validatedOptions)
719
781
  const startResult: StartRecordingResult =
720
782
  await audioStudio.startRecording(cleanOptions)
721
783
  dispatch({ type: 'START' })
@@ -755,14 +817,7 @@ export function useAudioRecorder({
755
817
 
756
818
  analysisRef.current = { ...defaultAnalysis } // Reset analysis data
757
819
  fullAnalysisRef.current = { ...defaultAnalysis }
758
- const {
759
- onAudioStream,
760
- onRecordingInterrupted,
761
- onMaxDurationReached,
762
- onAudioAnalysis,
763
- keepFullAnalysis: _keepFullAnalysis,
764
- ...options
765
- } = recordingOptions
820
+ const { onAudioStream } = recordingOptions
766
821
 
767
822
  // Store onAudioStream for later use when recording starts
768
823
  if (typeof onAudioStream === 'function') {
@@ -772,8 +827,8 @@ export function useAudioRecorder({
772
827
  onAudioStreamRef.current = null
773
828
  }
774
829
 
775
- // Strip undefined values and functions that can't cross the native bridge
776
- const cleanOptions = cleanNativeOptions(options)
830
+ // Strip hook-only values and undefineds that can't cross the native bridge.
831
+ const cleanOptions = createNativeRecordingOptions(recordingOptions)
777
832
  // Call the native prepareRecording method
778
833
  await audioStudio.prepareRecording(cleanOptions)
779
834
  logger?.debug(`recording prepared successfully`)
@@ -783,29 +838,8 @@ export function useAudioRecorder({
783
838
 
784
839
  const stopRecording = useCallback(async () => {
785
840
  logger?.debug(`stoping recording`)
786
-
787
- const stopResult: AudioRecording = await audioStudio.stopRecording()
788
- if (shouldKeepFullAnalysis(recordingConfigRef.current)) {
789
- stopResult.analysisData = fullAnalysisRef.current
790
- } else {
791
- // `keepFullAnalysis` is a hook-level retention policy. If a platform
792
- // starts returning native analysisData in the future, keep opt-out
793
- // semantics explicit and avoid leaking a full history here.
794
- delete stopResult.analysisData
795
- }
796
-
797
- if (analysisListenerRef.current) {
798
- analysisListenerRef.current.remove()
799
- analysisListenerRef.current = null
800
- }
801
- onAudioStreamRef.current = null
802
-
803
- // Note: We deliberately DON'T clear recordingConfigRef here to preserve interruption callback
804
- logger?.debug(`recording stopped`, stopResult)
805
- maxDurationHandledRef.current = false
806
- dispatch({ type: 'STOP' })
807
- return stopResult
808
- }, [dispatch])
841
+ return finalizeRecordingStop('manual')
842
+ }, [finalizeRecordingStop, logger])
809
843
 
810
844
  const pauseRecording = useCallback(async () => {
811
845
  logger?.debug(`pause recording`)
@@ -982,5 +1016,6 @@ export function useAudioRecorder({
982
1016
  analysisData: state.analysisData,
983
1017
  maxDurationMs: state.maxDurationMs,
984
1018
  maxDurationReached: state.maxDurationReached,
1019
+ lastRecordingReason: state.lastRecordingReason,
985
1020
  }
986
1021
  }
@@ -0,0 +1,29 @@
1
+ import { createNativeRecordingOptions } from './nativeRecordingOptions'
2
+
3
+ describe('createNativeRecordingOptions', () => {
4
+ it('keeps maxDurationMs native but leaves auto-stop finalization hook-owned', () => {
5
+ const nativeOptions = createNativeRecordingOptions({
6
+ maxDurationMs: 1500,
7
+ autoStopOnMaxDuration: true,
8
+ onMaxDurationReached: jest.fn(),
9
+ onRecordingStopped: jest.fn(),
10
+ onRecordingInterrupted: jest.fn(),
11
+ onAudioAnalysis: jest.fn(),
12
+ onAudioStream: jest.fn(),
13
+ keepFullAnalysis: true,
14
+ sampleRate: 16000,
15
+ })
16
+
17
+ expect(nativeOptions).toMatchObject({
18
+ maxDurationMs: 1500,
19
+ sampleRate: 16000,
20
+ })
21
+ expect(nativeOptions).not.toHaveProperty('autoStopOnMaxDuration')
22
+ expect(nativeOptions).not.toHaveProperty('onMaxDurationReached')
23
+ expect(nativeOptions).not.toHaveProperty('onRecordingStopped')
24
+ expect(nativeOptions).not.toHaveProperty('onRecordingInterrupted')
25
+ expect(nativeOptions).not.toHaveProperty('onAudioAnalysis')
26
+ expect(nativeOptions).not.toHaveProperty('onAudioStream')
27
+ expect(nativeOptions).not.toHaveProperty('keepFullAnalysis')
28
+ })
29
+ })
@@ -0,0 +1,20 @@
1
+ import { RecordingConfig } from '../AudioStudio.types'
2
+ import { cleanNativeOptions } from './cleanNativeOptions'
3
+
4
+ export function createNativeRecordingOptions(recordingOptions: RecordingConfig) {
5
+ const {
6
+ onAudioStream,
7
+ onRecordingInterrupted,
8
+ onMaxDurationReached,
9
+ onRecordingStopped,
10
+ onAudioAnalysis,
11
+ keepFullAnalysis: _keepFullAnalysis,
12
+ // Keep hook-owned auto-stop out of the native bridge so the hook can
13
+ // reuse the same finalization path as manual stop and expose the
14
+ // resulting AudioRecording consistently.
15
+ autoStopOnMaxDuration: _autoStopOnMaxDuration,
16
+ ...options
17
+ } = recordingOptions
18
+
19
+ return cleanNativeOptions(options)
20
+ }