whisper.rn
Version:
React Native binding of whisper.cpp
252 lines (197 loc) • 6.71 kB
text/typescript
import type { AudioStreamInterface, AudioStreamConfig, AudioStreamData } from '../types'
export interface JestAudioStreamAdapterOptions {
sampleRate?: number
channels?: number
bitsPerSample?: number
simulateLatency?: number // milliseconds
simulateErrors?: boolean
simulateStartErrorOnly?: boolean // only simulate errors on start, not initialize
chunkSize?: number // bytes per chunk
chunkInterval?: number // milliseconds between chunks
maxChunks?: number // maximum number of chunks to send
audioData?: Uint8Array // pre-defined audio data to stream
generateSilence?: boolean // generate silence if no audioData provided
}
export class JestAudioStreamAdapter implements AudioStreamInterface {
private config: AudioStreamConfig | null = null
private options: JestAudioStreamAdapterOptions
private isInitialized = false
private recording = false
private dataCallback?: (data: AudioStreamData) => void
private errorCallback?: (error: string) => void
private statusCallback?: (isRecording: boolean) => void
private streamInterval?: ReturnType<typeof setTimeout>
private chunksSent = 0
private startTime = 0
constructor(options: JestAudioStreamAdapterOptions = {}) {
this.options = {
sampleRate: 16000,
channels: 1,
bitsPerSample: 16,
simulateLatency: 0,
simulateErrors: false,
chunkSize: 3200, // 100ms at 16kHz, 16-bit, mono
chunkInterval: 100, // 100ms
maxChunks: -1, // unlimited
generateSilence: true,
...options,
}
}
async initialize(config: AudioStreamConfig): Promise<void> {
if (this.isInitialized) {
await this.release()
}
if (this.options.simulateLatency! > 0) {
await JestAudioStreamAdapter.delay(this.options.simulateLatency!)
}
if (this.options.simulateErrors && !this.options.simulateStartErrorOnly) {
throw new Error('Simulated initialization error')
}
this.config = config
this.isInitialized = true
}
async start(): Promise<void> {
if (!this.isInitialized) {
throw new Error('AudioStream not initialized')
}
if (this.recording) {
return
}
if (this.options.simulateLatency! > 0) {
await JestAudioStreamAdapter.delay(this.options.simulateLatency!)
}
if (this.options.simulateErrors) {
throw new Error('Simulated start error')
}
this.recording = true
this.chunksSent = 0
this.startTime = Date.now()
this.statusCallback?.(true)
this.startStreaming()
}
async stop(): Promise<void> {
if (!this.recording) {
return
}
if (this.options.simulateLatency! > 0) {
await JestAudioStreamAdapter.delay(this.options.simulateLatency!)
}
this.recording = false
this.statusCallback?.(false)
if (this.streamInterval) {
clearTimeout(this.streamInterval)
this.streamInterval = undefined
}
}
isRecording(): boolean {
return this.recording
}
onData(callback: (data: AudioStreamData) => void): void {
this.dataCallback = callback
}
onError(callback: (error: string) => void): void {
this.errorCallback = callback
}
onStatusChange(callback: (isRecording: boolean) => void): void {
this.statusCallback = callback
}
async release(): Promise<void> {
if (this.recording) {
await this.stop()
}
this.isInitialized = false
this.config = null
this.dataCallback = undefined
this.errorCallback = undefined
this.statusCallback = undefined
this.chunksSent = 0
}
// Test helper methods
simulateError(error: string): void {
this.errorCallback?.(error)
}
simulateDataChunk(data: Uint8Array): void {
if (!this.dataCallback || !this.config) {
return
}
const streamData: AudioStreamData = {
data,
sampleRate: this.config.sampleRate || this.options.sampleRate!,
channels: this.config.channels || this.options.channels!,
timestamp: Date.now(),
}
this.dataCallback(streamData)
}
getChunksSent(): number {
return this.chunksSent
}
getTotalBytesStreamed(): number {
return this.chunksSent * this.options.chunkSize!
}
getStreamDuration(): number {
return this.recording ? Date.now() - this.startTime : 0
}
private startStreaming(): void {
if (!this.dataCallback || !this.config) {
return
}
const streamChunk = () => {
if (!this.recording) {
return
}
// Check if we've reached the maximum chunks
if (this.options.maxChunks! > 0 && this.chunksSent >= this.options.maxChunks!) {
this.stop()
return
}
// Generate or use provided audio data
const audioData = this.generateAudioChunk()
if (audioData) {
this.simulateDataChunk(audioData)
this.chunksSent += 1
}
// Schedule next chunk if still recording
if (this.recording) {
this.streamInterval = setTimeout(streamChunk, this.options.chunkInterval!)
}
}
// Start streaming after a short delay
this.streamInterval = setTimeout(streamChunk, this.options.chunkInterval!)
}
private generateAudioChunk(): Uint8Array | null {
// If we have pre-defined audio data, use it
if (this.options.audioData) {
const startByte = this.chunksSent * this.options.chunkSize!
const endByte = Math.min(startByte + this.options.chunkSize!, this.options.audioData.length)
if (startByte >= this.options.audioData.length) {
return null // No more data
}
return this.options.audioData.subarray(startByte, endByte)
}
// Generate silence or simple tone
const chunkSize = this.options.chunkSize!
const audioData = new Uint8Array(chunkSize)
if (this.options.generateSilence) {
// Generate silence (all zeros)
audioData.fill(0)
} else {
// Generate a simple sine wave tone for testing
const sampleRate = this.options.sampleRate!
const frequency = 440 // A4 note
const samplesPerChunk = chunkSize / 2 // 16-bit samples
const timeOffset = (this.chunksSent * samplesPerChunk) / sampleRate
for (let i = 0; i < samplesPerChunk; i += 1) {
const time = timeOffset + i / sampleRate
const amplitude = Math.sin(2 * Math.PI * frequency * time) * 0.5
const sample = Math.round(amplitude * 32767) // 16-bit signed sample
// Convert to little-endian bytes
audioData[i * 2] = sample % 256
audioData[i * 2 + 1] = Math.floor(sample / 256) % 256
}
}
return audioData
}
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}