@siteed/expo-audio-stream 1.17.0 → 2.0.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 +26 -1
- package/README.md +1 -1
- package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +68 -22
- package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +24 -0
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +836 -386
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +0 -2
- package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +35 -29
- package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +236 -96
- package/android/src/main/java/net/siteed/audiostream/FFT.kt +55 -0
- package/android/src/main/java/net/siteed/audiostream/Features.kt +49 -7
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +2 -4
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts +55 -47
- package/build/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts +60 -13
- package/build/AudioAnalysis/extractAudioAnalysis.d.ts.map +1 -1
- package/build/AudioAnalysis/extractAudioAnalysis.js +147 -162
- package/build/AudioAnalysis/extractAudioAnalysis.js.map +1 -1
- package/build/ExpoAudioStream.types.d.ts +47 -3
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +0 -1
- package/build/ExpoAudioStream.web.js.map +1 -1
- package/build/ExpoAudioStreamModule.d.ts.map +1 -1
- package/build/ExpoAudioStreamModule.js +216 -12
- package/build/ExpoAudioStreamModule.js.map +1 -1
- package/build/WebRecorder.web.d.ts +67 -13
- package/build/WebRecorder.web.d.ts.map +1 -1
- package/build/WebRecorder.web.js +177 -173
- package/build/WebRecorder.web.js.map +1 -1
- package/build/index.d.ts +3 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/build/useAudioRecorder.d.ts.map +1 -1
- package/build/useAudioRecorder.js +12 -8
- package/build/useAudioRecorder.js.map +1 -1
- package/build/utils/audioProcessing.d.ts +24 -0
- package/build/utils/audioProcessing.d.ts.map +1 -0
- package/build/utils/audioProcessing.js +133 -0
- package/build/utils/audioProcessing.js.map +1 -0
- package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
- package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
- package/build/workers/InlineFeaturesExtractor.web.js +694 -194
- package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
- package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
- package/build/workers/inlineAudioWebWorker.web.js +3 -2
- package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
- package/ios/AudioAnalysisData.swift +51 -16
- package/ios/AudioProcessingHelpers.swift +710 -26
- package/ios/AudioProcessor.swift +334 -185
- package/ios/AudioStreamManager.swift +2 -3
- package/ios/DataPoint.swift +25 -12
- package/ios/DecodingConfig.swift +47 -0
- package/ios/ExpoAudioStreamModule.swift +187 -103
- package/ios/FFT.swift +62 -0
- package/ios/Features.swift +24 -3
- package/ios/RecordingSettings.swift +7 -7
- package/package.json +2 -1
- package/plugin/build/index.js +6 -1
- package/plugin/src/index.ts +9 -1
- package/src/AudioAnalysis/AudioAnalysis.types.ts +68 -52
- package/src/AudioAnalysis/extractAudioAnalysis.ts +223 -219
- package/src/ExpoAudioStream.types.ts +53 -7
- package/src/ExpoAudioStream.web.ts +0 -1
- package/src/ExpoAudioStreamModule.ts +255 -10
- package/src/WebRecorder.web.ts +231 -244
- package/src/index.ts +5 -3
- package/src/useAudioRecorder.tsx +14 -10
- package/src/utils/audioProcessing.ts +205 -0
- package/src/workers/InlineFeaturesExtractor.web.tsx +694 -194
- package/src/workers/inlineAudioWebWorker.web.tsx +3 -2
package/src/WebRecorder.web.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/WebRecorder.ts
|
|
1
|
+
// packages/expo-audio-stream/src/WebRecorder.web.ts
|
|
2
2
|
|
|
3
3
|
import { AudioAnalysis } from './AudioAnalysis/AudioAnalysis.types'
|
|
4
4
|
import { ConsoleLike, RecordingConfig } from './ExpoAudioStream.types'
|
|
@@ -6,9 +6,7 @@ import {
|
|
|
6
6
|
EmitAudioAnalysisFunction,
|
|
7
7
|
EmitAudioEventFunction,
|
|
8
8
|
} from './ExpoAudioStream.web'
|
|
9
|
-
import { convertPCMToFloat32 } from './utils/convertPCMToFloat32'
|
|
10
9
|
import { encodingToBitDepth } from './utils/encodingToBitDepth'
|
|
11
|
-
import { writeWavHeader } from './utils/writeWavHeader'
|
|
12
10
|
import { InlineFeaturesExtractor } from './workers/InlineFeaturesExtractor.web'
|
|
13
11
|
import { InlineAudioWebWorker } from './workers/inlineAudioWebWorker.web'
|
|
14
12
|
|
|
@@ -28,27 +26,17 @@ interface AudioFeaturesEvent {
|
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
const DEFAULT_WEB_BITDEPTH = 32
|
|
31
|
-
const
|
|
29
|
+
const DEFAULT_SEGMENT_DURATION_MS = 100
|
|
32
30
|
const DEFAULT_WEB_INTERVAL = 500
|
|
33
31
|
const DEFAULT_WEB_NUMBER_OF_CHANNELS = 1
|
|
34
|
-
const DEFAULT_ALGORITHM = 'rms'
|
|
35
32
|
|
|
36
33
|
const TAG = 'WebRecorder'
|
|
37
34
|
|
|
38
|
-
const STOP_PERFORMANCE_MARKS = {
|
|
39
|
-
STOP_INITIATED: 'stopInitiated',
|
|
40
|
-
COMPRESSED_RECORDING_STOP: 'compressedRecordingStop',
|
|
41
|
-
AUDIO_WORKLET_STOP: 'audioWorkletStop',
|
|
42
|
-
CLEANUP: 'cleanup',
|
|
43
|
-
TOTAL_STOP_TIME: 'totalStopTime',
|
|
44
|
-
} as const
|
|
45
|
-
|
|
46
35
|
export class WebRecorder {
|
|
47
36
|
private audioContext: AudioContext
|
|
48
37
|
private audioWorkletNode!: AudioWorkletNode
|
|
49
38
|
private featureExtractorWorker?: Worker
|
|
50
39
|
private source: MediaStreamAudioSourceNode
|
|
51
|
-
private audioWorkletUrl: string
|
|
52
40
|
private emitAudioEventCallback: EmitAudioEventFunction
|
|
53
41
|
private emitAudioAnalysisCallback: EmitAudioAnalysisFunction
|
|
54
42
|
private config: RecordingConfig
|
|
@@ -64,12 +52,21 @@ export class WebRecorder {
|
|
|
64
52
|
private compressedSize: number = 0
|
|
65
53
|
private pendingCompressedChunk: Blob | null = null
|
|
66
54
|
private readonly wavMimeType = 'audio/wav'
|
|
67
|
-
|
|
55
|
+
private dataPointIdCounter: number = 0 // Add this property to track the counter
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initializes a new WebRecorder instance for audio recording and processing
|
|
59
|
+
* @param audioContext - The AudioContext to use for recording
|
|
60
|
+
* @param source - The MediaStreamAudioSourceNode providing the audio input
|
|
61
|
+
* @param recordingConfig - Configuration options for the recording
|
|
62
|
+
* @param emitAudioEventCallback - Callback function for audio data events
|
|
63
|
+
* @param emitAudioAnalysisCallback - Callback function for audio analysis events
|
|
64
|
+
* @param logger - Optional logger for debugging information
|
|
65
|
+
*/
|
|
68
66
|
constructor({
|
|
69
67
|
audioContext,
|
|
70
68
|
source,
|
|
71
69
|
recordingConfig,
|
|
72
|
-
audioWorkletUrl,
|
|
73
70
|
emitAudioEventCallback,
|
|
74
71
|
emitAudioAnalysisCallback,
|
|
75
72
|
logger,
|
|
@@ -77,14 +74,12 @@ export class WebRecorder {
|
|
|
77
74
|
audioContext: AudioContext
|
|
78
75
|
source: MediaStreamAudioSourceNode
|
|
79
76
|
recordingConfig: RecordingConfig
|
|
80
|
-
audioWorkletUrl: string
|
|
81
77
|
emitAudioEventCallback: EmitAudioEventFunction
|
|
82
78
|
emitAudioAnalysisCallback: EmitAudioAnalysisFunction
|
|
83
79
|
logger?: ConsoleLike
|
|
84
80
|
}) {
|
|
85
81
|
this.audioContext = audioContext
|
|
86
82
|
this.source = source
|
|
87
|
-
this.audioWorkletUrl = audioWorkletUrl
|
|
88
83
|
this.emitAudioEventCallback = emitAudioEventCallback
|
|
89
84
|
this.emitAudioAnalysisCallback = emitAudioAnalysisCallback
|
|
90
85
|
this.config = recordingConfig
|
|
@@ -112,16 +107,15 @@ export class WebRecorder {
|
|
|
112
107
|
|
|
113
108
|
this.audioAnalysisData = {
|
|
114
109
|
amplitudeRange: { min: 0, max: 0 },
|
|
110
|
+
rmsRange: { min: 0, max: 0 },
|
|
115
111
|
dataPoints: [],
|
|
116
112
|
durationMs: 0,
|
|
117
113
|
samples: 0,
|
|
118
|
-
amplitudeAlgorithm: recordingConfig.algorithm || DEFAULT_ALGORITHM,
|
|
119
114
|
bitDepth: this.bitDepth,
|
|
120
115
|
numberOfChannels: this.numberOfChannels,
|
|
121
116
|
sampleRate: this.config.sampleRate || this.audioContext.sampleRate,
|
|
122
|
-
|
|
123
|
-
this.config.
|
|
124
|
-
speakerChanges: [],
|
|
117
|
+
segmentDurationMs:
|
|
118
|
+
this.config.segmentDurationMs ?? DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms segments
|
|
125
119
|
}
|
|
126
120
|
|
|
127
121
|
if (recordingConfig.enableProcessing) {
|
|
@@ -134,19 +128,19 @@ export class WebRecorder {
|
|
|
134
128
|
}
|
|
135
129
|
}
|
|
136
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Initializes the audio worklet using an inline script
|
|
133
|
+
* Creates and connects the audio processing pipeline
|
|
134
|
+
*/
|
|
137
135
|
async init() {
|
|
138
136
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
await this.audioContext.audioWorklet.addModule(
|
|
147
|
-
this.audioWorkletUrl
|
|
148
|
-
)
|
|
149
|
-
}
|
|
137
|
+
// Create and use inline audio worklet
|
|
138
|
+
const blob = new Blob([InlineAudioWebWorker], {
|
|
139
|
+
type: 'application/javascript',
|
|
140
|
+
})
|
|
141
|
+
const url = URL.createObjectURL(blob)
|
|
142
|
+
await this.audioContext.audioWorklet.addModule(url)
|
|
143
|
+
|
|
150
144
|
this.audioWorkletNode = new AudioWorkletNode(
|
|
151
145
|
this.audioContext,
|
|
152
146
|
'recorder-processor'
|
|
@@ -170,33 +164,42 @@ export class WebRecorder {
|
|
|
170
164
|
event.data.sampleRate ?? this.audioContext.sampleRate
|
|
171
165
|
const duration = pcmBufferFloat.length / sampleRate
|
|
172
166
|
|
|
167
|
+
// Calculate bytes per sample based on bit depth
|
|
168
|
+
const bytesPerSample = this.bitDepth / 8
|
|
169
|
+
|
|
173
170
|
// Emit chunks without storing them
|
|
174
171
|
for (let i = 0; i < pcmBufferFloat.length; i += chunkSize) {
|
|
175
172
|
const chunk = pcmBufferFloat.slice(i, i + chunkSize)
|
|
176
173
|
const chunkPosition = this.position + i / sampleRate
|
|
177
174
|
|
|
175
|
+
// Calculate byte positions and samples
|
|
176
|
+
const startPosition = Math.floor(i * bytesPerSample)
|
|
177
|
+
const endPosition = Math.floor(
|
|
178
|
+
(i + chunk.length) * bytesPerSample
|
|
179
|
+
)
|
|
180
|
+
const samples = chunk.length // Number of samples in this chunk
|
|
181
|
+
|
|
178
182
|
// Process features if enabled
|
|
179
183
|
if (
|
|
180
184
|
this.config.enableProcessing &&
|
|
181
185
|
this.featureExtractorWorker
|
|
182
186
|
) {
|
|
183
|
-
this.featureExtractorWorker.postMessage(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
)
|
|
187
|
+
this.featureExtractorWorker.postMessage({
|
|
188
|
+
command: 'process',
|
|
189
|
+
channelData: chunk,
|
|
190
|
+
sampleRate,
|
|
191
|
+
segmentDurationMs:
|
|
192
|
+
this.config.segmentDurationMs ??
|
|
193
|
+
DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms
|
|
194
|
+
bitDepth: this.bitDepth,
|
|
195
|
+
fullAudioDurationMs: chunkPosition * 1000,
|
|
196
|
+
numberOfChannels: this.numberOfChannels,
|
|
197
|
+
features: this.config.features,
|
|
198
|
+
intervalAnalysis: this.config.intervalAnalysis,
|
|
199
|
+
startPosition,
|
|
200
|
+
endPosition,
|
|
201
|
+
samples,
|
|
202
|
+
})
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
// Emit chunk immediately
|
|
@@ -235,6 +238,7 @@ export class WebRecorder {
|
|
|
235
238
|
exportBitDepth: this.exportBitDepth,
|
|
236
239
|
channels: this.numberOfChannels,
|
|
237
240
|
interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
|
|
241
|
+
// enableLogging: !!this.logger,
|
|
238
242
|
})
|
|
239
243
|
|
|
240
244
|
// Connect the source to the AudioWorkletNode and start recording
|
|
@@ -245,33 +249,11 @@ export class WebRecorder {
|
|
|
245
249
|
}
|
|
246
250
|
}
|
|
247
251
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// We keep the url during dev and use the blob in production.
|
|
254
|
-
this.featureExtractorWorker = new Worker(
|
|
255
|
-
new URL(featuresExtratorUrl, window.location.href)
|
|
256
|
-
)
|
|
257
|
-
this.featureExtractorWorker.onmessage =
|
|
258
|
-
this.handleFeatureExtractorMessage.bind(this)
|
|
259
|
-
this.featureExtractorWorker.onerror =
|
|
260
|
-
this.handleWorkerError.bind(this)
|
|
261
|
-
} else {
|
|
262
|
-
// Fallback to the inline worker if the URL is not provided
|
|
263
|
-
this.initFallbackWorker()
|
|
264
|
-
}
|
|
265
|
-
} catch (error) {
|
|
266
|
-
console.error(
|
|
267
|
-
`[${TAG}] Failed to initialize feature extractor worker`,
|
|
268
|
-
error
|
|
269
|
-
)
|
|
270
|
-
this.initFallbackWorker()
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
initFallbackWorker() {
|
|
252
|
+
/**
|
|
253
|
+
* Initializes the feature extractor worker for audio analysis
|
|
254
|
+
* Creates an inline worker from a blob for audio feature extraction
|
|
255
|
+
*/
|
|
256
|
+
initFeatureExtractorWorker() {
|
|
275
257
|
try {
|
|
276
258
|
const blob = new Blob([InlineFeaturesExtractor], {
|
|
277
259
|
type: 'application/javascript',
|
|
@@ -281,63 +263,156 @@ export class WebRecorder {
|
|
|
281
263
|
this.featureExtractorWorker.onmessage =
|
|
282
264
|
this.handleFeatureExtractorMessage.bind(this)
|
|
283
265
|
this.featureExtractorWorker.onerror = (error) => {
|
|
284
|
-
console.error(`[${TAG}]
|
|
266
|
+
console.error(`[${TAG}] Feature extractor worker error:`, error)
|
|
285
267
|
}
|
|
286
|
-
this.logger?.log(
|
|
268
|
+
this.logger?.log(
|
|
269
|
+
'Feature extractor worker initialized successfully'
|
|
270
|
+
)
|
|
287
271
|
} catch (error) {
|
|
288
272
|
console.error(
|
|
289
|
-
`[${TAG}] Failed to initialize
|
|
273
|
+
`[${TAG}] Failed to initialize feature extractor worker`,
|
|
290
274
|
error
|
|
291
275
|
)
|
|
292
276
|
}
|
|
293
277
|
}
|
|
294
278
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Processes audio analysis results from the feature extractor worker
|
|
281
|
+
* Updates the audio analysis data and emits events
|
|
282
|
+
* @param event - The event containing audio analysis results
|
|
283
|
+
*/
|
|
299
284
|
handleFeatureExtractorMessage(event: AudioFeaturesEvent) {
|
|
300
285
|
if (event.data.command === 'features') {
|
|
301
286
|
const segmentResult = event.data.result
|
|
302
287
|
|
|
303
|
-
//
|
|
288
|
+
// Update the dataPointIdCounter based on the last ID received
|
|
289
|
+
if (
|
|
290
|
+
segmentResult.dataPoints &&
|
|
291
|
+
segmentResult.dataPoints.length > 0
|
|
292
|
+
) {
|
|
293
|
+
const lastDataPoint =
|
|
294
|
+
segmentResult.dataPoints[
|
|
295
|
+
segmentResult.dataPoints.length - 1
|
|
296
|
+
]
|
|
297
|
+
if (lastDataPoint && typeof lastDataPoint.id === 'number') {
|
|
298
|
+
this.dataPointIdCounter = Math.max(
|
|
299
|
+
this.dataPointIdCounter,
|
|
300
|
+
lastDataPoint.id + 1
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.debug('[WebRecorder] Raw segment result:', {
|
|
306
|
+
dataPointsLength: segmentResult.dataPoints.length,
|
|
307
|
+
durationMs: segmentResult.durationMs,
|
|
308
|
+
sampleRate: segmentResult.sampleRate,
|
|
309
|
+
amplitudeRange: segmentResult.amplitudeRange,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// Ensure consistent sample rate in the result
|
|
313
|
+
segmentResult.sampleRate =
|
|
314
|
+
this.config.sampleRate || this.audioContext.sampleRate
|
|
315
|
+
|
|
316
|
+
// Update the full audio analysis data with proper range merging
|
|
304
317
|
this.audioAnalysisData.dataPoints.push(...segmentResult.dataPoints)
|
|
305
|
-
this.audioAnalysisData.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
this.audioAnalysisData.
|
|
318
|
+
this.audioAnalysisData.durationMs += segmentResult.durationMs
|
|
319
|
+
|
|
320
|
+
// Make sure the sample rate is consistent
|
|
321
|
+
this.audioAnalysisData.sampleRate = segmentResult.sampleRate
|
|
322
|
+
|
|
323
|
+
// Properly merge amplitude ranges
|
|
309
324
|
if (segmentResult.amplitudeRange) {
|
|
310
|
-
this.audioAnalysisData.amplitudeRange
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
325
|
+
if (!this.audioAnalysisData.amplitudeRange) {
|
|
326
|
+
this.audioAnalysisData.amplitudeRange = {
|
|
327
|
+
...segmentResult.amplitudeRange,
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
this.audioAnalysisData.amplitudeRange = {
|
|
331
|
+
min: Math.min(
|
|
332
|
+
this.audioAnalysisData.amplitudeRange.min,
|
|
333
|
+
segmentResult.amplitudeRange.min
|
|
334
|
+
),
|
|
335
|
+
max: Math.max(
|
|
336
|
+
this.audioAnalysisData.amplitudeRange.max,
|
|
337
|
+
segmentResult.amplitudeRange.max
|
|
338
|
+
),
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Properly merge RMS ranges
|
|
344
|
+
if (segmentResult.rmsRange) {
|
|
345
|
+
if (!this.audioAnalysisData.rmsRange) {
|
|
346
|
+
this.audioAnalysisData.rmsRange = {
|
|
347
|
+
...segmentResult.rmsRange,
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
this.audioAnalysisData.rmsRange = {
|
|
351
|
+
min: Math.min(
|
|
352
|
+
this.audioAnalysisData.rmsRange.min,
|
|
353
|
+
segmentResult.rmsRange.min
|
|
354
|
+
),
|
|
355
|
+
max: Math.max(
|
|
356
|
+
this.audioAnalysisData.rmsRange.max,
|
|
357
|
+
segmentResult.rmsRange.max
|
|
358
|
+
),
|
|
359
|
+
}
|
|
319
360
|
}
|
|
320
361
|
}
|
|
321
|
-
|
|
362
|
+
|
|
322
363
|
this.logger?.debug('features event segmentResult', segmentResult)
|
|
323
364
|
this.logger?.debug(
|
|
324
365
|
`features event audioAnalysisData duration=${this.audioAnalysisData.durationMs}`,
|
|
325
366
|
this.audioAnalysisData
|
|
326
367
|
)
|
|
327
368
|
this.emitAudioAnalysisCallback(segmentResult)
|
|
369
|
+
|
|
370
|
+
console.debug('[WebRecorder] Updated audioAnalysisData:', {
|
|
371
|
+
dataPointsLength: this.audioAnalysisData.dataPoints.length,
|
|
372
|
+
durationMs: this.audioAnalysisData.durationMs,
|
|
373
|
+
sampleRate: this.audioAnalysisData.sampleRate,
|
|
374
|
+
amplitudeRange: this.audioAnalysisData.amplitudeRange,
|
|
375
|
+
})
|
|
328
376
|
}
|
|
329
377
|
}
|
|
330
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Resets the data point ID counter
|
|
381
|
+
* Used when starting a new recording
|
|
382
|
+
*/
|
|
383
|
+
resetDataPointCounter() {
|
|
384
|
+
this.dataPointIdCounter = 0
|
|
385
|
+
|
|
386
|
+
// Reset the counter in the worker
|
|
387
|
+
if (this.featureExtractorWorker) {
|
|
388
|
+
this.featureExtractorWorker.postMessage({
|
|
389
|
+
command: 'resetCounter',
|
|
390
|
+
startCounterFrom: 0,
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Starts the audio recording process
|
|
397
|
+
* Connects the audio nodes and begins capturing audio data
|
|
398
|
+
*/
|
|
331
399
|
start() {
|
|
332
400
|
this.source.connect(this.audioWorkletNode)
|
|
333
401
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
334
402
|
this.packetCount = 0
|
|
335
403
|
|
|
404
|
+
// Reset the counter when starting a new recording
|
|
405
|
+
this.resetDataPointCounter()
|
|
406
|
+
|
|
336
407
|
if (this.compressedMediaRecorder) {
|
|
337
408
|
this.compressedMediaRecorder.start(this.config.interval ?? 1000)
|
|
338
409
|
}
|
|
339
410
|
}
|
|
340
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Stops the audio recording process and returns the recorded data
|
|
414
|
+
* @returns Promise resolving to an object containing PCM data and optional compressed blob
|
|
415
|
+
*/
|
|
341
416
|
async stop(): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
|
|
342
417
|
try {
|
|
343
418
|
if (this.compressedMediaRecorder) {
|
|
@@ -359,6 +434,10 @@ export class WebRecorder {
|
|
|
359
434
|
}
|
|
360
435
|
}
|
|
361
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Cleans up resources when recording is stopped
|
|
439
|
+
* Closes audio context and disconnects nodes
|
|
440
|
+
*/
|
|
362
441
|
private cleanup() {
|
|
363
442
|
if (this.audioContext) {
|
|
364
443
|
this.audioContext.close()
|
|
@@ -372,113 +451,10 @@ export class WebRecorder {
|
|
|
372
451
|
this.stopMediaStreamTracks()
|
|
373
452
|
}
|
|
374
453
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}> {
|
|
380
|
-
const processStartTime = performance.now()
|
|
381
|
-
this.logger?.debug('[Performance] Starting recording stop process')
|
|
382
|
-
|
|
383
|
-
const [compressedData, workletData] = await Promise.all([
|
|
384
|
-
this.stopCompressedRecording(),
|
|
385
|
-
this.stopAudioWorklet(),
|
|
386
|
-
])
|
|
387
|
-
|
|
388
|
-
this.logger?.debug(
|
|
389
|
-
`[Performance] Recording stop process completed in ${performance.now() - processStartTime}ms`
|
|
390
|
-
)
|
|
391
|
-
return {
|
|
392
|
-
pcmData:
|
|
393
|
-
workletData ??
|
|
394
|
-
new Float32Array(this.audioAnalysisData.dataPoints.length),
|
|
395
|
-
compressedBlob: compressedData,
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Helper method to stop compressed recording
|
|
400
|
-
private stopCompressedRecording(): Promise<Blob | undefined> {
|
|
401
|
-
const startTime = performance.now()
|
|
402
|
-
this.logger?.debug(
|
|
403
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.COMPRESSED_RECORDING_STOP}] Starting compressed recording stop`
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
if (!this.compressedMediaRecorder) {
|
|
407
|
-
this.logger?.debug('[Performance] No compressed recorder to stop')
|
|
408
|
-
return Promise.resolve(undefined)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return new Promise((resolve) => {
|
|
412
|
-
this.compressedMediaRecorder!.onstop = () => {
|
|
413
|
-
const blob = new Blob(this.compressedChunks, {
|
|
414
|
-
type: 'audio/webm;codecs=opus',
|
|
415
|
-
})
|
|
416
|
-
this.logger?.debug(
|
|
417
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.COMPRESSED_RECORDING_STOP}] Compressed recording stopped in ${performance.now() - startTime}ms, size: ${blob.size}`
|
|
418
|
-
)
|
|
419
|
-
resolve(blob)
|
|
420
|
-
}
|
|
421
|
-
this.compressedMediaRecorder!.stop()
|
|
422
|
-
})
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Helper method to stop audio worklet
|
|
426
|
-
private stopAudioWorklet(): Promise<Float32Array | undefined> {
|
|
427
|
-
const startTime = performance.now()
|
|
428
|
-
this.logger?.debug(
|
|
429
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Starting audio worklet stop`
|
|
430
|
-
)
|
|
431
|
-
|
|
432
|
-
if (!this.audioWorkletNode) {
|
|
433
|
-
this.logger?.debug('[Performance] No audio worklet to stop')
|
|
434
|
-
return Promise.resolve(undefined)
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return new Promise((resolve) => {
|
|
438
|
-
const onMessage = (event: AudioWorkletEvent) => {
|
|
439
|
-
if (event.data.command === 'recordedData') {
|
|
440
|
-
this.audioWorkletNode?.port.removeEventListener(
|
|
441
|
-
'message',
|
|
442
|
-
onMessage
|
|
443
|
-
)
|
|
444
|
-
const rawPCMDataFull = event.data.recordedData?.slice(0)
|
|
445
|
-
|
|
446
|
-
if (!rawPCMDataFull) {
|
|
447
|
-
this.logger?.debug('[Performance] No PCM data received')
|
|
448
|
-
resolve(undefined)
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (this.exportBitDepth !== this.bitDepth) {
|
|
453
|
-
const conversionStart = performance.now()
|
|
454
|
-
convertPCMToFloat32({
|
|
455
|
-
buffer: rawPCMDataFull.buffer,
|
|
456
|
-
bitDepth: this.exportBitDepth,
|
|
457
|
-
skipWavHeader: true,
|
|
458
|
-
logger: this.logger,
|
|
459
|
-
}).then(({ pcmValues }) => {
|
|
460
|
-
this.logger?.debug(
|
|
461
|
-
`[Performance] PCM conversion completed in ${performance.now() - conversionStart}ms`
|
|
462
|
-
)
|
|
463
|
-
this.logger?.debug(
|
|
464
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Audio worklet stopped in ${performance.now() - startTime}ms`
|
|
465
|
-
)
|
|
466
|
-
resolve(pcmValues)
|
|
467
|
-
})
|
|
468
|
-
} else {
|
|
469
|
-
this.logger?.debug(
|
|
470
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Audio worklet stopped in ${performance.now() - startTime}ms`
|
|
471
|
-
)
|
|
472
|
-
resolve(rawPCMDataFull)
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
this.audioWorkletNode.port.addEventListener('message', onMessage)
|
|
478
|
-
this.audioWorkletNode.port.postMessage({ command: 'stop' })
|
|
479
|
-
})
|
|
480
|
-
}
|
|
481
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Pauses the audio recording process
|
|
456
|
+
* Disconnects audio nodes and pauses the media recorder
|
|
457
|
+
*/
|
|
482
458
|
pause() {
|
|
483
459
|
this.source.disconnect(this.audioWorkletNode) // Disconnect the source from the AudioWorkletNode
|
|
484
460
|
this.audioWorkletNode.disconnect(this.audioContext.destination) // Disconnect the AudioWorkletNode from the destination
|
|
@@ -486,50 +462,21 @@ export class WebRecorder {
|
|
|
486
462
|
this.compressedMediaRecorder?.pause()
|
|
487
463
|
}
|
|
488
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Stops all media stream tracks to release hardware resources
|
|
467
|
+
* Ensures recording indicators (like microphone icon) are turned off
|
|
468
|
+
*/
|
|
489
469
|
stopMediaStreamTracks() {
|
|
490
470
|
// Stop all audio tracks to stop the recording icon
|
|
491
471
|
const tracks = this.source.mediaStream.getTracks()
|
|
492
472
|
tracks.forEach((track) => track.stop())
|
|
493
473
|
}
|
|
494
474
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}) {
|
|
501
|
-
try {
|
|
502
|
-
// Create a WAV blob with proper headers
|
|
503
|
-
const wavHeaderBuffer = writeWavHeader({
|
|
504
|
-
buffer: recordedData,
|
|
505
|
-
sampleRate: this.audioContext.sampleRate,
|
|
506
|
-
numChannels: this.numberOfChannels,
|
|
507
|
-
bitDepth: this.exportBitDepth,
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
const blob = new Blob([wavHeaderBuffer], { type: 'audio/wav' })
|
|
511
|
-
const url = URL.createObjectURL(blob)
|
|
512
|
-
const response = await fetch(url)
|
|
513
|
-
const arrayBuffer = await response.arrayBuffer()
|
|
514
|
-
|
|
515
|
-
// Decode the audio data
|
|
516
|
-
const audioBuffer =
|
|
517
|
-
await this.audioContext.decodeAudioData(arrayBuffer)
|
|
518
|
-
|
|
519
|
-
// Create a buffer source node and play the audio
|
|
520
|
-
const bufferSource = this.audioContext.createBufferSource()
|
|
521
|
-
bufferSource.buffer = audioBuffer
|
|
522
|
-
bufferSource.connect(this.audioContext.destination)
|
|
523
|
-
bufferSource.start()
|
|
524
|
-
this.logger?.debug('Playing recorded data', recordedData)
|
|
525
|
-
|
|
526
|
-
// Clean up
|
|
527
|
-
URL.revokeObjectURL(url)
|
|
528
|
-
} catch (error) {
|
|
529
|
-
console.error(`[${TAG}] Failed to play recorded data:`, error)
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
475
|
+
/**
|
|
476
|
+
* Determines the audio format capabilities of the current audio context
|
|
477
|
+
* @param sampleRate - The sample rate to check
|
|
478
|
+
* @returns Object containing format information (sample rate, bit depth, channels)
|
|
479
|
+
*/
|
|
533
480
|
private checkAudioContextFormat({ sampleRate }: { sampleRate: number }) {
|
|
534
481
|
// Create a silent AudioBuffer
|
|
535
482
|
const frameCount = sampleRate * 1.0 // 1 second buffer
|
|
@@ -550,6 +497,10 @@ export class WebRecorder {
|
|
|
550
497
|
}
|
|
551
498
|
}
|
|
552
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Resumes a paused recording
|
|
502
|
+
* Reconnects audio nodes and resumes the media recorder
|
|
503
|
+
*/
|
|
553
504
|
resume() {
|
|
554
505
|
this.source.connect(this.audioWorkletNode)
|
|
555
506
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
@@ -557,6 +508,10 @@ export class WebRecorder {
|
|
|
557
508
|
this.compressedMediaRecorder?.resume()
|
|
558
509
|
}
|
|
559
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Initializes the compressed media recorder if compression is enabled
|
|
513
|
+
* Sets up event handlers for compressed audio data
|
|
514
|
+
*/
|
|
560
515
|
private initializeCompressedRecorder() {
|
|
561
516
|
try {
|
|
562
517
|
const mimeType = 'audio/webm;codecs=opus'
|
|
@@ -590,4 +545,36 @@ export class WebRecorder {
|
|
|
590
545
|
)
|
|
591
546
|
}
|
|
592
547
|
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Processes features if enabled
|
|
551
|
+
*/
|
|
552
|
+
processFeatures(
|
|
553
|
+
chunk: Float32Array,
|
|
554
|
+
sampleRate: number,
|
|
555
|
+
chunkPosition: number,
|
|
556
|
+
startPosition: number,
|
|
557
|
+
endPosition: number,
|
|
558
|
+
samples: number
|
|
559
|
+
) {
|
|
560
|
+
if (this.config.enableProcessing && this.featureExtractorWorker) {
|
|
561
|
+
this.featureExtractorWorker.postMessage({
|
|
562
|
+
command: 'process',
|
|
563
|
+
channelData: chunk,
|
|
564
|
+
sampleRate,
|
|
565
|
+
segmentDurationMs:
|
|
566
|
+
this.config.segmentDurationMs ??
|
|
567
|
+
DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms
|
|
568
|
+
bitDepth: this.bitDepth,
|
|
569
|
+
fullAudioDurationMs: chunkPosition * 1000,
|
|
570
|
+
numberOfChannels: this.numberOfChannels,
|
|
571
|
+
features: this.config.features,
|
|
572
|
+
intervalAnalysis: this.config.intervalAnalysis,
|
|
573
|
+
startPosition,
|
|
574
|
+
endPosition,
|
|
575
|
+
samples,
|
|
576
|
+
startCounterFrom: this.dataPointIdCounter, // Pass the current counter value
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
}
|
|
593
580
|
}
|