@siteed/audio-studio 3.1.0 → 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 +10 -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/AudioStudioModule.kt +53 -10
- package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
- 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/index.js +6 -1
- package/build/cjs/index.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/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/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/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -1
- package/ios/AudioProcessor.swift +99 -0
- package/ios/AudioStudioModule.swift +63 -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/index.ts +10 -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
|
+
}
|
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'
|