Parakeet is an offline model trained on whole utterances, so re-decoding the growing buffer to animate a live transcript cost O(n^2) work for a result the final decode replaced. Sessions now decode once per committed segment, and the composer shows a scrolling waveform of the mic level instead of running text. Long dictations split at a pause once past 60s (hard cap 90s) instead of on a blind 15s timer, so cuts no longer land mid-word. Committed segments decode while the user is still speaking: a 185s dictation returns 4.1s after stop instead of 11.0s, with identical text (816 vs 817 words). Also fixes two ways the stream manager could silently drop transcribed audio. It now counts the commits it issued instead of trusting the session's echoed events, so a commit still in flight when the client finishes can no longer be left out of the final text. And segment byte/peak accounting is reset where the commit is issued rather than when the event arrives, which could mistake the tail of a dictation for silence and clear it.
100 lines
2.8 KiB
JavaScript
100 lines
2.8 KiB
JavaScript
/**
|
|
* Pseudo-streaming transcription session for OpenAI-compatible Whisper
|
|
* endpoints (faster-whisper, whisper.cpp, OpenAI, ...).
|
|
*
|
|
* The Whisper HTTP API cannot stream, so audio is buffered per segment and
|
|
* transcribed on commit(). This matches how the local session behaves: the
|
|
* DictationStreamManager splits long dictations at pauses, and everything
|
|
* shorter is one request on stop.
|
|
*
|
|
* Implements the StreamingTranscriptionSession contract used by
|
|
* DictationStreamManager.
|
|
*/
|
|
|
|
import { EventEmitter } from 'events';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
import { transcribeAudio } from '../tts/stt.js';
|
|
import { pcm16ToWav } from './audio.js';
|
|
|
|
const OPENAI_COMPATIBLE_SAMPLE_RATE = 16000;
|
|
|
|
export class OpenAICompatibleTranscriptionSession extends EventEmitter {
|
|
/**
|
|
* @param {{ baseURL: string, model: string, apiKey?: string, language?: string, prompt?: string }} config
|
|
*/
|
|
constructor(config) {
|
|
super();
|
|
this.config = config;
|
|
this.requiredSampleRate = OPENAI_COMPATIBLE_SAMPLE_RATE;
|
|
this.connected = false;
|
|
this.segmentId = randomUUID();
|
|
this.previousSegmentId = null;
|
|
this.pcm16 = Buffer.alloc(0);
|
|
}
|
|
|
|
async connect() {
|
|
if (!this.config.baseURL) {
|
|
throw new Error('Custom STT server URL is not configured');
|
|
}
|
|
if (!this.config.model) {
|
|
throw new Error('STT model is not configured');
|
|
}
|
|
this.connected = true;
|
|
}
|
|
|
|
appendPcm16(chunk) {
|
|
if (!this.connected) {
|
|
this.emit('error', new Error('STT session not connected'));
|
|
return;
|
|
}
|
|
this.pcm16 = this.pcm16.length === 0 ? chunk : Buffer.concat([this.pcm16, chunk]);
|
|
}
|
|
|
|
commit() {
|
|
if (!this.connected) {
|
|
this.emit('error', new Error('STT session not connected'));
|
|
return;
|
|
}
|
|
|
|
const committedId = this.segmentId;
|
|
const previousSegmentId = this.previousSegmentId;
|
|
const committedPcm16 = this.pcm16;
|
|
this.previousSegmentId = committedId;
|
|
this.segmentId = randomUUID();
|
|
this.pcm16 = Buffer.alloc(0);
|
|
this.emit('committed', { segmentId: committedId, previousSegmentId });
|
|
|
|
void (async () => {
|
|
try {
|
|
const wav = pcm16ToWav(committedPcm16, OPENAI_COMPATIBLE_SAMPLE_RATE);
|
|
const text = await transcribeAudio({
|
|
audioBuffer: wav,
|
|
mimeType: 'audio/wav',
|
|
model: this.config.model,
|
|
baseURL: this.config.baseURL,
|
|
apiKey: this.config.apiKey,
|
|
language: this.config.language,
|
|
});
|
|
this.emit('transcript', {
|
|
segmentId: committedId,
|
|
transcript: (text ?? '').trim(),
|
|
isFinal: true,
|
|
});
|
|
} catch (err) {
|
|
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
})();
|
|
}
|
|
|
|
clear() {
|
|
this.pcm16 = Buffer.alloc(0);
|
|
this.segmentId = randomUUID();
|
|
}
|
|
|
|
close() {
|
|
this.connected = false;
|
|
this.pcm16 = Buffer.alloc(0);
|
|
}
|
|
}
|