whisper.rn
Version:
React Native binding of whisper.cpp
383 lines (314 loc) • 9.95 kB
text/typescript
import type {
AudioStreamInterface,
AudioStreamConfig,
AudioStreamData,
} from '../types'
import { WavFileReader, WavFileReaderFs } from '../../utils/WavFileReader'
export interface SimulateFileOptions {
fs: WavFileReaderFs
filePath: string
playbackSpeed?: number // Default: 1.0 (real-time), 0.5 (half speed), 2.0 (double speed)
chunkDurationMs?: number // Default: 100ms chunks
loop?: boolean // Default: false
onEndOfFile?: () => void // Callback when end of file is reached
logger?: (message: string) => void // Default: noop - custom logger function
}
export class SimulateFileAudioStreamAdapter implements AudioStreamInterface {
private fileReader: WavFileReader
private config: AudioStreamConfig | null = null
private options: SimulateFileOptions
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 setInterval>
private currentBytePosition = 0
private startTime = 0
private pausedTime = 0
private hasReachedEnd = false
constructor(options: SimulateFileOptions) {
this.options = {
playbackSpeed: 1.0,
chunkDurationMs: 100,
loop: false,
logger: () => {},
...options,
}
this.fileReader = new WavFileReader(this.options.fs, this.options.filePath)
}
async initialize(config: AudioStreamConfig): Promise<void> {
if (this.isInitialized) {
await this.release()
}
try {
this.config = config
// Initialize the WAV file reader
await this.fileReader.initialize()
// Validate file format matches config
const header = this.fileReader.getHeader()
if (!header) {
throw new Error('Failed to read WAV file header')
}
// Warn about mismatched formats but allow processing
if (header.sampleRate !== config.sampleRate) {
this.log(
`WAV file sample rate (${header.sampleRate}Hz) differs from config (${config.sampleRate}Hz)`,
)
}
if (header.channels !== config.channels) {
this.log(
`WAV file channels (${header.channels}) differs from config (${config.channels})`,
)
}
if (header.bitsPerSample !== config.bitsPerSample) {
this.log(
`WAV file bits per sample (${header.bitsPerSample}) differs from config (${config.bitsPerSample})`,
)
}
this.isInitialized = true
this.log(
`Simulate audio stream initialized: ${header.duration.toFixed(2)}s at ${
this.options.playbackSpeed
}x speed`,
)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown initialization error'
this.errorCallback?.(errorMessage)
throw new Error(
`Failed to initialize SimulateFileAudioStreamAdapter: ${errorMessage}`,
)
}
}
async start(): Promise<void> {
if (!this.isInitialized || !this.config) {
throw new Error('Adapter not initialized')
}
if (this.recording) {
return
}
try {
this.recording = true
this.hasReachedEnd = false
this.startTime = Date.now() - this.pausedTime
this.statusCallback?.(true)
// Start streaming chunks
this.startStreaming()
this.log('File audio simulation started')
} catch (error) {
this.recording = false
this.statusCallback?.(false)
const errorMessage =
error instanceof Error ? error.message : 'Unknown start error'
this.errorCallback?.(errorMessage)
throw error
}
}
async stop(): Promise<void> {
if (!this.recording) {
return
}
try {
this.recording = false
this.pausedTime = Date.now() - this.startTime
// Stop the streaming interval
if (this.streamInterval) {
clearInterval(this.streamInterval)
this.streamInterval = undefined
}
this.statusCallback?.(false)
this.log('File audio simulation stopped')
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown stop error'
this.errorCallback?.(errorMessage)
}
}
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
}
onEnd(callback: () => void): void {
this.options.onEndOfFile = callback
}
async release(): Promise<void> {
await this.stop()
this.isInitialized = false
this.currentBytePosition = 0
this.pausedTime = 0
this.log('SimulateFileAudioStreamAdapter released')
}
/**
* Start the streaming process
*/
private startStreaming(): void {
if (!this.config || !this.isInitialized) {
return
}
const header = this.fileReader.getHeader()
if (!header) {
this.errorCallback?.('WAV file header not available')
return
}
// Calculate chunk size based on desired duration
const chunkDurationSec = (this.options.chunkDurationMs || 100) / 1000
const bytesPerSecond =
header.sampleRate * header.channels * (header.bitsPerSample / 8)
const chunkSizeBytes = Math.floor(chunkDurationSec * bytesPerSecond)
// Adjust interval timing based on playback speed
const intervalMs =
(this.options.chunkDurationMs || 100) /
(this.options.playbackSpeed || 1.0)
this.streamInterval = setInterval(() => {
if (!this.recording) {
return
}
try {
this.streamNextChunk(chunkSizeBytes)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Streaming error'
this.errorCallback?.(errorMessage)
this.stop()
}
}, intervalMs)
}
/**
* Stream the next audio chunk
*/
private streamNextChunk(chunkSizeBytes: number): void {
if (!this.dataCallback || !this.config) {
return
}
const header = this.fileReader.getHeader()
if (!header) {
return
}
// Get the next chunk of audio data
const audioChunk = this.fileReader.getAudioSlice(
this.currentBytePosition,
chunkSizeBytes,
)
if (!audioChunk || audioChunk.length === 0) {
// End of file reached
if (this.options.loop) {
// Reset to beginning for looping
this.currentBytePosition = 0
this.startTime = Date.now()
this.pausedTime = 0
this.hasReachedEnd = false
this.log('Looping audio file simulation')
return
}
// Stop streaming due to no new buffer
this.log('Audio file simulation completed - no new buffer available')
this.hasReachedEnd = true
// Call the end-of-file callback if provided
if (this.options.onEndOfFile) {
this.log('Calling onEndOfFile callback')
this.options.onEndOfFile()
}
// Stop the stream
this.stop()
return
}
// Update position
this.currentBytePosition += audioChunk.length
// Create stream data using the original file's format
const streamData: AudioStreamData = {
data: audioChunk,
sampleRate: header.sampleRate,
channels: header.channels,
timestamp: Date.now(),
}
// Send the chunk
this.dataCallback(streamData)
}
/**
* Get current playback statistics
*/
getStatistics() {
const header = this.fileReader.getHeader()
const currentTime = this.fileReader.byteToTime(this.currentBytePosition)
return {
filePath: this.options.filePath,
isRecording: this.recording,
currentTime,
totalDuration: header?.duration || 0,
progress: header ? currentTime / header.duration : 0,
playbackSpeed: this.options.playbackSpeed,
currentBytePosition: this.currentBytePosition,
totalBytes: this.fileReader.getTotalDataSize(),
hasReachedEnd: this.hasReachedEnd,
header,
}
}
/**
* Seek to a specific time position
*/
seekToTime(timeSeconds: number): void {
const header = this.fileReader.getHeader()
if (!header) {
return
}
const clampedTime = Math.max(0, Math.min(timeSeconds, header.duration))
this.currentBytePosition = this.fileReader.timeToByte(clampedTime)
// Reset timing if we're currently playing
if (this.recording) {
this.startTime =
Date.now() - (clampedTime * 1000) / (this.options.playbackSpeed || 1.0)
this.pausedTime = 0
}
this.log(`Seeked to ${clampedTime.toFixed(2)}s`)
}
/**
* Set playback speed
*/
setPlaybackSpeed(speed: number): void {
if (speed <= 0) {
throw new Error('Playback speed must be greater than 0')
}
this.options.playbackSpeed = speed
// If currently playing, restart streaming with new speed
if (this.recording) {
this.stop().then(() => {
this.start()
})
}
this.log(`Playback speed set to ${speed}x`)
}
/**
* Reset file buffer to beginning
*/
resetBuffer(): void {
this.log('Resetting file buffer to beginning')
// Reset position and timing
this.currentBytePosition = 0
this.startTime = Date.now()
this.pausedTime = 0
this.hasReachedEnd = false
// If currently playing, restart streaming from beginning
if (this.recording) {
this.log('Restarting streaming from beginning')
// Stop and restart to apply the reset
this.stop().then(() => {
this.start()
})
}
}
/**
* Logger function
*/
private log(message: string): void {
this.options.logger?.(`[SimulateFileAudioStreamAdapter] ${message}`)
}
}