whisper.rn 0.5.0-rc.0 → 0.5.0-rc.10
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/README.md +128 -50
- package/android/build.gradle +1 -0
- package/android/src/main/CMakeLists.txt +1 -0
- package/android/src/main/java/com/rnwhisper/RNWhisper.java +35 -0
- package/android/src/main/java/com/rnwhisper/WhisperContext.java +33 -0
- package/android/src/main/jni.cpp +81 -0
- package/android/src/newarch/java/com/rnwhisper/RNWhisperModule.java +5 -0
- package/android/src/oldarch/java/com/rnwhisper/RNWhisperModule.java +5 -0
- package/cpp/jsi/RNWhisperJSI.cpp +42 -6
- package/ios/RNWhisper.mm +11 -0
- package/ios/RNWhisperContext.h +1 -0
- package/ios/RNWhisperContext.mm +46 -0
- package/ios/rnwhisper.xcframework/ios-arm64/rnwhisper.framework/Info.plist +0 -0
- package/ios/rnwhisper.xcframework/ios-arm64_x86_64-simulator/rnwhisper.framework/Info.plist +0 -0
- package/ios/rnwhisper.xcframework/ios-arm64_x86_64-simulator/rnwhisper.framework/_CodeSignature/CodeResources +1 -1
- package/ios/rnwhisper.xcframework/ios-arm64_x86_64-simulator/rnwhisper.framework/rnwhisper +0 -0
- package/ios/rnwhisper.xcframework/tvos-arm64/rnwhisper.framework/Info.plist +0 -0
- package/ios/rnwhisper.xcframework/tvos-arm64_x86_64-simulator/rnwhisper.framework/Info.plist +0 -0
- package/ios/rnwhisper.xcframework/tvos-arm64_x86_64-simulator/rnwhisper.framework/_CodeSignature/CodeResources +1 -1
- package/ios/rnwhisper.xcframework/tvos-arm64_x86_64-simulator/rnwhisper.framework/rnwhisper +0 -0
- package/lib/commonjs/AudioSessionIos.js +2 -1
- package/lib/commonjs/AudioSessionIos.js.map +1 -1
- package/lib/commonjs/NativeRNWhisper.js.map +1 -1
- package/lib/commonjs/index.js +50 -10
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/jest-mock.js +126 -0
- package/lib/commonjs/jest-mock.js.map +1 -0
- package/lib/commonjs/realtime-transcription/RealtimeTranscriber.js +857 -0
- package/lib/commonjs/realtime-transcription/RealtimeTranscriber.js.map +1 -0
- package/lib/commonjs/realtime-transcription/SliceManager.js +233 -0
- package/lib/commonjs/realtime-transcription/SliceManager.js.map +1 -0
- package/lib/commonjs/realtime-transcription/adapters/AudioPcmStreamAdapter.js +133 -0
- package/lib/commonjs/realtime-transcription/adapters/AudioPcmStreamAdapter.js.map +1 -0
- package/lib/commonjs/realtime-transcription/adapters/JestAudioStreamAdapter.js +201 -0
- package/lib/commonjs/realtime-transcription/adapters/JestAudioStreamAdapter.js.map +1 -0
- package/lib/commonjs/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.js +309 -0
- package/lib/commonjs/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.js.map +1 -0
- package/lib/commonjs/realtime-transcription/index.js +27 -0
- package/lib/commonjs/realtime-transcription/index.js.map +1 -0
- package/lib/commonjs/realtime-transcription/types.js +114 -0
- package/lib/commonjs/realtime-transcription/types.js.map +1 -0
- package/lib/commonjs/utils/WavFileReader.js +158 -0
- package/lib/commonjs/utils/WavFileReader.js.map +1 -0
- package/lib/commonjs/utils/WavFileWriter.js +181 -0
- package/lib/commonjs/utils/WavFileWriter.js.map +1 -0
- package/lib/commonjs/utils/common.js +25 -0
- package/lib/commonjs/utils/common.js.map +1 -0
- package/lib/module/AudioSessionIos.js +2 -1
- package/lib/module/AudioSessionIos.js.map +1 -1
- package/lib/module/NativeRNWhisper.js.map +1 -1
- package/lib/module/index.js +48 -10
- package/lib/module/index.js.map +1 -1
- package/lib/module/jest-mock.js +124 -0
- package/lib/module/jest-mock.js.map +1 -0
- package/lib/module/realtime-transcription/RealtimeTranscriber.js +851 -0
- package/lib/module/realtime-transcription/RealtimeTranscriber.js.map +1 -0
- package/lib/module/realtime-transcription/SliceManager.js +226 -0
- package/lib/module/realtime-transcription/SliceManager.js.map +1 -0
- package/lib/module/realtime-transcription/adapters/AudioPcmStreamAdapter.js +124 -0
- package/lib/module/realtime-transcription/adapters/AudioPcmStreamAdapter.js.map +1 -0
- package/lib/module/realtime-transcription/adapters/JestAudioStreamAdapter.js +194 -0
- package/lib/module/realtime-transcription/adapters/JestAudioStreamAdapter.js.map +1 -0
- package/lib/module/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.js +302 -0
- package/lib/module/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.js.map +1 -0
- package/lib/module/realtime-transcription/index.js +8 -0
- package/lib/module/realtime-transcription/index.js.map +1 -0
- package/lib/module/realtime-transcription/types.js +107 -0
- package/lib/module/realtime-transcription/types.js.map +1 -0
- package/lib/module/utils/WavFileReader.js +151 -0
- package/lib/module/utils/WavFileReader.js.map +1 -0
- package/lib/module/utils/WavFileWriter.js +174 -0
- package/lib/module/utils/WavFileWriter.js.map +1 -0
- package/lib/module/utils/common.js +18 -0
- package/lib/module/utils/common.js.map +1 -0
- package/lib/typescript/AudioSessionIos.d.ts +1 -1
- package/lib/typescript/AudioSessionIos.d.ts.map +1 -1
- package/lib/typescript/NativeRNWhisper.d.ts +1 -0
- package/lib/typescript/NativeRNWhisper.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +8 -4
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/jest-mock.d.ts +2 -0
- package/lib/typescript/jest-mock.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/RealtimeTranscriber.d.ts +166 -0
- package/lib/typescript/realtime-transcription/RealtimeTranscriber.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/SliceManager.d.ts +72 -0
- package/lib/typescript/realtime-transcription/SliceManager.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/adapters/AudioPcmStreamAdapter.d.ts +22 -0
- package/lib/typescript/realtime-transcription/adapters/AudioPcmStreamAdapter.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/adapters/JestAudioStreamAdapter.d.ts +44 -0
- package/lib/typescript/realtime-transcription/adapters/JestAudioStreamAdapter.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.d.ts +75 -0
- package/lib/typescript/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/index.d.ts +6 -0
- package/lib/typescript/realtime-transcription/index.d.ts.map +1 -0
- package/lib/typescript/realtime-transcription/types.d.ts +222 -0
- package/lib/typescript/realtime-transcription/types.d.ts.map +1 -0
- package/lib/typescript/utils/WavFileReader.d.ts +61 -0
- package/lib/typescript/utils/WavFileReader.d.ts.map +1 -0
- package/lib/typescript/utils/WavFileWriter.d.ts +57 -0
- package/lib/typescript/utils/WavFileWriter.d.ts.map +1 -0
- package/lib/typescript/utils/common.d.ts +9 -0
- package/lib/typescript/utils/common.d.ts.map +1 -0
- package/package.json +23 -11
- package/src/AudioSessionIos.ts +3 -2
- package/src/NativeRNWhisper.ts +2 -0
- package/src/index.ts +74 -22
- package/{jest/mock.js → src/jest-mock.ts} +2 -2
- package/src/realtime-transcription/RealtimeTranscriber.ts +1015 -0
- package/src/realtime-transcription/SliceManager.ts +252 -0
- package/src/realtime-transcription/adapters/AudioPcmStreamAdapter.ts +143 -0
- package/src/realtime-transcription/adapters/JestAudioStreamAdapter.ts +251 -0
- package/src/realtime-transcription/adapters/SimulateFileAudioStreamAdapter.ts +378 -0
- package/src/realtime-transcription/index.ts +34 -0
- package/src/realtime-transcription/types.ts +283 -0
- package/src/utils/WavFileReader.ts +202 -0
- package/src/utils/WavFileWriter.ts +206 -0
- package/src/utils/common.ts +17 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { AudioSlice, MemoryUsage } from './types'
|
|
2
|
+
|
|
3
|
+
export class SliceManager {
|
|
4
|
+
private slices: AudioSlice[] = []
|
|
5
|
+
|
|
6
|
+
private currentSliceIndex = 0
|
|
7
|
+
|
|
8
|
+
private transcribeSliceIndex = 0
|
|
9
|
+
|
|
10
|
+
private maxSlicesInMemory: number
|
|
11
|
+
|
|
12
|
+
private sliceDurationSec: number
|
|
13
|
+
|
|
14
|
+
private sampleRate: number
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
sliceDurationSec = 30,
|
|
18
|
+
maxSlicesInMemory = 1,
|
|
19
|
+
sampleRate = 16000,
|
|
20
|
+
) {
|
|
21
|
+
this.sliceDurationSec = sliceDurationSec
|
|
22
|
+
this.maxSlicesInMemory = maxSlicesInMemory
|
|
23
|
+
this.sampleRate = sampleRate
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Add audio data to the current slice
|
|
28
|
+
*/
|
|
29
|
+
addAudioData(audioData: Uint8Array): {
|
|
30
|
+
slice?: AudioSlice
|
|
31
|
+
} {
|
|
32
|
+
// Get or create current slice
|
|
33
|
+
const currentSlice = this.getCurrentSlice()
|
|
34
|
+
|
|
35
|
+
// Calculate bytes per slice (2 bytes per sample for 16-bit PCM)
|
|
36
|
+
const bytesPerSlice = this.sliceDurationSec * this.sampleRate * 2
|
|
37
|
+
|
|
38
|
+
// Check if adding this data would exceed slice capacity
|
|
39
|
+
if (currentSlice.sampleCount + audioData.length > bytesPerSlice) {
|
|
40
|
+
// Finalize current slice and create new one
|
|
41
|
+
this.finalizeCurrentSlice()
|
|
42
|
+
this.currentSliceIndex += 1
|
|
43
|
+
return this.addAudioData(audioData) // Recursively add to new slice
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Append data to current slice
|
|
47
|
+
const newData = new Uint8Array(currentSlice.sampleCount + audioData.length)
|
|
48
|
+
newData.set(currentSlice.data.subarray(0, currentSlice.sampleCount))
|
|
49
|
+
newData.set(audioData, currentSlice.sampleCount)
|
|
50
|
+
|
|
51
|
+
currentSlice.data = newData
|
|
52
|
+
currentSlice.sampleCount += audioData.length
|
|
53
|
+
currentSlice.endTime = Date.now()
|
|
54
|
+
|
|
55
|
+
// Check if slice is complete
|
|
56
|
+
const isSliceComplete = currentSlice.sampleCount >= bytesPerSlice * 0.8 // 80% full
|
|
57
|
+
|
|
58
|
+
if (isSliceComplete) {
|
|
59
|
+
this.finalizeCurrentSlice()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { slice: currentSlice }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the current slice being built
|
|
67
|
+
*/
|
|
68
|
+
private getCurrentSlice(): AudioSlice {
|
|
69
|
+
let slice = this.slices.find((s) => s.index === this.currentSliceIndex)
|
|
70
|
+
|
|
71
|
+
if (!slice) {
|
|
72
|
+
const bytesPerSlice = this.sliceDurationSec * this.sampleRate * 2 // 2 bytes per sample
|
|
73
|
+
slice = {
|
|
74
|
+
index: this.currentSliceIndex,
|
|
75
|
+
data: new Uint8Array(bytesPerSlice),
|
|
76
|
+
sampleCount: 0,
|
|
77
|
+
startTime: Date.now(),
|
|
78
|
+
endTime: Date.now(),
|
|
79
|
+
isProcessed: false,
|
|
80
|
+
isReleased: false,
|
|
81
|
+
}
|
|
82
|
+
this.slices.push(slice)
|
|
83
|
+
|
|
84
|
+
// Clean up old slices if we have too many
|
|
85
|
+
this.cleanupOldSlices()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return slice
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Finalize the current slice
|
|
93
|
+
*/
|
|
94
|
+
private finalizeCurrentSlice(): void {
|
|
95
|
+
const slice = this.slices.find((s) => s.index === this.currentSliceIndex)
|
|
96
|
+
if (slice && slice.sampleCount > 0) {
|
|
97
|
+
// Trim the data array to actual size
|
|
98
|
+
slice.data = slice.data.subarray(0, slice.sampleCount)
|
|
99
|
+
slice.endTime = Date.now()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get a slice for transcription
|
|
105
|
+
*/
|
|
106
|
+
getSliceForTranscription(): AudioSlice | null {
|
|
107
|
+
const slice = this.slices.find(
|
|
108
|
+
(s) => s.index === this.transcribeSliceIndex && !s.isProcessed,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (slice && slice.sampleCount > 0) {
|
|
112
|
+
return slice
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mark a slice as processed
|
|
120
|
+
*/
|
|
121
|
+
markSliceAsProcessed(sliceIndex: number): void {
|
|
122
|
+
const slice = this.slices.find((s) => s.index === sliceIndex)
|
|
123
|
+
if (slice) {
|
|
124
|
+
slice.isProcessed = true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Move to the next slice for transcription
|
|
130
|
+
*/
|
|
131
|
+
moveToNextTranscribeSlice(): void {
|
|
132
|
+
this.transcribeSliceIndex += 1
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get audio data for transcription (base64 encoded)
|
|
137
|
+
*/
|
|
138
|
+
getAudioDataForTranscription(sliceIndex: number): Uint8Array | null {
|
|
139
|
+
const slice = this.slices.find((s) => s.index === sliceIndex)
|
|
140
|
+
if (!slice || slice.sampleCount === 0) {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return slice.data.subarray(0, slice.sampleCount)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get a slice by index
|
|
149
|
+
*/
|
|
150
|
+
getSliceByIndex(sliceIndex: number): AudioSlice | null {
|
|
151
|
+
return this.slices.find((s) => s.index === sliceIndex) || null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Clean up old slices to manage memory
|
|
156
|
+
*/
|
|
157
|
+
private cleanupOldSlices(): void {
|
|
158
|
+
if (this.slices.length <= this.maxSlicesInMemory) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Sort slices by index
|
|
163
|
+
this.slices.sort((a, b) => a.index - b.index)
|
|
164
|
+
|
|
165
|
+
// Keep only the most recent slices
|
|
166
|
+
const slicesToKeep = this.slices.slice(-this.maxSlicesInMemory)
|
|
167
|
+
const slicesToRemove = this.slices.slice(0, -this.maxSlicesInMemory)
|
|
168
|
+
|
|
169
|
+
// Release old slices
|
|
170
|
+
slicesToRemove.forEach((slice) => {
|
|
171
|
+
if (!slice.isReleased) {
|
|
172
|
+
slice.isReleased = true
|
|
173
|
+
// Clear the audio data to free memory
|
|
174
|
+
slice.data = new Uint8Array(0)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
this.slices = slicesToKeep
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get memory usage statistics
|
|
183
|
+
*/
|
|
184
|
+
getMemoryUsage(): MemoryUsage {
|
|
185
|
+
const activeSlices = this.slices.filter((s) => !s.isReleased)
|
|
186
|
+
const totalBytes = activeSlices.reduce(
|
|
187
|
+
(sum, slice) => sum + slice.sampleCount,
|
|
188
|
+
0,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// Estimate memory usage (Uint8Array = 1 byte per sample)
|
|
192
|
+
const estimatedMB = totalBytes / (1024 * 1024)
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
slicesInMemory: activeSlices.length,
|
|
196
|
+
totalSamples: totalBytes / 2, // Convert bytes to samples (2 bytes per sample)
|
|
197
|
+
estimatedMB: Math.round(estimatedMB * 100) / 100, // Round to 2 decimal places
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reset all slices and indices
|
|
203
|
+
*/
|
|
204
|
+
reset(): void {
|
|
205
|
+
// Release all slices
|
|
206
|
+
this.slices.forEach((slice) => {
|
|
207
|
+
slice.isReleased = true
|
|
208
|
+
// Clear the audio data to free memory
|
|
209
|
+
slice.data = new Uint8Array(0)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Reset state
|
|
213
|
+
this.slices = []
|
|
214
|
+
this.currentSliceIndex = 0
|
|
215
|
+
this.transcribeSliceIndex = 0
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get current slice information
|
|
220
|
+
*/
|
|
221
|
+
getCurrentSliceInfo() {
|
|
222
|
+
return {
|
|
223
|
+
currentSliceIndex: this.currentSliceIndex,
|
|
224
|
+
transcribeSliceIndex: this.transcribeSliceIndex,
|
|
225
|
+
totalSlices: this.slices.length,
|
|
226
|
+
memoryUsage: this.getMemoryUsage(),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Force move to the next slice, finalizing the current one regardless of capacity
|
|
232
|
+
*/
|
|
233
|
+
forceNextSlice(): { slice?: AudioSlice } {
|
|
234
|
+
const currentSlice = this.slices.find(
|
|
235
|
+
(s) => s.index === this.currentSliceIndex,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if (currentSlice && currentSlice.sampleCount > 0) {
|
|
239
|
+
// Finalize current slice
|
|
240
|
+
this.finalizeCurrentSlice()
|
|
241
|
+
|
|
242
|
+
// Move to next slice
|
|
243
|
+
this.currentSliceIndex += 1
|
|
244
|
+
|
|
245
|
+
return { slice: currentSlice }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// If no current slice or it's empty, just move to next index
|
|
249
|
+
this.currentSliceIndex += 1
|
|
250
|
+
return {}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/* eslint-disable import/no-extraneous-dependencies */
|
|
2
|
+
// @ts-ignore
|
|
3
|
+
import LiveAudioStream from '@fugood/react-native-audio-pcm-stream'
|
|
4
|
+
import type { AudioStreamInterface, AudioStreamConfig, AudioStreamData } from '../types'
|
|
5
|
+
import { base64ToUint8Array } from '../../utils/common'
|
|
6
|
+
|
|
7
|
+
export class AudioPcmStreamAdapter implements AudioStreamInterface {
|
|
8
|
+
private isInitialized = false
|
|
9
|
+
|
|
10
|
+
private recording = false
|
|
11
|
+
|
|
12
|
+
private config: AudioStreamConfig | null = null
|
|
13
|
+
|
|
14
|
+
private dataCallback?: (data: AudioStreamData) => void
|
|
15
|
+
|
|
16
|
+
private errorCallback?: (error: string) => void
|
|
17
|
+
|
|
18
|
+
private statusCallback?: (isRecording: boolean) => void
|
|
19
|
+
|
|
20
|
+
async initialize(config: AudioStreamConfig): Promise<void> {
|
|
21
|
+
if (this.isInitialized) {
|
|
22
|
+
await this.release()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
this.config = config || null
|
|
27
|
+
|
|
28
|
+
// Initialize LiveAudioStream
|
|
29
|
+
LiveAudioStream.init({
|
|
30
|
+
sampleRate: config.sampleRate || 16000,
|
|
31
|
+
channels: config.channels || 1,
|
|
32
|
+
bitsPerSample: config.bitsPerSample || 16,
|
|
33
|
+
audioSource: config.audioSource || 6,
|
|
34
|
+
bufferSize: config.bufferSize || 16 * 1024,
|
|
35
|
+
wavFile: '', // We handle file writing separately
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Set up data listener
|
|
39
|
+
LiveAudioStream.on('data', this.handleAudioData.bind(this))
|
|
40
|
+
|
|
41
|
+
this.isInitialized = true
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown initialization error'
|
|
44
|
+
this.errorCallback?.(errorMessage)
|
|
45
|
+
throw new Error(`Failed to initialize LiveAudioStream: ${errorMessage}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async start(): Promise<void> {
|
|
50
|
+
if (!this.isInitialized) {
|
|
51
|
+
throw new Error('AudioStream not initialized')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.recording) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
LiveAudioStream.start()
|
|
60
|
+
this.recording = true
|
|
61
|
+
this.statusCallback?.(true)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown start error'
|
|
64
|
+
this.errorCallback?.(errorMessage)
|
|
65
|
+
throw new Error(`Failed to start recording: ${errorMessage}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async stop(): Promise<void> {
|
|
70
|
+
if (!this.recording) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await LiveAudioStream.stop()
|
|
76
|
+
this.recording = false
|
|
77
|
+
this.statusCallback?.(false)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown stop error'
|
|
80
|
+
this.errorCallback?.(errorMessage)
|
|
81
|
+
throw new Error(`Failed to stop recording: ${errorMessage}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
isRecording(): boolean {
|
|
86
|
+
return this.recording
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onData(callback: (data: AudioStreamData) => void): void {
|
|
90
|
+
this.dataCallback = callback
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onError(callback: (error: string) => void): void {
|
|
94
|
+
this.errorCallback = callback
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onStatusChange(callback: (isRecording: boolean) => void): void {
|
|
98
|
+
this.statusCallback = callback
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async release(): Promise<void> {
|
|
102
|
+
if (this.recording) {
|
|
103
|
+
await this.stop()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// LiveAudioStream doesn't have an explicit release method
|
|
108
|
+
// But we should remove listeners and reset state
|
|
109
|
+
this.isInitialized = false
|
|
110
|
+
this.config = null
|
|
111
|
+
this.dataCallback = undefined
|
|
112
|
+
this.errorCallback = undefined
|
|
113
|
+
this.statusCallback = undefined
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn('Error during LiveAudioStream release:', error)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Handle incoming audio data from LiveAudioStream
|
|
121
|
+
*/
|
|
122
|
+
private handleAudioData(base64Data: string): void {
|
|
123
|
+
if (!this.dataCallback) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const audioData = base64ToUint8Array(base64Data)
|
|
129
|
+
|
|
130
|
+
const streamData: AudioStreamData = {
|
|
131
|
+
data: audioData,
|
|
132
|
+
sampleRate: this.config?.sampleRate || 16000,
|
|
133
|
+
channels: this.config?.channels || 1,
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.dataCallback(streamData)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const errorMessage = error instanceof Error ? error.message : 'Audio processing error'
|
|
140
|
+
this.errorCallback?.(errorMessage)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { AudioStreamInterface, AudioStreamConfig, AudioStreamData } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface JestAudioStreamAdapterOptions {
|
|
4
|
+
sampleRate?: number
|
|
5
|
+
channels?: number
|
|
6
|
+
bitsPerSample?: number
|
|
7
|
+
simulateLatency?: number // milliseconds
|
|
8
|
+
simulateErrors?: boolean
|
|
9
|
+
simulateStartErrorOnly?: boolean // only simulate errors on start, not initialize
|
|
10
|
+
chunkSize?: number // bytes per chunk
|
|
11
|
+
chunkInterval?: number // milliseconds between chunks
|
|
12
|
+
maxChunks?: number // maximum number of chunks to send
|
|
13
|
+
audioData?: Uint8Array // pre-defined audio data to stream
|
|
14
|
+
generateSilence?: boolean // generate silence if no audioData provided
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class JestAudioStreamAdapter implements AudioStreamInterface {
|
|
18
|
+
private config: AudioStreamConfig | null = null
|
|
19
|
+
|
|
20
|
+
private options: JestAudioStreamAdapterOptions
|
|
21
|
+
|
|
22
|
+
private isInitialized = false
|
|
23
|
+
|
|
24
|
+
private recording = false
|
|
25
|
+
|
|
26
|
+
private dataCallback?: (data: AudioStreamData) => void
|
|
27
|
+
|
|
28
|
+
private errorCallback?: (error: string) => void
|
|
29
|
+
|
|
30
|
+
private statusCallback?: (isRecording: boolean) => void
|
|
31
|
+
|
|
32
|
+
private streamInterval?: ReturnType<typeof setTimeout>
|
|
33
|
+
|
|
34
|
+
private chunksSent = 0
|
|
35
|
+
|
|
36
|
+
private startTime = 0
|
|
37
|
+
|
|
38
|
+
constructor(options: JestAudioStreamAdapterOptions = {}) {
|
|
39
|
+
this.options = {
|
|
40
|
+
sampleRate: 16000,
|
|
41
|
+
channels: 1,
|
|
42
|
+
bitsPerSample: 16,
|
|
43
|
+
simulateLatency: 0,
|
|
44
|
+
simulateErrors: false,
|
|
45
|
+
chunkSize: 3200, // 100ms at 16kHz, 16-bit, mono
|
|
46
|
+
chunkInterval: 100, // 100ms
|
|
47
|
+
maxChunks: -1, // unlimited
|
|
48
|
+
generateSilence: true,
|
|
49
|
+
...options,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async initialize(config: AudioStreamConfig): Promise<void> {
|
|
54
|
+
if (this.isInitialized) {
|
|
55
|
+
await this.release()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (this.options.simulateLatency! > 0) {
|
|
59
|
+
await JestAudioStreamAdapter.delay(this.options.simulateLatency!)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (this.options.simulateErrors && !this.options.simulateStartErrorOnly) {
|
|
63
|
+
throw new Error('Simulated initialization error')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.config = config
|
|
67
|
+
this.isInitialized = true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async start(): Promise<void> {
|
|
71
|
+
if (!this.isInitialized) {
|
|
72
|
+
throw new Error('AudioStream not initialized')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.recording) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.options.simulateLatency! > 0) {
|
|
80
|
+
await JestAudioStreamAdapter.delay(this.options.simulateLatency!)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.options.simulateErrors) {
|
|
84
|
+
throw new Error('Simulated start error')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.recording = true
|
|
88
|
+
this.chunksSent = 0
|
|
89
|
+
this.startTime = Date.now()
|
|
90
|
+
this.statusCallback?.(true)
|
|
91
|
+
this.startStreaming()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async stop(): Promise<void> {
|
|
95
|
+
if (!this.recording) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (this.options.simulateLatency! > 0) {
|
|
100
|
+
await JestAudioStreamAdapter.delay(this.options.simulateLatency!)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.recording = false
|
|
104
|
+
this.statusCallback?.(false)
|
|
105
|
+
|
|
106
|
+
if (this.streamInterval) {
|
|
107
|
+
clearTimeout(this.streamInterval)
|
|
108
|
+
this.streamInterval = undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
isRecording(): boolean {
|
|
113
|
+
return this.recording
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onData(callback: (data: AudioStreamData) => void): void {
|
|
117
|
+
this.dataCallback = callback
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
onError(callback: (error: string) => void): void {
|
|
121
|
+
this.errorCallback = callback
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onStatusChange(callback: (isRecording: boolean) => void): void {
|
|
125
|
+
this.statusCallback = callback
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async release(): Promise<void> {
|
|
129
|
+
if (this.recording) {
|
|
130
|
+
await this.stop()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.isInitialized = false
|
|
134
|
+
this.config = null
|
|
135
|
+
this.dataCallback = undefined
|
|
136
|
+
this.errorCallback = undefined
|
|
137
|
+
this.statusCallback = undefined
|
|
138
|
+
this.chunksSent = 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Test helper methods
|
|
142
|
+
simulateError(error: string): void {
|
|
143
|
+
this.errorCallback?.(error)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
simulateDataChunk(data: Uint8Array): void {
|
|
147
|
+
if (!this.dataCallback || !this.config) {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const streamData: AudioStreamData = {
|
|
152
|
+
data,
|
|
153
|
+
sampleRate: this.config.sampleRate || this.options.sampleRate!,
|
|
154
|
+
channels: this.config.channels || this.options.channels!,
|
|
155
|
+
timestamp: Date.now(),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.dataCallback(streamData)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getChunksSent(): number {
|
|
162
|
+
return this.chunksSent
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getTotalBytesStreamed(): number {
|
|
166
|
+
return this.chunksSent * this.options.chunkSize!
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getStreamDuration(): number {
|
|
170
|
+
return this.recording ? Date.now() - this.startTime : 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private startStreaming(): void {
|
|
174
|
+
if (!this.dataCallback || !this.config) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const streamChunk = () => {
|
|
179
|
+
if (!this.recording) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check if we've reached the maximum chunks
|
|
184
|
+
if (this.options.maxChunks! > 0 && this.chunksSent >= this.options.maxChunks!) {
|
|
185
|
+
this.stop()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Generate or use provided audio data
|
|
190
|
+
const audioData = this.generateAudioChunk()
|
|
191
|
+
|
|
192
|
+
if (audioData) {
|
|
193
|
+
this.simulateDataChunk(audioData)
|
|
194
|
+
this.chunksSent += 1
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Schedule next chunk if still recording
|
|
198
|
+
if (this.recording) {
|
|
199
|
+
this.streamInterval = setTimeout(streamChunk, this.options.chunkInterval!)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Start streaming after a short delay
|
|
204
|
+
this.streamInterval = setTimeout(streamChunk, this.options.chunkInterval!)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private generateAudioChunk(): Uint8Array | null {
|
|
208
|
+
// If we have pre-defined audio data, use it
|
|
209
|
+
if (this.options.audioData) {
|
|
210
|
+
const startByte = this.chunksSent * this.options.chunkSize!
|
|
211
|
+
const endByte = Math.min(startByte + this.options.chunkSize!, this.options.audioData.length)
|
|
212
|
+
|
|
213
|
+
if (startByte >= this.options.audioData.length) {
|
|
214
|
+
return null // No more data
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return this.options.audioData.subarray(startByte, endByte)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Generate silence or simple tone
|
|
221
|
+
const chunkSize = this.options.chunkSize!
|
|
222
|
+
const audioData = new Uint8Array(chunkSize)
|
|
223
|
+
|
|
224
|
+
if (this.options.generateSilence) {
|
|
225
|
+
// Generate silence (all zeros)
|
|
226
|
+
audioData.fill(0)
|
|
227
|
+
} else {
|
|
228
|
+
// Generate a simple sine wave tone for testing
|
|
229
|
+
const sampleRate = this.options.sampleRate!
|
|
230
|
+
const frequency = 440 // A4 note
|
|
231
|
+
const samplesPerChunk = chunkSize / 2 // 16-bit samples
|
|
232
|
+
const timeOffset = (this.chunksSent * samplesPerChunk) / sampleRate
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < samplesPerChunk; i += 1) {
|
|
235
|
+
const time = timeOffset + i / sampleRate
|
|
236
|
+
const amplitude = Math.sin(2 * Math.PI * frequency * time) * 0.5
|
|
237
|
+
const sample = Math.round(amplitude * 32767) // 16-bit signed sample
|
|
238
|
+
|
|
239
|
+
// Convert to little-endian bytes
|
|
240
|
+
audioData[i * 2] = sample % 256
|
|
241
|
+
audioData[i * 2 + 1] = Math.floor(sample / 256) % 256
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return audioData
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private static delay(ms: number): Promise<void> {
|
|
249
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
250
|
+
}
|
|
251
|
+
}
|