Files
paseo/packages/server/src/server/speech/sherpa/sherpa-online-recognizer.ts
T
Mohamed Boudra d791f79efe Local streaming STT/TTS with Parakeet + Pocket TTS
- Add provider-agnostic streaming speech interfaces
- Local STT via Sherpa (Zipformer/Paraformer) + Parakeet v3 (offline)
- Local TTS via Pocket TTS ONNX (onnxruntime-node) with chunked streaming
- Auto-download models into PASEO_HOME and add Node download script
- Add E2E download test that round-trips TTS->STT
2026-02-06 10:38:38 +07:00

128 lines
3.8 KiB
TypeScript

import { existsSync } from "node:fs";
import type pino from "pino";
import { loadSherpaOnnx } from "./sherpa-onnx-loader.js";
export type SherpaOnlineRecognizerModel =
| {
kind: "transducer";
encoder: string;
decoder: string;
joiner: string;
tokens: string;
modelType?: string;
}
| {
kind: "paraformer";
encoder: string;
decoder: string;
tokens: string;
};
export type SherpaOnlineRecognizerConfig = {
model: SherpaOnlineRecognizerModel;
numThreads?: number;
provider?: "cpu";
debug?: 0 | 1;
sampleRate?: number;
featureDim?: number;
decodingMethod?: "greedy_search";
maxActivePaths?: number;
enableEndpoint?: 0 | 1;
rule1MinTrailingSilence?: number;
rule2MinTrailingSilence?: number;
rule3MinUtteranceLength?: number;
};
function assertFileExists(filePath: string, label: string): void {
if (!existsSync(filePath)) {
throw new Error(`Missing ${label}: ${filePath}`);
}
}
export class SherpaOnlineRecognizerEngine {
public readonly recognizer: any;
public readonly sampleRate: number;
private readonly logger: pino.Logger;
constructor(config: SherpaOnlineRecognizerConfig, logger: pino.Logger) {
this.logger = logger.child({ module: "speech", provider: "sherpa-onnx", component: "online-recognizer" });
const { model } = config;
if (model.kind === "transducer") {
assertFileExists(model.encoder, "transducer encoder");
assertFileExists(model.decoder, "transducer decoder");
assertFileExists(model.joiner, "transducer joiner");
assertFileExists(model.tokens, "tokens");
} else {
assertFileExists(model.encoder, "paraformer encoder");
assertFileExists(model.decoder, "paraformer decoder");
assertFileExists(model.tokens, "tokens");
}
const sherpa = loadSherpaOnnx();
const modelConfig =
model.kind === "transducer"
? {
transducer: {
encoder: model.encoder,
decoder: model.decoder,
joiner: model.joiner,
},
tokens: model.tokens,
modelType: model.modelType ?? "zipformer",
}
: {
paraformer: {
encoder: model.encoder,
decoder: model.decoder,
},
tokens: model.tokens,
};
const featConfig = {
sampleRate: config.sampleRate ?? 16000,
featureDim: config.featureDim ?? 80,
};
const recognizerConfig = {
featConfig,
modelConfig: {
...modelConfig,
// NOTE: In the WASM-backed `sherpa-onnx` npm package, online recognizers
// error when `numThreads > 1`. Keep the default conservative.
numThreads: config.numThreads ?? 1,
provider: config.provider ?? "cpu",
debug: config.debug ?? 0,
},
decodingMethod: config.decodingMethod ?? "greedy_search",
maxActivePaths: config.maxActivePaths ?? 4,
enableEndpoint: config.enableEndpoint ?? 0,
rule1MinTrailingSilence: config.rule1MinTrailingSilence ?? 2.4,
rule2MinTrailingSilence: config.rule2MinTrailingSilence ?? 1.2,
rule3MinUtteranceLength: config.rule3MinUtteranceLength ?? 20,
};
this.recognizer = sherpa.createOnlineRecognizer(recognizerConfig);
const sr = this.recognizer?.config?.featConfig?.sampleRate;
this.sampleRate = typeof sr === "number" && Number.isFinite(sr) && sr > 0 ? sr : featConfig.sampleRate;
this.logger.info(
{ sampleRate: this.sampleRate, modelKind: model.kind, numThreads: config.numThreads ?? 2 },
"Sherpa online recognizer initialized"
);
}
createStream(): any {
return this.recognizer.createStream();
}
free(): void {
try {
this.recognizer?.free?.();
} catch (err) {
this.logger.warn({ err }, "Failed to free sherpa recognizer");
}
}
}