@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +97 -50
  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/AudioStudioModule.kt +53 -10
  9. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  10. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  11. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  12. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  13. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  14. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  15. package/build/cjs/errors/AudioExtractionError.js +127 -0
  16. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  17. package/build/cjs/index.js +6 -1
  18. package/build/cjs/index.js.map +1 -1
  19. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  20. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  21. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  22. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  23. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  24. package/build/esm/errors/AudioExtractionError.js +122 -0
  25. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  26. package/build/esm/index.js +2 -0
  27. package/build/esm/index.js.map +1 -1
  28. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  29. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  30. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  31. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  32. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  33. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  34. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  35. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  36. package/build/types/index.d.ts +3 -0
  37. package/build/types/index.d.ts.map +1 -1
  38. package/ios/AudioProcessor.swift +99 -0
  39. package/ios/AudioStudioModule.swift +63 -0
  40. package/package.json +7 -7
  41. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  42. package/src/AudioAnalysis/extractPreview.ts +118 -17
  43. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  44. package/src/errors/AudioExtractionError.ts +167 -0
  45. 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'