@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.
- package/CHANGELOG.md +19 -1
- package/README.md +108 -41
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
- package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +74 -22
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +86 -19
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
- package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
- package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
- package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
- package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/cjs/AudioStudio.types.js.map +1 -1
- package/build/cjs/errors/AudioExtractionError.js +127 -0
- package/build/cjs/errors/AudioExtractionError.js.map +1 -0
- package/build/cjs/index.js +6 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/useAudioRecorder.js +36 -18
- package/build/cjs/useAudioRecorder.js.map +1 -1
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreview.js +92 -15
- package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
- package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
- package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
- package/build/esm/AudioStudio.types.js.map +1 -1
- package/build/esm/errors/AudioExtractionError.js +122 -0
- package/build/esm/errors/AudioExtractionError.js.map +1 -0
- package/build/esm/index.js +2 -0
- package/build/esm/index.js.map +1 -1
- package/build/esm/useAudioRecorder.js +36 -18
- package/build/esm/useAudioRecorder.js.map +1 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
- package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
- package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
- package/build/types/AudioStudio.types.d.ts +14 -1
- package/build/types/AudioStudio.types.d.ts.map +1 -1
- package/build/types/errors/AudioExtractionError.d.ts +24 -0
- package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -1
- package/build/types/useAudioRecorder.d.ts.map +1 -1
- package/ios/AudioProcessor.swift +99 -0
- package/ios/AudioStreamManager.swift +79 -15
- package/ios/AudioStudioModule.swift +63 -0
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
- package/package.json +7 -7
- package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
- package/src/AudioAnalysis/extractPreview.ts +118 -17
- package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
- package/src/AudioStudio.types.ts +15 -1
- package/src/errors/AudioExtractionError.ts +167 -0
- package/src/index.ts +10 -0
- package/src/useAudioRecorder.tsx +36 -14
|
@@ -1,34 +1,135 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
}
|
package/src/AudioStudio.types.ts
CHANGED
|
@@ -160,7 +160,10 @@ export interface AudioRecording {
|
|
|
160
160
|
createdAt?: number
|
|
161
161
|
/** Array of transcription data if available */
|
|
162
162
|
transcripts?: TranscriberData[]
|
|
163
|
-
/**
|
|
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'
|
package/src/useAudioRecorder.tsx
CHANGED
|
@@ -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
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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()
|