@siteed/audio-studio 3.1.0 → 3.2.0-beta.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 +30 -1
- package/README.md +97 -50
- 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/AudioStreamDecoder.kt +640 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +187 -13
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
- package/android/src/main/java/net/siteed/audiostudio/Constants.kt +4 -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/errors/AudioExtractionError.js +127 -0
- package/build/cjs/errors/AudioExtractionError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.js +152 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -0
- package/build/cjs/errors/AudioStreamError.test.js +61 -0
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -0
- package/build/cjs/index.js +12 -1
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/streamAudioData.js +467 -0
- package/build/cjs/streamAudioData.js.map +1 -0
- 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/errors/AudioExtractionError.js +122 -0
- package/build/esm/errors/AudioExtractionError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.js +147 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -0
- package/build/esm/errors/AudioStreamError.test.js +59 -0
- package/build/esm/errors/AudioStreamError.test.js.map +1 -0
- package/build/esm/index.js +5 -1
- package/build/esm/index.js.map +1 -1
- package/build/esm/streamAudioData.js +460 -0
- package/build/esm/streamAudioData.js.map +1 -0
- 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/errors/AudioExtractionError.d.ts +24 -0
- package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
- package/build/types/errors/AudioStreamError.d.ts +25 -0
- package/build/types/errors/AudioStreamError.d.ts.map +1 -0
- package/build/types/errors/AudioStreamError.test.d.ts +2 -0
- package/build/types/errors/AudioStreamError.test.d.ts.map +1 -0
- package/build/types/index.d.ts +8 -1
- package/build/types/index.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +114 -0
- package/build/types/streamAudioData.d.ts.map +1 -0
- package/ios/AudioProcessingHelpers.swift +10 -5
- package/ios/AudioProcessor.swift +99 -0
- package/ios/AudioStreamDecoder.swift +523 -0
- package/ios/AudioStudioModule.swift +210 -3
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +128 -0
- 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/errors/AudioExtractionError.ts +167 -0
- package/src/errors/AudioStreamError.test.ts +65 -0
- package/src/errors/AudioStreamError.ts +185 -0
- package/src/index.ts +34 -0
- package/src/streamAudioData.ts +654 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AudioStreamError, mapStreamError } from './AudioStreamError'
|
|
2
|
+
|
|
3
|
+
describe('AudioStreamError', () => {
|
|
4
|
+
it('passes through an existing AudioStreamError unchanged', () => {
|
|
5
|
+
const original = new AudioStreamError({
|
|
6
|
+
code: 'ERR_AUDIO_STREAM_CANCELLED',
|
|
7
|
+
message: 'aborted',
|
|
8
|
+
recoverable: true,
|
|
9
|
+
})
|
|
10
|
+
expect(mapStreamError(original)).toBe(original)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('maps native FILE_NOT_FOUND code', () => {
|
|
14
|
+
const mapped = mapStreamError({ code: 'FILE_NOT_FOUND', message: 'gone' })
|
|
15
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_FILE_NOT_FOUND')
|
|
16
|
+
expect(mapped.recoverable).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('maps unsupported codec text', () => {
|
|
20
|
+
const mapped = mapStreamError(new Error('No suitable codec for audio/opus'))
|
|
21
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('marks cancellation as recoverable', () => {
|
|
25
|
+
const mapped = mapStreamError({
|
|
26
|
+
code: 'ERR_AUDIO_STREAM_CANCELLED',
|
|
27
|
+
message: 'user cancelled',
|
|
28
|
+
})
|
|
29
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_CANCELLED')
|
|
30
|
+
expect(mapped.recoverable).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('falls back to UNKNOWN', () => {
|
|
34
|
+
const mapped = mapStreamError({})
|
|
35
|
+
expect(mapped.code).toBe('ERR_AUDIO_STREAM_UNKNOWN')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('preserves nativeCode and nativeMessage', () => {
|
|
39
|
+
const mapped = mapStreamError({
|
|
40
|
+
code: 'WEIRD_NATIVE_CODE',
|
|
41
|
+
message: 'something went wrong on the bridge',
|
|
42
|
+
})
|
|
43
|
+
expect(mapped.nativeCode).toBe('WEIRD_NATIVE_CODE')
|
|
44
|
+
expect(mapped.nativeMessage).toContain('bridge')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('serialises to a stable JSON payload', () => {
|
|
48
|
+
const err = new AudioStreamError({
|
|
49
|
+
code: 'ERR_AUDIO_STREAM_DECODE_FAILED',
|
|
50
|
+
message: 'decoder bust',
|
|
51
|
+
recoverable: false,
|
|
52
|
+
fileUri: 'file:///a.m4a',
|
|
53
|
+
platform: 'ios',
|
|
54
|
+
})
|
|
55
|
+
expect(err.toJSON()).toEqual({
|
|
56
|
+
code: 'ERR_AUDIO_STREAM_DECODE_FAILED',
|
|
57
|
+
message: 'decoder bust',
|
|
58
|
+
recoverable: false,
|
|
59
|
+
fileUri: 'file:///a.m4a',
|
|
60
|
+
platform: 'ios',
|
|
61
|
+
nativeCode: undefined,
|
|
62
|
+
nativeMessage: undefined,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable typed errors for `streamAudioData`. Callers can switch on `code`.
|
|
3
|
+
*/
|
|
4
|
+
export type AudioStreamErrorCode =
|
|
5
|
+
| 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT'
|
|
6
|
+
| 'ERR_AUDIO_STREAM_INVALID_RANGE'
|
|
7
|
+
| 'ERR_AUDIO_STREAM_DECODE_FAILED'
|
|
8
|
+
| 'ERR_AUDIO_STREAM_CANCELLED'
|
|
9
|
+
| 'ERR_AUDIO_STREAM_PERMISSION_DENIED'
|
|
10
|
+
| 'ERR_AUDIO_STREAM_FILE_NOT_FOUND'
|
|
11
|
+
| 'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT'
|
|
12
|
+
| 'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE'
|
|
13
|
+
| 'ERR_AUDIO_STREAM_BUSY'
|
|
14
|
+
| 'ERR_AUDIO_STREAM_UNKNOWN'
|
|
15
|
+
|
|
16
|
+
export interface AudioStreamErrorPayload {
|
|
17
|
+
code: AudioStreamErrorCode
|
|
18
|
+
message: string
|
|
19
|
+
recoverable: boolean
|
|
20
|
+
fileUri?: string
|
|
21
|
+
platform?: string
|
|
22
|
+
nativeCode?: string
|
|
23
|
+
nativeMessage?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RECOVERABLE: AudioStreamErrorCode[] = [
|
|
27
|
+
'ERR_AUDIO_STREAM_CANCELLED',
|
|
28
|
+
'ERR_AUDIO_STREAM_BUSY',
|
|
29
|
+
'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT',
|
|
30
|
+
'ERR_AUDIO_STREAM_PERMISSION_DENIED',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export class AudioStreamError extends Error {
|
|
34
|
+
readonly code: AudioStreamErrorCode
|
|
35
|
+
readonly recoverable: boolean
|
|
36
|
+
readonly fileUri?: string
|
|
37
|
+
readonly platform?: string
|
|
38
|
+
readonly nativeCode?: string
|
|
39
|
+
readonly nativeMessage?: string
|
|
40
|
+
|
|
41
|
+
constructor(payload: AudioStreamErrorPayload) {
|
|
42
|
+
super(payload.message)
|
|
43
|
+
this.name = 'AudioStreamError'
|
|
44
|
+
this.code = payload.code
|
|
45
|
+
this.recoverable = payload.recoverable
|
|
46
|
+
this.fileUri = payload.fileUri
|
|
47
|
+
this.platform = payload.platform
|
|
48
|
+
this.nativeCode = payload.nativeCode
|
|
49
|
+
this.nativeMessage = payload.nativeMessage
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toJSON(): AudioStreamErrorPayload {
|
|
53
|
+
return {
|
|
54
|
+
code: this.code,
|
|
55
|
+
message: this.message,
|
|
56
|
+
recoverable: this.recoverable,
|
|
57
|
+
fileUri: this.fileUri,
|
|
58
|
+
platform: this.platform,
|
|
59
|
+
nativeCode: this.nativeCode,
|
|
60
|
+
nativeMessage: this.nativeMessage,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getNativeMessage(err: unknown): string {
|
|
66
|
+
if (err instanceof Error) return err.message
|
|
67
|
+
if (typeof err === 'string') return err
|
|
68
|
+
try {
|
|
69
|
+
return JSON.stringify(err) ?? String(err)
|
|
70
|
+
} catch {
|
|
71
|
+
return String(err)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getNativeCode(err: unknown): string | undefined {
|
|
76
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
77
|
+
const code = (err as { code?: unknown }).code
|
|
78
|
+
if (typeof code === 'string') return code
|
|
79
|
+
}
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeCode(raw: string | undefined): AudioStreamErrorCode | null {
|
|
84
|
+
if (!raw) return null
|
|
85
|
+
const upper = raw.toUpperCase()
|
|
86
|
+
if (upper.startsWith('ERR_AUDIO_STREAM_')) {
|
|
87
|
+
const known: AudioStreamErrorCode[] = [
|
|
88
|
+
'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT',
|
|
89
|
+
'ERR_AUDIO_STREAM_INVALID_RANGE',
|
|
90
|
+
'ERR_AUDIO_STREAM_DECODE_FAILED',
|
|
91
|
+
'ERR_AUDIO_STREAM_CANCELLED',
|
|
92
|
+
'ERR_AUDIO_STREAM_PERMISSION_DENIED',
|
|
93
|
+
'ERR_AUDIO_STREAM_FILE_NOT_FOUND',
|
|
94
|
+
'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT',
|
|
95
|
+
'ERR_AUDIO_STREAM_NATIVE_UNAVAILABLE',
|
|
96
|
+
'ERR_AUDIO_STREAM_BUSY',
|
|
97
|
+
'ERR_AUDIO_STREAM_UNKNOWN',
|
|
98
|
+
]
|
|
99
|
+
if ((known as string[]).includes(upper)) {
|
|
100
|
+
return upper as AudioStreamErrorCode
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (upper.includes('FILE_NOT_FOUND') || upper === 'ENOENT') {
|
|
104
|
+
return 'ERR_AUDIO_STREAM_FILE_NOT_FOUND'
|
|
105
|
+
}
|
|
106
|
+
if (upper.includes('PERMISSION') || upper === 'EACCES') {
|
|
107
|
+
return 'ERR_AUDIO_STREAM_PERMISSION_DENIED'
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
upper.includes('UNSUPPORTED') ||
|
|
111
|
+
upper.includes('NO_SUITABLE_CODEC') ||
|
|
112
|
+
upper.includes('NO SUITABLE CODEC') ||
|
|
113
|
+
upper.includes('NOT SUPPORTED')
|
|
114
|
+
) {
|
|
115
|
+
return 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT'
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
upper.includes('INVALID_RANGE') ||
|
|
119
|
+
upper.includes('OUT_OF_RANGE') ||
|
|
120
|
+
upper.includes('INVALID_TIME')
|
|
121
|
+
) {
|
|
122
|
+
return 'ERR_AUDIO_STREAM_INVALID_RANGE'
|
|
123
|
+
}
|
|
124
|
+
if (upper.includes('CANCELLED') || upper.includes('CANCELED')) {
|
|
125
|
+
return 'ERR_AUDIO_STREAM_CANCELLED'
|
|
126
|
+
}
|
|
127
|
+
if (upper.includes('BUSY')) {
|
|
128
|
+
return 'ERR_AUDIO_STREAM_BUSY'
|
|
129
|
+
}
|
|
130
|
+
if (upper.includes('BACKPRESSURE')) {
|
|
131
|
+
return 'ERR_AUDIO_STREAM_BACKPRESSURE_TIMEOUT'
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
upper.includes('DECODE') ||
|
|
135
|
+
upper.includes('CODEC') ||
|
|
136
|
+
upper.includes('MALFORMED')
|
|
137
|
+
) {
|
|
138
|
+
return 'ERR_AUDIO_STREAM_DECODE_FAILED'
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function mapStreamError(
|
|
144
|
+
err: unknown,
|
|
145
|
+
fileUri?: string,
|
|
146
|
+
platform?: string
|
|
147
|
+
): AudioStreamError {
|
|
148
|
+
if (err instanceof AudioStreamError) return err
|
|
149
|
+
|
|
150
|
+
const nativeMessage = getNativeMessage(err)
|
|
151
|
+
const nativeCode = getNativeCode(err)
|
|
152
|
+
const lower = nativeMessage.toLowerCase()
|
|
153
|
+
|
|
154
|
+
let code =
|
|
155
|
+
normalizeCode(nativeCode) ??
|
|
156
|
+
normalizeCode(nativeMessage) ??
|
|
157
|
+
'ERR_AUDIO_STREAM_UNKNOWN'
|
|
158
|
+
|
|
159
|
+
if (code === 'ERR_AUDIO_STREAM_UNKNOWN') {
|
|
160
|
+
if (lower.includes('not found') || lower.includes('does not exist')) {
|
|
161
|
+
code = 'ERR_AUDIO_STREAM_FILE_NOT_FOUND'
|
|
162
|
+
} else if (
|
|
163
|
+
lower.includes('unsupported') ||
|
|
164
|
+
lower.includes('no suitable codec')
|
|
165
|
+
) {
|
|
166
|
+
code = 'ERR_AUDIO_STREAM_UNSUPPORTED_FORMAT'
|
|
167
|
+
} else if (lower.includes('permission') || lower.includes('denied')) {
|
|
168
|
+
code = 'ERR_AUDIO_STREAM_PERMISSION_DENIED'
|
|
169
|
+
} else if (lower.includes('decode') || lower.includes('codec')) {
|
|
170
|
+
code = 'ERR_AUDIO_STREAM_DECODE_FAILED'
|
|
171
|
+
} else if (lower.includes('cancel')) {
|
|
172
|
+
code = 'ERR_AUDIO_STREAM_CANCELLED'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return new AudioStreamError({
|
|
177
|
+
code,
|
|
178
|
+
message: `Audio stream failed (${code}): ${nativeMessage}`,
|
|
179
|
+
recoverable: RECOVERABLE.includes(code),
|
|
180
|
+
fileUri,
|
|
181
|
+
platform,
|
|
182
|
+
nativeCode,
|
|
183
|
+
nativeMessage,
|
|
184
|
+
})
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
useSharedAudioRecorder,
|
|
20
20
|
} from './AudioRecorder.provider'
|
|
21
21
|
import AudioStudioModule from './AudioStudioModule'
|
|
22
|
+
import {
|
|
23
|
+
getAudioDecodeCapabilities,
|
|
24
|
+
streamAudioData,
|
|
25
|
+
} from './streamAudioData'
|
|
22
26
|
import { trimAudio } from './trimAudio'
|
|
23
27
|
import { useAudioRecorder } from './useAudioRecorder'
|
|
24
28
|
|
|
@@ -44,6 +48,7 @@ export { AudioDeviceManager, audioDeviceManager } from './AudioDeviceManager'
|
|
|
44
48
|
export { useAudioDevices } from './hooks/useAudioDevices'
|
|
45
49
|
|
|
46
50
|
export { setMelSpectrogramWasmUrl } from './AudioAnalysis/wasmConfig'
|
|
51
|
+
export { extractPreviewBars } from './AudioAnalysis/extractPreviewBars'
|
|
47
52
|
|
|
48
53
|
export {
|
|
49
54
|
AudioRecorderProvider,
|
|
@@ -53,6 +58,8 @@ export {
|
|
|
53
58
|
extractPreview,
|
|
54
59
|
trimAudio,
|
|
55
60
|
extractAudioData,
|
|
61
|
+
streamAudioData,
|
|
62
|
+
getAudioDecodeCapabilities,
|
|
56
63
|
extractMelSpectrogram,
|
|
57
64
|
initMelStreamingWasm,
|
|
58
65
|
computeMelFrameWasm,
|
|
@@ -61,6 +68,33 @@ export {
|
|
|
61
68
|
useSharedAudioRecorder,
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
export {
|
|
72
|
+
AudioExtractionError,
|
|
73
|
+
mapExtractionError,
|
|
74
|
+
} from './errors/AudioExtractionError'
|
|
75
|
+
export type {
|
|
76
|
+
AudioExtractionErrorCode,
|
|
77
|
+
AudioExtractionErrorPayload,
|
|
78
|
+
} from './errors/AudioExtractionError'
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
AudioStreamError,
|
|
82
|
+
mapStreamError,
|
|
83
|
+
} from './errors/AudioStreamError'
|
|
84
|
+
export type {
|
|
85
|
+
AudioStreamErrorCode,
|
|
86
|
+
AudioStreamErrorPayload,
|
|
87
|
+
} from './errors/AudioStreamError'
|
|
88
|
+
|
|
89
|
+
export type {
|
|
90
|
+
StreamAudioDataOptions,
|
|
91
|
+
StreamAudioDataChunk,
|
|
92
|
+
StreamAudioDataProgress,
|
|
93
|
+
StreamAudioDataResult,
|
|
94
|
+
StreamAudioDataCallbacks,
|
|
95
|
+
AudioDecodeCapabilities,
|
|
96
|
+
} from './streamAudioData'
|
|
97
|
+
|
|
64
98
|
// Export all types
|
|
65
99
|
export type * from './AudioAnalysis/AudioAnalysis.types'
|
|
66
100
|
export type * from './AudioStudio.types'
|