@siteed/audio-studio 3.0.5 → 3.1.1

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 (63) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +108 -41
  3. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
  4. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
  5. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
  6. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
  7. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
  8. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +74 -22
  9. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +86 -19
  10. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  11. package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
  12. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
  13. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
  14. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
  15. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  16. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  17. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  18. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  19. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  20. package/build/cjs/AudioStudio.types.js.map +1 -1
  21. package/build/cjs/errors/AudioExtractionError.js +127 -0
  22. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  23. package/build/cjs/index.js +6 -1
  24. package/build/cjs/index.js.map +1 -1
  25. package/build/cjs/useAudioRecorder.js +36 -18
  26. package/build/cjs/useAudioRecorder.js.map +1 -1
  27. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  28. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  29. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  30. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  31. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  32. package/build/esm/AudioStudio.types.js.map +1 -1
  33. package/build/esm/errors/AudioExtractionError.js +122 -0
  34. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  35. package/build/esm/index.js +2 -0
  36. package/build/esm/index.js.map +1 -1
  37. package/build/esm/useAudioRecorder.js +36 -18
  38. package/build/esm/useAudioRecorder.js.map +1 -1
  39. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  40. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  41. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  42. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  43. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  44. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  45. package/build/types/AudioStudio.types.d.ts +14 -1
  46. package/build/types/AudioStudio.types.d.ts.map +1 -1
  47. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  48. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -1
  51. package/build/types/useAudioRecorder.d.ts.map +1 -1
  52. package/ios/AudioProcessor.swift +99 -0
  53. package/ios/AudioStreamManager.swift +79 -15
  54. package/ios/AudioStudioModule.swift +63 -0
  55. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
  56. package/package.json +7 -7
  57. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  58. package/src/AudioAnalysis/extractPreview.ts +118 -17
  59. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  60. package/src/AudioStudio.types.ts +15 -1
  61. package/src/errors/AudioExtractionError.ts +167 -0
  62. package/src/index.ts +10 -0
  63. package/src/useAudioRecorder.tsx +36 -14
@@ -1,34 +1,135 @@
1
- import { PreviewOptions, AudioAnalysis } from './AudioAnalysis.types'
1
+ import { mapExtractionError } from '../errors/AudioExtractionError'
2
+ import { PreviewOptions, AudioAnalysis, DataPoint } from './AudioAnalysis.types'
2
3
  import { extractAudioAnalysis } from './extractAudioAnalysis'
3
4
 
5
+ const DEFAULT_SILENCE_THRESHOLD = 0.01
6
+
7
+ /**
8
+ * Apply a silence threshold to the data points by recomputing the `silent` flag from rms.
9
+ * Returns a new array (does not mutate the source).
10
+ */
11
+ function applySilenceThreshold(
12
+ dataPoints: DataPoint[],
13
+ threshold: number
14
+ ): DataPoint[] {
15
+ return dataPoints.map((p) => ({
16
+ ...p,
17
+ silent: p.rms < threshold,
18
+ }))
19
+ }
20
+
21
+ const SMALL_TOTAL_INSTANT_THRESHOLD = 50
22
+ const PROGRESSIVE_BATCH_DELAY_MS = 30
23
+ const PROGRESSIVE_BATCH_COUNT = 8
24
+
25
+ /**
26
+ * Schedule progressive emission of points after the native one-shot resolve.
27
+ * Native progressive streaming is a future enhancement; today the points are
28
+ * micro-batched on the JS side so consumers (and the agentic recipe runner)
29
+ * can observe an in-flight `pointsReceived < totalPoints` window.
30
+ */
31
+ function emitPointsProgressively(
32
+ dataPoints: DataPoint[],
33
+ onPointReady: NonNullable<PreviewOptions['onPointReady']>,
34
+ signal?: PreviewOptions['signal'],
35
+ logger?: PreviewOptions['logger']
36
+ ): void {
37
+ const total = dataPoints.length
38
+ if (total === 0) return
39
+
40
+ const safeEmit = (point: DataPoint, index: number) => {
41
+ if (signal?.aborted) return
42
+ try {
43
+ onPointReady(point, index, total)
44
+ } catch (err) {
45
+ // Swallow callback errors so a buggy consumer cannot break extraction.
46
+ logger?.warn?.('extractPreview onPointReady callback failed', err)
47
+ }
48
+ }
49
+
50
+ if (signal?.aborted) return
51
+ if (total <= SMALL_TOTAL_INSTANT_THRESHOLD) {
52
+ for (let i = 0; i < total; i++) safeEmit(dataPoints[i], i)
53
+ return
54
+ }
55
+
56
+ // First quarter flushes immediately so the UI shows something within a frame.
57
+ const firstFlushCount = Math.max(1, Math.floor(total / 4))
58
+ for (let i = 0; i < firstFlushCount; i++) safeEmit(dataPoints[i], i)
59
+
60
+ if (firstFlushCount >= total) return
61
+
62
+ const remaining = total - firstFlushCount
63
+ const batchSize = Math.max(
64
+ 1,
65
+ Math.ceil(remaining / PROGRESSIVE_BATCH_COUNT)
66
+ )
67
+ let cursor = firstFlushCount
68
+ const pump = () => {
69
+ if (signal?.aborted) return
70
+ const end = Math.min(total, cursor + batchSize)
71
+ for (let i = cursor; i < end; i++) safeEmit(dataPoints[i], i)
72
+ cursor = end
73
+ if (cursor < total) {
74
+ setTimeout(pump, PROGRESSIVE_BATCH_DELAY_MS)
75
+ }
76
+ }
77
+ setTimeout(pump, PROGRESSIVE_BATCH_DELAY_MS)
78
+ }
79
+
4
80
  /**
5
81
  * Generates a simplified preview of the audio waveform for quick visualization.
6
82
  * Ideal for UI rendering with a specified number of points.
7
83
  *
8
84
  * @param options - The options for the preview, including file URI and time range.
9
85
  * @returns A promise that resolves to the audio preview data.
86
+ * @throws {AudioExtractionError} when the underlying extraction fails.
10
87
  */
11
88
  export async function extractPreview({
12
89
  fileUri,
13
90
  numberOfPoints = 100,
14
91
  startTimeMs = 0,
15
- endTimeMs = 30000, // First 30 seconds
92
+ endTimeMs = 30000,
16
93
  decodingOptions,
17
94
  logger,
95
+ onPointReady,
96
+ signal,
18
97
  }: PreviewOptions): Promise<AudioAnalysis> {
19
- const durationMs = endTimeMs - startTimeMs
20
- const segmentDurationMs = Math.floor(durationMs / numberOfPoints)
21
-
22
- // Call extractAudioAnalysis with calculated parameters
23
- const analysis = await extractAudioAnalysis({
24
- fileUri,
25
- startTimeMs,
26
- endTimeMs,
27
- logger,
28
- segmentDurationMs,
29
- decodingOptions,
30
- })
31
-
32
- // Transform the result into AudioPreview format
33
- return analysis
98
+ const durationMs = Math.max(1, endTimeMs - startTimeMs)
99
+ const segmentDurationMs = Math.max(
100
+ 1,
101
+ Math.floor(durationMs / numberOfPoints)
102
+ )
103
+
104
+ let analysis: AudioAnalysis
105
+ try {
106
+ analysis = await extractAudioAnalysis({
107
+ fileUri,
108
+ startTimeMs,
109
+ endTimeMs,
110
+ logger,
111
+ segmentDurationMs,
112
+ decodingOptions,
113
+ })
114
+ } catch (err) {
115
+ throw mapExtractionError(err, fileUri)
116
+ }
117
+
118
+ const threshold =
119
+ decodingOptions?.silenceRmsThreshold ?? DEFAULT_SILENCE_THRESHOLD
120
+ const adjusted: AudioAnalysis = {
121
+ ...analysis,
122
+ dataPoints: applySilenceThreshold(analysis.dataPoints, threshold),
123
+ }
124
+
125
+ if (onPointReady) {
126
+ emitPointsProgressively(
127
+ adjusted.dataPoints,
128
+ onPointReady,
129
+ signal,
130
+ logger
131
+ )
132
+ }
133
+
134
+ return adjusted
34
135
  }
@@ -0,0 +1,193 @@
1
+ import {
2
+ AudioAnalysis,
3
+ DataPoint,
4
+ PreviewBar,
5
+ PreviewBarsOptions,
6
+ PreviewBarsResult,
7
+ } from './AudioAnalysis.types'
8
+ import { extractPreview } from './extractPreview'
9
+ import AudioStudioModule from '../AudioStudioModule'
10
+ import { mapExtractionError } from '../errors/AudioExtractionError'
11
+ import { cleanNativeOptions } from '../utils/cleanNativeOptions'
12
+
13
+ const DEFAULT_PREVIEW_BARS = 100
14
+ const DEFAULT_PREVIEW_END_TIME_MS = 30000
15
+ const DEFAULT_SILENCE_THRESHOLD = 0.01
16
+
17
+ interface NativePreviewBarsModule {
18
+ extractPreviewBars: (
19
+ options: Record<string, unknown>
20
+ ) => Promise<PreviewBarsResult>
21
+ }
22
+
23
+ function hasNativePreviewBars(
24
+ module: unknown
25
+ ): module is NativePreviewBarsModule {
26
+ return (
27
+ module !== null &&
28
+ (typeof module === 'object' || typeof module === 'function') &&
29
+ typeof (module as NativePreviewBarsModule).extractPreviewBars ===
30
+ 'function'
31
+ )
32
+ }
33
+
34
+ function clamp01(value: number): number {
35
+ if (!Number.isFinite(value)) return 0
36
+ return Math.max(0, Math.min(1, value))
37
+ }
38
+
39
+ function getPointTimeMs(value: number | undefined, fallbackMs: number): number {
40
+ return typeof value === 'number' && Number.isFinite(value)
41
+ ? Math.round(value)
42
+ : fallbackMs
43
+ }
44
+
45
+ function pointToPreviewBar(
46
+ point: DataPoint,
47
+ index: number,
48
+ fallbackBarDurationMs: number,
49
+ silenceRmsThreshold: number
50
+ ): PreviewBar {
51
+ const fallbackStartTimeMs = Math.round(index * fallbackBarDurationMs)
52
+ const fallbackEndTimeMs = Math.round((index + 1) * fallbackBarDurationMs)
53
+ const startTimeMs = getPointTimeMs(point.startTime, fallbackStartTimeMs)
54
+ const endTimeMs = getPointTimeMs(point.endTime, fallbackEndTimeMs)
55
+ const rms = clamp01(point.rms)
56
+
57
+ return {
58
+ id: point.id ?? index,
59
+ amplitude: clamp01(point.amplitude),
60
+ rms,
61
+ silent: point.silent ?? rms < silenceRmsThreshold,
62
+ startTimeMs,
63
+ endTimeMs: Math.max(startTimeMs, endTimeMs),
64
+ }
65
+ }
66
+
67
+ function calculateRange(values: number[]): { min: number; max: number } {
68
+ if (values.length === 0) {
69
+ return { min: 0, max: 0 }
70
+ }
71
+
72
+ let min = Number.POSITIVE_INFINITY
73
+ let max = Number.NEGATIVE_INFINITY
74
+
75
+ for (const value of values) {
76
+ const safeValue = clamp01(value)
77
+ min = Math.min(min, safeValue)
78
+ max = Math.max(max, safeValue)
79
+ }
80
+
81
+ return { min, max }
82
+ }
83
+
84
+ function fromAudioAnalysis(
85
+ analysis: AudioAnalysis,
86
+ requestedNumberOfBars: number,
87
+ silenceRmsThreshold: number
88
+ ): PreviewBarsResult {
89
+ const barDurationMs =
90
+ analysis.segmentDurationMs ||
91
+ Math.max(
92
+ 1,
93
+ analysis.durationMs / Math.max(1, analysis.dataPoints.length)
94
+ )
95
+ const bars = analysis.dataPoints.map((point, index) =>
96
+ pointToPreviewBar(point, index, barDurationMs, silenceRmsThreshold)
97
+ )
98
+
99
+ return {
100
+ bars,
101
+ durationMs: analysis.durationMs,
102
+ sampleRate: analysis.sampleRate,
103
+ numberOfChannels: analysis.numberOfChannels,
104
+ bitDepth: analysis.bitDepth,
105
+ samples: analysis.samples,
106
+ requestedNumberOfBars,
107
+ barDurationMs,
108
+ amplitudeRange: calculateRange(bars.map((bar) => bar.amplitude)),
109
+ rmsRange: calculateRange(bars.map((bar) => bar.rms)),
110
+ extractionTimeMs: analysis.extractionTimeMs,
111
+ }
112
+ }
113
+
114
+ function emitBarsProgressively(
115
+ bars: PreviewBar[],
116
+ onBarReady: NonNullable<PreviewBarsOptions['onBarReady']>
117
+ ): void {
118
+ const total = bars.length
119
+ for (let index = 0; index < total; index++) {
120
+ onBarReady(bars[index], index, total)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Extracts compact waveform preview bars for UI rendering.
126
+ *
127
+ * Native platforms may provide a compact `extractPreviewBars` bridge. Until that
128
+ * bridge is available, this safely falls back to the existing `extractPreview`
129
+ * compatibility path and adapts `DataPoint` objects into compact bars.
130
+ *
131
+ * @throws {AudioExtractionError} when the underlying extraction fails.
132
+ */
133
+ export async function extractPreviewBars({
134
+ fileUri,
135
+ numberOfBars = DEFAULT_PREVIEW_BARS,
136
+ startTimeMs = 0,
137
+ endTimeMs = DEFAULT_PREVIEW_END_TIME_MS,
138
+ decodingOptions,
139
+ logger,
140
+ onBarReady,
141
+ }: PreviewBarsOptions): Promise<PreviewBarsResult> {
142
+ const requestedNumberOfBars = Math.max(1, Math.floor(numberOfBars))
143
+ const nativeOptions = {
144
+ fileUri,
145
+ numberOfBars: requestedNumberOfBars,
146
+ startTimeMs,
147
+ endTimeMs,
148
+ decodingOptions,
149
+ }
150
+
151
+ const nativeModule = AudioStudioModule as unknown
152
+ if (hasNativePreviewBars(nativeModule)) {
153
+ let result: PreviewBarsResult
154
+ try {
155
+ result = await nativeModule.extractPreviewBars(
156
+ cleanNativeOptions(nativeOptions)
157
+ )
158
+ } catch (err) {
159
+ throw mapExtractionError(err, fileUri)
160
+ }
161
+
162
+ if (onBarReady) {
163
+ emitBarsProgressively(result.bars, onBarReady)
164
+ }
165
+ return result
166
+ }
167
+
168
+ let analysis: AudioAnalysis
169
+ try {
170
+ analysis = await extractPreview({
171
+ fileUri,
172
+ numberOfPoints: requestedNumberOfBars,
173
+ startTimeMs,
174
+ endTimeMs,
175
+ decodingOptions,
176
+ logger,
177
+ })
178
+ } catch (err) {
179
+ throw mapExtractionError(err, fileUri)
180
+ }
181
+
182
+ const result = fromAudioAnalysis(
183
+ analysis,
184
+ requestedNumberOfBars,
185
+ decodingOptions?.silenceRmsThreshold ?? DEFAULT_SILENCE_THRESHOLD
186
+ )
187
+
188
+ if (onBarReady) {
189
+ emitBarsProgressively(result.bars, onBarReady)
190
+ }
191
+
192
+ return result
193
+ }
@@ -160,7 +160,10 @@ export interface AudioRecording {
160
160
  createdAt?: number
161
161
  /** Array of transcription data if available */
162
162
  transcripts?: TranscriberData[]
163
- /** Analysis data for the recording if processing was enabled */
163
+ /**
164
+ * Full analysis data for the recording if processing was enabled and
165
+ * `keepFullAnalysis` was not set to `false`.
166
+ */
164
167
  analysisData?: AudioAnalysis
165
168
  /** Information about compression if enabled, including the URI to the compressed file */
166
169
  compression?: CompressionInfo & {
@@ -432,6 +435,17 @@ export interface RecordingConfig {
432
435
  /** Enable audio processing (default is false) */
433
436
  enableProcessing?: boolean
434
437
 
438
+ /**
439
+ * Whether `useAudioRecorder` should retain every audio-analysis data point
440
+ * and attach the full history to `stopRecording().analysisData`.
441
+ *
442
+ * Defaults to `true` for backwards compatibility. Set to `false` for
443
+ * long-running recordings when you only need live `analysisData` state or
444
+ * per-callback `onAudioAnalysis` chunks; this avoids unbounded JS memory
445
+ * growth in the hook without disabling native analysis processing.
446
+ */
447
+ keepFullAnalysis?: boolean
448
+
435
449
  /** iOS-specific configuration */
436
450
  ios?: IOSConfig
437
451
 
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Typed error class for audio extraction failures.
3
+ * Wraps native module errors with stable codes consumers can switch on.
4
+ */
5
+ export type AudioExtractionErrorCode =
6
+ | 'unsupported_codec'
7
+ | 'malformed_file'
8
+ | 'decode_failed'
9
+ | 'permission_denied'
10
+ | 'file_not_found'
11
+ | 'unknown'
12
+
13
+ export interface AudioExtractionErrorPayload {
14
+ code: AudioExtractionErrorCode
15
+ message: string
16
+ nativeMessage?: string
17
+ fileUri?: string
18
+ }
19
+
20
+ export class AudioExtractionError extends Error {
21
+ readonly code: AudioExtractionErrorCode
22
+ readonly nativeMessage?: string
23
+ readonly fileUri?: string
24
+
25
+ constructor(payload: AudioExtractionErrorPayload) {
26
+ super(payload.message)
27
+ this.name = 'AudioExtractionError'
28
+ this.code = payload.code
29
+ this.nativeMessage = payload.nativeMessage
30
+ this.fileUri = payload.fileUri
31
+ }
32
+
33
+ toJSON(): AudioExtractionErrorPayload {
34
+ return {
35
+ code: this.code,
36
+ message: this.message,
37
+ nativeMessage: this.nativeMessage,
38
+ fileUri: this.fileUri,
39
+ }
40
+ }
41
+ }
42
+
43
+ function getNativeMessage(err: unknown): string {
44
+ if (err instanceof Error) return err.message
45
+ if (typeof err === 'string') return err
46
+
47
+ try {
48
+ return JSON.stringify(err) ?? String(err)
49
+ } catch {
50
+ return String(err)
51
+ }
52
+ }
53
+
54
+ function getNativeCode(err: unknown): string | undefined {
55
+ if (err && typeof err === 'object' && 'code' in err) {
56
+ const code = (err as { code?: unknown }).code
57
+ if (typeof code === 'string') return code
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ function mapNativeCode(
63
+ code: string | undefined
64
+ ): AudioExtractionErrorCode | null {
65
+ if (!code) return null
66
+
67
+ const normalized = code.toUpperCase()
68
+ if (
69
+ normalized.includes('FILE_NOT_FOUND') ||
70
+ normalized === 'ENOENT' ||
71
+ normalized.includes('NO_SUCH_FILE')
72
+ ) {
73
+ return 'file_not_found'
74
+ }
75
+ if (
76
+ normalized.includes('PERMISSION') ||
77
+ normalized === 'EACCES' ||
78
+ normalized.includes('NOT_AUTHORIZED')
79
+ ) {
80
+ return 'permission_denied'
81
+ }
82
+ if (
83
+ normalized.includes('UNSUPPORTED') ||
84
+ normalized.includes('NO_SUITABLE_CODEC')
85
+ ) {
86
+ return 'unsupported_codec'
87
+ }
88
+ if (
89
+ normalized.includes('INVALID_RANGE') ||
90
+ normalized.includes('INVALID_HEADER') ||
91
+ normalized.includes('MALFORMED') ||
92
+ normalized.includes('CORRUPT')
93
+ ) {
94
+ return 'malformed_file'
95
+ }
96
+ if (
97
+ normalized.includes('PROCESSING_ERROR') ||
98
+ normalized.includes('AUDIO_READ_ERROR') ||
99
+ normalized.includes('DECODE')
100
+ ) {
101
+ return 'decode_failed'
102
+ }
103
+
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * Map a thrown native/JS value into an AudioExtractionError with a stable code.
109
+ * Heuristics inspect message text and known native error codes.
110
+ */
111
+ export function mapExtractionError(
112
+ err: unknown,
113
+ fileUri?: string
114
+ ): AudioExtractionError {
115
+ if (err instanceof AudioExtractionError) return err
116
+
117
+ const nativeMessage = getNativeMessage(err)
118
+ const lower = nativeMessage.toLowerCase()
119
+
120
+ let code = mapNativeCode(getNativeCode(err)) ?? 'unknown'
121
+ if (
122
+ code === 'unknown' &&
123
+ (lower.includes('unsupported') ||
124
+ lower.includes('not supported') ||
125
+ lower.includes('no suitable codec') ||
126
+ lower.includes('no track'))
127
+ ) {
128
+ code = 'unsupported_codec'
129
+ } else if (
130
+ code === 'unknown' &&
131
+ (lower.includes('not found') ||
132
+ lower.includes('no such file') ||
133
+ lower.includes('does not exist'))
134
+ ) {
135
+ code = 'file_not_found'
136
+ } else if (
137
+ code === 'unknown' &&
138
+ (lower.includes('permission') ||
139
+ lower.includes('denied') ||
140
+ lower.includes('not authorized'))
141
+ ) {
142
+ code = 'permission_denied'
143
+ } else if (
144
+ code === 'unknown' &&
145
+ (lower.includes('malformed') ||
146
+ lower.includes('corrupt') ||
147
+ lower.includes('invalid header') ||
148
+ lower.includes('invalid wav'))
149
+ ) {
150
+ code = 'malformed_file'
151
+ } else if (
152
+ code === 'unknown' &&
153
+ (lower.includes('decode') ||
154
+ lower.includes('codec') ||
155
+ lower.includes('mediaextractor') ||
156
+ lower.includes('avaudio'))
157
+ ) {
158
+ code = 'decode_failed'
159
+ }
160
+
161
+ return new AudioExtractionError({
162
+ code,
163
+ message: `Audio extraction failed (${code}): ${nativeMessage}`,
164
+ nativeMessage,
165
+ fileUri,
166
+ })
167
+ }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export { AudioDeviceManager, audioDeviceManager } from './AudioDeviceManager'
44
44
  export { useAudioDevices } from './hooks/useAudioDevices'
45
45
 
46
46
  export { setMelSpectrogramWasmUrl } from './AudioAnalysis/wasmConfig'
47
+ export { extractPreviewBars } from './AudioAnalysis/extractPreviewBars'
47
48
 
48
49
  export {
49
50
  AudioRecorderProvider,
@@ -61,6 +62,15 @@ export {
61
62
  useSharedAudioRecorder,
62
63
  }
63
64
 
65
+ export {
66
+ AudioExtractionError,
67
+ mapExtractionError,
68
+ } from './errors/AudioExtractionError'
69
+ export type {
70
+ AudioExtractionErrorCode,
71
+ AudioExtractionErrorPayload,
72
+ } from './errors/AudioExtractionError'
73
+
64
74
  // Export all types
65
75
  export type * from './AudioAnalysis/AudioAnalysis.types'
66
76
  export type * from './AudioStudio.types'
@@ -156,6 +156,10 @@ interface HandleAudioAnalysisProps {
156
156
  visualizationDuration: number
157
157
  }
158
158
 
159
+ function shouldKeepFullAnalysis(config?: RecordingConfig | null): boolean {
160
+ return config?.keepFullAnalysis !== false
161
+ }
162
+
159
163
  export function useAudioRecorder({
160
164
  logger,
161
165
  audioWorkletUrl,
@@ -232,10 +236,15 @@ export function useAudioRecorder({
232
236
  ...analysis.dataPoints,
233
237
  ]
234
238
 
235
- const fullCombinedDataPoints = [
236
- ...(fullAnalysisRef.current?.dataPoints ?? []),
237
- ...analysis.dataPoints,
238
- ]
239
+ const keepFullAnalysis = shouldKeepFullAnalysis(
240
+ recordingConfigRef.current
241
+ )
242
+ const fullCombinedDataPoints = keepFullAnalysis
243
+ ? [
244
+ ...(fullAnalysisRef.current?.dataPoints ?? []),
245
+ ...analysis.dataPoints,
246
+ ]
247
+ : undefined
239
248
 
240
249
  // Calculate the new duration
241
250
  // The number of segments is based on how many segments of segmentDurationMs can fit in visualizationDuration
@@ -257,13 +266,15 @@ export function useAudioRecorder({
257
266
  )
258
267
  }
259
268
 
260
- // Keep the full data points
261
- fullAnalysisRef.current = {
262
- ...fullAnalysisRef.current,
263
- dataPoints: fullCombinedDataPoints,
269
+ // Keep the full data points when requested for stopRecording().analysisData.
270
+ if (keepFullAnalysis && fullCombinedDataPoints) {
271
+ fullAnalysisRef.current = {
272
+ ...fullAnalysisRef.current,
273
+ dataPoints: fullCombinedDataPoints,
274
+ }
275
+ fullAnalysisRef.current.durationMs =
276
+ fullCombinedDataPoints.length * analysis.segmentDurationMs
264
277
  }
265
- fullAnalysisRef.current.durationMs =
266
- fullCombinedDataPoints.length * analysis.segmentDurationMs
267
278
  savedAnalysisData.dataPoints = combinedDataPoints
268
279
  savedAnalysisData.bitDepth =
269
280
  analysis.bitDepth || savedAnalysisData.bitDepth
@@ -284,9 +295,11 @@ export function useAudioRecorder({
284
295
  min: newMin,
285
296
  max: newMax,
286
297
  }
287
- fullAnalysisRef.current.amplitudeRange = {
288
- min: newMin,
289
- max: newMax,
298
+ if (keepFullAnalysis) {
299
+ fullAnalysisRef.current.amplitudeRange = {
300
+ min: newMin,
301
+ max: newMax,
302
+ }
290
303
  }
291
304
 
292
305
  logger?.debug(
@@ -523,6 +536,7 @@ export function useAudioRecorder({
523
536
  onAudioStream,
524
537
  onRecordingInterrupted,
525
538
  onAudioAnalysis,
539
+ keepFullAnalysis: _keepFullAnalysis,
526
540
  ...options
527
541
  } = validatedOptions
528
542
  const { enableProcessing } = options
@@ -579,6 +593,7 @@ export function useAudioRecorder({
579
593
  onAudioStream,
580
594
  onRecordingInterrupted,
581
595
  onAudioAnalysis,
596
+ keepFullAnalysis: _keepFullAnalysis,
582
597
  ...options
583
598
  } = recordingOptions
584
599
 
@@ -603,7 +618,14 @@ export function useAudioRecorder({
603
618
  logger?.debug(`stoping recording`)
604
619
 
605
620
  const stopResult: AudioRecording = await audioStudio.stopRecording()
606
- stopResult.analysisData = fullAnalysisRef.current
621
+ if (shouldKeepFullAnalysis(recordingConfigRef.current)) {
622
+ stopResult.analysisData = fullAnalysisRef.current
623
+ } else {
624
+ // `keepFullAnalysis` is a hook-level retention policy. If a platform
625
+ // starts returning native analysisData in the future, keep opt-out
626
+ // semantics explicit and avoid leaking a full history here.
627
+ delete stopResult.analysisData
628
+ }
607
629
 
608
630
  if (analysisListenerRef.current) {
609
631
  analysisListenerRef.current.remove()