@siteed/expo-audio-stream 1.16.0 → 2.0.0
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 +28 -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 +134 -23
- package/android/src/main/java/net/siteed/audiostream/AudioRecordingService.kt +35 -29
- package/android/src/main/java/net/siteed/audiostream/Constants.kt +1 -0
- 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 +4 -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 +49 -3
- package/build/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/ExpoAudioStream.types.js.map +1 -1
- package/build/ExpoAudioStream.web.d.ts +2 -0
- package/build/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/ExpoAudioStream.web.js +8 -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 +178 -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 +692 -175
- 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 +66 -22
- package/ios/DataPoint.swift +25 -12
- package/ios/DecodingConfig.swift +47 -0
- package/ios/ExpoAudioStreamModule.swift +189 -104
- package/ios/FFT.swift +62 -0
- package/ios/Features.swift +24 -3
- package/ios/RecordingSettings.swift +9 -7
- package/package.json +2 -1
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +10 -3
- package/plugin/src/index.ts +10 -1
- package/src/AudioAnalysis/AudioAnalysis.types.ts +68 -52
- package/src/AudioAnalysis/extractAudioAnalysis.ts +223 -219
- package/src/ExpoAudioStream.types.ts +57 -7
- package/src/ExpoAudioStream.web.ts +8 -1
- package/src/ExpoAudioStreamModule.ts +255 -10
- package/src/WebRecorder.web.ts +231 -243
- 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 +692 -175
- 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,32 +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
|
-
)
|
|
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
|
+
})
|
|
199
203
|
}
|
|
200
204
|
|
|
201
205
|
// Emit chunk immediately
|
|
@@ -234,6 +238,7 @@ export class WebRecorder {
|
|
|
234
238
|
exportBitDepth: this.exportBitDepth,
|
|
235
239
|
channels: this.numberOfChannels,
|
|
236
240
|
interval: this.config.interval ?? DEFAULT_WEB_INTERVAL,
|
|
241
|
+
// enableLogging: !!this.logger,
|
|
237
242
|
})
|
|
238
243
|
|
|
239
244
|
// Connect the source to the AudioWorkletNode and start recording
|
|
@@ -244,33 +249,11 @@ export class WebRecorder {
|
|
|
244
249
|
}
|
|
245
250
|
}
|
|
246
251
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// We keep the url during dev and use the blob in production.
|
|
253
|
-
this.featureExtractorWorker = new Worker(
|
|
254
|
-
new URL(featuresExtratorUrl, window.location.href)
|
|
255
|
-
)
|
|
256
|
-
this.featureExtractorWorker.onmessage =
|
|
257
|
-
this.handleFeatureExtractorMessage.bind(this)
|
|
258
|
-
this.featureExtractorWorker.onerror =
|
|
259
|
-
this.handleWorkerError.bind(this)
|
|
260
|
-
} else {
|
|
261
|
-
// Fallback to the inline worker if the URL is not provided
|
|
262
|
-
this.initFallbackWorker()
|
|
263
|
-
}
|
|
264
|
-
} catch (error) {
|
|
265
|
-
console.error(
|
|
266
|
-
`[${TAG}] Failed to initialize feature extractor worker`,
|
|
267
|
-
error
|
|
268
|
-
)
|
|
269
|
-
this.initFallbackWorker()
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
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() {
|
|
274
257
|
try {
|
|
275
258
|
const blob = new Blob([InlineFeaturesExtractor], {
|
|
276
259
|
type: 'application/javascript',
|
|
@@ -280,63 +263,156 @@ export class WebRecorder {
|
|
|
280
263
|
this.featureExtractorWorker.onmessage =
|
|
281
264
|
this.handleFeatureExtractorMessage.bind(this)
|
|
282
265
|
this.featureExtractorWorker.onerror = (error) => {
|
|
283
|
-
console.error(`[${TAG}]
|
|
266
|
+
console.error(`[${TAG}] Feature extractor worker error:`, error)
|
|
284
267
|
}
|
|
285
|
-
this.logger?.log(
|
|
268
|
+
this.logger?.log(
|
|
269
|
+
'Feature extractor worker initialized successfully'
|
|
270
|
+
)
|
|
286
271
|
} catch (error) {
|
|
287
272
|
console.error(
|
|
288
|
-
`[${TAG}] Failed to initialize
|
|
273
|
+
`[${TAG}] Failed to initialize feature extractor worker`,
|
|
289
274
|
error
|
|
290
275
|
)
|
|
291
276
|
}
|
|
292
277
|
}
|
|
293
278
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
*/
|
|
298
284
|
handleFeatureExtractorMessage(event: AudioFeaturesEvent) {
|
|
299
285
|
if (event.data.command === 'features') {
|
|
300
286
|
const segmentResult = event.data.result
|
|
301
287
|
|
|
302
|
-
//
|
|
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
|
|
303
317
|
this.audioAnalysisData.dataPoints.push(...segmentResult.dataPoints)
|
|
304
|
-
this.audioAnalysisData.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
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
|
|
308
324
|
if (segmentResult.amplitudeRange) {
|
|
309
|
-
this.audioAnalysisData.amplitudeRange
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|
|
318
360
|
}
|
|
319
361
|
}
|
|
320
|
-
|
|
362
|
+
|
|
321
363
|
this.logger?.debug('features event segmentResult', segmentResult)
|
|
322
364
|
this.logger?.debug(
|
|
323
365
|
`features event audioAnalysisData duration=${this.audioAnalysisData.durationMs}`,
|
|
324
366
|
this.audioAnalysisData
|
|
325
367
|
)
|
|
326
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
|
+
})
|
|
327
376
|
}
|
|
328
377
|
}
|
|
329
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
|
+
*/
|
|
330
399
|
start() {
|
|
331
400
|
this.source.connect(this.audioWorkletNode)
|
|
332
401
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
333
402
|
this.packetCount = 0
|
|
334
403
|
|
|
404
|
+
// Reset the counter when starting a new recording
|
|
405
|
+
this.resetDataPointCounter()
|
|
406
|
+
|
|
335
407
|
if (this.compressedMediaRecorder) {
|
|
336
408
|
this.compressedMediaRecorder.start(this.config.interval ?? 1000)
|
|
337
409
|
}
|
|
338
410
|
}
|
|
339
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
|
+
*/
|
|
340
416
|
async stop(): Promise<{ pcmData: Float32Array; compressedBlob?: Blob }> {
|
|
341
417
|
try {
|
|
342
418
|
if (this.compressedMediaRecorder) {
|
|
@@ -358,6 +434,10 @@ export class WebRecorder {
|
|
|
358
434
|
}
|
|
359
435
|
}
|
|
360
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Cleans up resources when recording is stopped
|
|
439
|
+
* Closes audio context and disconnects nodes
|
|
440
|
+
*/
|
|
361
441
|
private cleanup() {
|
|
362
442
|
if (this.audioContext) {
|
|
363
443
|
this.audioContext.close()
|
|
@@ -371,113 +451,10 @@ export class WebRecorder {
|
|
|
371
451
|
this.stopMediaStreamTracks()
|
|
372
452
|
}
|
|
373
453
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}> {
|
|
379
|
-
const processStartTime = performance.now()
|
|
380
|
-
this.logger?.debug('[Performance] Starting recording stop process')
|
|
381
|
-
|
|
382
|
-
const [compressedData, workletData] = await Promise.all([
|
|
383
|
-
this.stopCompressedRecording(),
|
|
384
|
-
this.stopAudioWorklet(),
|
|
385
|
-
])
|
|
386
|
-
|
|
387
|
-
this.logger?.debug(
|
|
388
|
-
`[Performance] Recording stop process completed in ${performance.now() - processStartTime}ms`
|
|
389
|
-
)
|
|
390
|
-
return {
|
|
391
|
-
pcmData:
|
|
392
|
-
workletData ??
|
|
393
|
-
new Float32Array(this.audioAnalysisData.dataPoints.length),
|
|
394
|
-
compressedBlob: compressedData,
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Helper method to stop compressed recording
|
|
399
|
-
private stopCompressedRecording(): Promise<Blob | undefined> {
|
|
400
|
-
const startTime = performance.now()
|
|
401
|
-
this.logger?.debug(
|
|
402
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.COMPRESSED_RECORDING_STOP}] Starting compressed recording stop`
|
|
403
|
-
)
|
|
404
|
-
|
|
405
|
-
if (!this.compressedMediaRecorder) {
|
|
406
|
-
this.logger?.debug('[Performance] No compressed recorder to stop')
|
|
407
|
-
return Promise.resolve(undefined)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return new Promise((resolve) => {
|
|
411
|
-
this.compressedMediaRecorder!.onstop = () => {
|
|
412
|
-
const blob = new Blob(this.compressedChunks, {
|
|
413
|
-
type: 'audio/webm;codecs=opus',
|
|
414
|
-
})
|
|
415
|
-
this.logger?.debug(
|
|
416
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.COMPRESSED_RECORDING_STOP}] Compressed recording stopped in ${performance.now() - startTime}ms, size: ${blob.size}`
|
|
417
|
-
)
|
|
418
|
-
resolve(blob)
|
|
419
|
-
}
|
|
420
|
-
this.compressedMediaRecorder!.stop()
|
|
421
|
-
})
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Helper method to stop audio worklet
|
|
425
|
-
private stopAudioWorklet(): Promise<Float32Array | undefined> {
|
|
426
|
-
const startTime = performance.now()
|
|
427
|
-
this.logger?.debug(
|
|
428
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Starting audio worklet stop`
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
if (!this.audioWorkletNode) {
|
|
432
|
-
this.logger?.debug('[Performance] No audio worklet to stop')
|
|
433
|
-
return Promise.resolve(undefined)
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
return new Promise((resolve) => {
|
|
437
|
-
const onMessage = (event: AudioWorkletEvent) => {
|
|
438
|
-
if (event.data.command === 'recordedData') {
|
|
439
|
-
this.audioWorkletNode?.port.removeEventListener(
|
|
440
|
-
'message',
|
|
441
|
-
onMessage
|
|
442
|
-
)
|
|
443
|
-
const rawPCMDataFull = event.data.recordedData?.slice(0)
|
|
444
|
-
|
|
445
|
-
if (!rawPCMDataFull) {
|
|
446
|
-
this.logger?.debug('[Performance] No PCM data received')
|
|
447
|
-
resolve(undefined)
|
|
448
|
-
return
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (this.exportBitDepth !== this.bitDepth) {
|
|
452
|
-
const conversionStart = performance.now()
|
|
453
|
-
convertPCMToFloat32({
|
|
454
|
-
buffer: rawPCMDataFull.buffer,
|
|
455
|
-
bitDepth: this.exportBitDepth,
|
|
456
|
-
skipWavHeader: true,
|
|
457
|
-
logger: this.logger,
|
|
458
|
-
}).then(({ pcmValues }) => {
|
|
459
|
-
this.logger?.debug(
|
|
460
|
-
`[Performance] PCM conversion completed in ${performance.now() - conversionStart}ms`
|
|
461
|
-
)
|
|
462
|
-
this.logger?.debug(
|
|
463
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Audio worklet stopped in ${performance.now() - startTime}ms`
|
|
464
|
-
)
|
|
465
|
-
resolve(pcmValues)
|
|
466
|
-
})
|
|
467
|
-
} else {
|
|
468
|
-
this.logger?.debug(
|
|
469
|
-
`[Performance][${STOP_PERFORMANCE_MARKS.AUDIO_WORKLET_STOP}] Audio worklet stopped in ${performance.now() - startTime}ms`
|
|
470
|
-
)
|
|
471
|
-
resolve(rawPCMDataFull)
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
this.audioWorkletNode.port.addEventListener('message', onMessage)
|
|
477
|
-
this.audioWorkletNode.port.postMessage({ command: 'stop' })
|
|
478
|
-
})
|
|
479
|
-
}
|
|
480
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Pauses the audio recording process
|
|
456
|
+
* Disconnects audio nodes and pauses the media recorder
|
|
457
|
+
*/
|
|
481
458
|
pause() {
|
|
482
459
|
this.source.disconnect(this.audioWorkletNode) // Disconnect the source from the AudioWorkletNode
|
|
483
460
|
this.audioWorkletNode.disconnect(this.audioContext.destination) // Disconnect the AudioWorkletNode from the destination
|
|
@@ -485,50 +462,21 @@ export class WebRecorder {
|
|
|
485
462
|
this.compressedMediaRecorder?.pause()
|
|
486
463
|
}
|
|
487
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Stops all media stream tracks to release hardware resources
|
|
467
|
+
* Ensures recording indicators (like microphone icon) are turned off
|
|
468
|
+
*/
|
|
488
469
|
stopMediaStreamTracks() {
|
|
489
470
|
// Stop all audio tracks to stop the recording icon
|
|
490
471
|
const tracks = this.source.mediaStream.getTracks()
|
|
491
472
|
tracks.forEach((track) => track.stop())
|
|
492
473
|
}
|
|
493
474
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}) {
|
|
500
|
-
try {
|
|
501
|
-
// Create a WAV blob with proper headers
|
|
502
|
-
const wavHeaderBuffer = writeWavHeader({
|
|
503
|
-
buffer: recordedData,
|
|
504
|
-
sampleRate: this.audioContext.sampleRate,
|
|
505
|
-
numChannels: this.numberOfChannels,
|
|
506
|
-
bitDepth: this.exportBitDepth,
|
|
507
|
-
})
|
|
508
|
-
|
|
509
|
-
const blob = new Blob([wavHeaderBuffer], { type: 'audio/wav' })
|
|
510
|
-
const url = URL.createObjectURL(blob)
|
|
511
|
-
const response = await fetch(url)
|
|
512
|
-
const arrayBuffer = await response.arrayBuffer()
|
|
513
|
-
|
|
514
|
-
// Decode the audio data
|
|
515
|
-
const audioBuffer =
|
|
516
|
-
await this.audioContext.decodeAudioData(arrayBuffer)
|
|
517
|
-
|
|
518
|
-
// Create a buffer source node and play the audio
|
|
519
|
-
const bufferSource = this.audioContext.createBufferSource()
|
|
520
|
-
bufferSource.buffer = audioBuffer
|
|
521
|
-
bufferSource.connect(this.audioContext.destination)
|
|
522
|
-
bufferSource.start()
|
|
523
|
-
this.logger?.debug('Playing recorded data', recordedData)
|
|
524
|
-
|
|
525
|
-
// Clean up
|
|
526
|
-
URL.revokeObjectURL(url)
|
|
527
|
-
} catch (error) {
|
|
528
|
-
console.error(`[${TAG}] Failed to play recorded data:`, error)
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
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
|
+
*/
|
|
532
480
|
private checkAudioContextFormat({ sampleRate }: { sampleRate: number }) {
|
|
533
481
|
// Create a silent AudioBuffer
|
|
534
482
|
const frameCount = sampleRate * 1.0 // 1 second buffer
|
|
@@ -549,6 +497,10 @@ export class WebRecorder {
|
|
|
549
497
|
}
|
|
550
498
|
}
|
|
551
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Resumes a paused recording
|
|
502
|
+
* Reconnects audio nodes and resumes the media recorder
|
|
503
|
+
*/
|
|
552
504
|
resume() {
|
|
553
505
|
this.source.connect(this.audioWorkletNode)
|
|
554
506
|
this.audioWorkletNode.connect(this.audioContext.destination)
|
|
@@ -556,6 +508,10 @@ export class WebRecorder {
|
|
|
556
508
|
this.compressedMediaRecorder?.resume()
|
|
557
509
|
}
|
|
558
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Initializes the compressed media recorder if compression is enabled
|
|
513
|
+
* Sets up event handlers for compressed audio data
|
|
514
|
+
*/
|
|
559
515
|
private initializeCompressedRecorder() {
|
|
560
516
|
try {
|
|
561
517
|
const mimeType = 'audio/webm;codecs=opus'
|
|
@@ -589,4 +545,36 @@ export class WebRecorder {
|
|
|
589
545
|
)
|
|
590
546
|
}
|
|
591
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
|
+
}
|
|
592
580
|
}
|