refactor(speech): add ONNX type augmentation and fix type-aware lint errors (#701)

This commit is contained in:
Mohamed Boudra
2026-05-05 19:56:52 +07:00
parent ab9f07dd8a
commit b2c88e4312
4 changed files with 147 additions and 115 deletions
@@ -0,0 +1,15 @@
// Type augmentation for onnxruntime-node to include runtime properties
// not exposed in the official type definitions
import type * as ort from "onnxruntime-node";
declare module "onnxruntime-node" {
interface InferenceSession {
/** Input tensor names (available at runtime but not in types) */
readonly inputNames: string[];
/** Output tensor names (available at runtime but not in types) */
readonly outputNames?: string[];
/** Input metadata for shape/type info (available at runtime but not in types) */
readonly inputMetadata?: unknown;
}
}
@@ -16,6 +16,10 @@ type OrtModule = typeof import("onnxruntime-node");
type OrtSession = import("onnxruntime-node").InferenceSession;
type OrtTensor = import("onnxruntime-node").Tensor;
function createSessionFeed(feed: Record<string, OrtTensor>): Record<string, OrtTensor> {
return feed;
}
interface SentencePieceProcessor {
encodeIds: (text: string) => number[];
load?: (modelPath: string) => unknown;
@@ -52,27 +56,13 @@ function getSessionInputMeta(
session: OrtSession,
inputName: string,
): { type?: string; dims?: Array<number | string | null> } | undefined {
const metaAny = (session as unknown as { inputMetadata?: unknown }).inputMetadata;
if (Array.isArray(metaAny)) {
const entry = metaAny.find(
(m) => m && typeof m === "object" && (m as { name?: string }).name === inputName,
) as { type?: string; shape?: Array<number | string | null> } | undefined;
if (!entry) return undefined;
return { type: entry.type, dims: entry.shape };
const metaAny = session.inputMetadata;
if (!Array.isArray(metaAny)) {
return undefined;
}
if (metaAny && typeof metaAny === "object" && inputName in metaAny) {
const entry = (metaAny as Record<string, unknown>)[inputName] as
| {
type?: string;
dimensions?: Array<number | string | null>;
shape?: Array<number | string | null>;
}
| undefined;
return { type: entry?.type, dims: entry?.dimensions ?? entry?.shape };
}
return undefined;
const entry = metaAny.find((m) => m.name === inputName);
if (!entry) return undefined;
return { type: String(entry.type), dims: entry.shape };
}
function toBigInt64(values: number[]): BigInt64Array {
@@ -108,31 +98,54 @@ function normalizeTextForPocket(text: string): string {
}
async function loadOrt(): Promise<OrtModule> {
return (await import("onnxruntime-node")) as OrtModule;
return import("onnxruntime-node");
}
interface SentencePieceModule {
SentencePieceProcessor?: new () => SentencePieceProcessor;
default?:
| (new () => SentencePieceProcessor)
| { SentencePieceProcessor?: new () => SentencePieceProcessor };
}
function isSentencePieceModule(mod: unknown): mod is SentencePieceModule {
return mod !== null && typeof mod === "object";
}
function getSentencePieceProcessor(
mod: SentencePieceModule,
): (new () => SentencePieceProcessor) | undefined {
if (mod.SentencePieceProcessor) {
return mod.SentencePieceProcessor;
}
const defaultValue = mod.default;
if (
defaultValue &&
typeof defaultValue === "object" &&
"SentencePieceProcessor" in defaultValue
) {
return defaultValue.SentencePieceProcessor;
}
if (typeof defaultValue === "function") {
return defaultValue as new () => SentencePieceProcessor;
}
return undefined;
}
async function loadSentencePiece(tokenizerModelPath: string): Promise<SentencePieceProcessor> {
const mod = await import("@sctg/sentencepiece-js");
const modRecord = mod as unknown as {
SentencePieceProcessor?: new () => SentencePieceProcessor;
default?:
| (new () => SentencePieceProcessor)
| { SentencePieceProcessor?: new () => SentencePieceProcessor };
};
const defaultValue = modRecord.default;
const Processor =
modRecord.SentencePieceProcessor ??
(defaultValue && typeof defaultValue === "object" && "SentencePieceProcessor" in defaultValue
? defaultValue.SentencePieceProcessor
: undefined) ??
(typeof defaultValue === "function" ? defaultValue : undefined);
if (!isSentencePieceModule(mod)) {
throw new Error("@sctg/sentencepiece-js module has unexpected shape");
}
const Processor = getSentencePieceProcessor(mod);
if (!Processor) {
throw new Error("Failed to load SentencePiece processor from @sctg/sentencepiece-js");
}
const sp: SentencePieceProcessor = new Processor();
const sp = new Processor();
if (typeof sp.load === "function") {
await sp.load(tokenizerModelPath);
@@ -181,8 +194,7 @@ function createZeroTensorForInput(
function initState(session: OrtSession, ort: OrtModule): Record<string, OrtTensor> {
const out: Record<string, OrtTensor> = {};
const inputNames = (session as unknown as { inputNames: string[] }).inputNames;
for (const name of inputNames) {
for (const name of session.inputNames) {
if (name.startsWith("state_")) {
out[name] = createZeroTensorForInput(ort, session, name);
}
@@ -203,10 +215,15 @@ function updateStateFromOutputs(
}
}
interface OrtTensorWithData {
data: unknown;
}
function tensorDataFloat32(t: OrtTensor): Float32Array {
const data = (t as unknown as { data: unknown }).data;
const tensorWithData = t as OrtTensorWithData;
const data = tensorWithData.data;
if (data instanceof Float32Array) return data;
if (Array.isArray(data)) return Float32Array.from(data as number[]);
if (Array.isArray(data)) return Float32Array.from(data);
throw new Error("Unexpected tensor data type (expected Float32Array)");
}
@@ -362,11 +379,9 @@ class PocketTtsOnnxEngine {
const audioTensor = new ort.Tensor("float32", floatAudio, [1, 1, floatAudio.length]);
const encoded = await mimiEncoder.run({ audio: audioTensor });
const firstOutName = (mimiEncoder as unknown as { outputNames?: string[] }).outputNames?.[0];
const encodedRecord = encoded as unknown as Record<string, OrtTensor>;
const voiceEmb = firstOutName
? encodedRecord[firstOutName]
: (Object.values(encodedRecord)[0] as OrtTensor | undefined);
const firstOutName = mimiEncoder.outputNames?.[0];
const encodedRecord = encoded as Record<string, OrtTensor>;
const voiceEmb = firstOutName ? encodedRecord[firstOutName] : Object.values(encodedRecord)[0];
if (!voiceEmb) {
throw new Error("PocketTTS mimi_encoder: missing output");
}
@@ -399,15 +414,11 @@ class PocketTtsOnnxEngine {
}
private async runTextConditioner(tokenIds: OrtTensor): Promise<OrtTensor> {
const out = await this.textConditioner.run({
token_ids: tokenIds,
} as unknown as Record<string, OrtTensor>);
const firstOutName = (this.textConditioner as unknown as { outputNames?: string[] })
.outputNames?.[0];
const outRecord = out as unknown as Record<string, OrtTensor>;
const t = firstOutName
? outRecord[firstOutName]
: (Object.values(outRecord)[0] as OrtTensor | undefined);
const feed: Record<string, OrtTensor> = { token_ids: tokenIds };
const out = await this.textConditioner.run(feed);
const firstOutName = this.textConditioner.outputNames?.[0];
const outRecord = out as Record<string, OrtTensor>;
const t = firstOutName ? outRecord[firstOutName] : Object.values(outRecord)[0];
if (!t) throw new Error("PocketTTS text_conditioner: missing output");
return t;
}
@@ -420,20 +431,24 @@ class PocketTtsOnnxEngine {
const emptyText = new ort.Tensor("float32", new Float32Array(0), [1, 0, 1024]);
// Voice conditioning pass
const resVoice = await this.flowLmMain.run({
sequence: emptySeq,
text_embeddings: this.voiceEmbeddings,
...state,
} as unknown as Record<string, OrtTensor>);
updateStateFromOutputs(state, resVoice as unknown as Record<string, OrtTensor>);
const resVoice = await this.flowLmMain.run(
createSessionFeed({
sequence: emptySeq,
text_embeddings: this.voiceEmbeddings,
...state,
}),
);
updateStateFromOutputs(state, resVoice as Record<string, OrtTensor>);
// Text conditioning pass
const resText = await this.flowLmMain.run({
sequence: emptySeq,
text_embeddings: textEmbeddings,
...state,
} as unknown as Record<string, OrtTensor>);
updateStateFromOutputs(state, resText as unknown as Record<string, OrtTensor>);
const resText = await this.flowLmMain.run(
createSessionFeed({
sequence: emptySeq,
text_embeddings: textEmbeddings,
...state,
}),
);
updateStateFromOutputs(state, resText as Record<string, OrtTensor>);
// Autoregressive generation
const curr = new Float32Array(32);
@@ -444,14 +459,16 @@ class PocketTtsOnnxEngine {
let eosStep: number | null = null;
for (let step = 0; step < this.maxFrames; step += 1) {
const resStep = await this.flowLmMain.run({
sequence: currTensor,
text_embeddings: emptyText,
...state,
} as unknown as Record<string, OrtTensor>);
const resStep = await this.flowLmMain.run(
createSessionFeed({
sequence: currTensor,
text_embeddings: emptyText,
...state,
}),
);
const outputNames = (this.flowLmMain as unknown as { outputNames?: string[] }).outputNames;
const resStepRecord = resStep as unknown as Record<string, OrtTensor>;
const outputNames = this.flowLmMain.outputNames;
const resStepRecord = resStep as Record<string, OrtTensor>;
const conditioningName = outputNames?.[0] ?? Object.keys(resStepRecord)[0];
const eosName = outputNames?.[1] ?? Object.keys(resStepRecord)[1];
@@ -481,17 +498,17 @@ class PocketTtsOnnxEngine {
for (const st of this.stBuffers) {
const xTensor = new ort.Tensor("float32", x, [1, 32]);
const flowOut = await this.flowLmFlow.run({
c: conditioning,
s: st.s,
t: st.t,
x: xTensor,
} as unknown as Record<string, OrtTensor>);
const first = (this.flowLmFlow as unknown as { outputNames?: string[] }).outputNames?.[0];
const flowOutRecord = flowOut as unknown as Record<string, OrtTensor>;
const flowTensor = first
? flowOutRecord[first]
: (Object.values(flowOutRecord)[0] as OrtTensor | undefined);
const flowOut = await this.flowLmFlow.run(
createSessionFeed({
c: conditioning,
s: st.s,
t: st.t,
x: xTensor,
}),
);
const first = this.flowLmFlow.outputNames?.[0];
const flowOutRecord = flowOut as Record<string, OrtTensor>;
const flowTensor = first ? flowOutRecord[first] : Object.values(flowOutRecord)[0];
if (!flowTensor) throw new Error("PocketTTS flow_lm_flow: missing output");
const delta = tensorDataFloat32(flowTensor);
for (let i = 0; i < x.length; i += 1) {
@@ -516,18 +533,12 @@ class PocketTtsOnnxEngine {
}
const latent = new ort.Tensor("float32", flattened, [1, frameCount, 32]);
const out = await this.mimiDecoder.run({
latent,
...state,
} as unknown as Record<string, OrtTensor>);
const outRecord = out as unknown as Record<string, OrtTensor>;
const out = await this.mimiDecoder.run(createSessionFeed({ latent, ...state }));
const outRecord = out as Record<string, OrtTensor>;
updateStateFromOutputs(state, outRecord);
const firstOutName = (this.mimiDecoder as unknown as { outputNames?: string[] })
.outputNames?.[0];
const audioTensor = firstOutName
? outRecord[firstOutName]
: (Object.values(outRecord)[0] as OrtTensor | undefined);
const firstOutName = this.mimiDecoder.outputNames?.[0];
const audioTensor = firstOutName ? outRecord[firstOutName] : Object.values(outRecord)[0];
if (!audioTensor) {
throw new Error("PocketTTS mimi_decoder: missing audio output");
}
@@ -134,15 +134,21 @@ type ModelIdByKind<K extends SherpaOnnxModelKind> = {
export type LocalSttModelId = ModelIdByKind<"stt-online"> | ModelIdByKind<"stt-offline">;
export type LocalTtsModelId = ModelIdByKind<"tts">;
const ALL_MODEL_IDS = Object.keys(SHERPA_ONNX_MODEL_CATALOG) as SherpaOnnxModelId[];
export const LOCAL_STT_MODEL_IDS = ALL_MODEL_IDS.filter(
(id): id is LocalSttModelId => SHERPA_ONNX_MODEL_CATALOG[id].kind !== "tts",
const ALL_MODEL_IDS: SherpaOnnxModelId[] = Object.keys(SHERPA_ONNX_MODEL_CATALOG).filter(
(k): k is SherpaOnnxModelId => k in SHERPA_ONNX_MODEL_CATALOG,
);
export const LOCAL_TTS_MODEL_IDS = ALL_MODEL_IDS.filter(
(id): id is LocalTtsModelId => SHERPA_ONNX_MODEL_CATALOG[id].kind === "tts",
);
function isLocalSttModelId(id: SherpaOnnxModelId): id is LocalSttModelId {
return SHERPA_ONNX_MODEL_CATALOG[id].kind !== "tts";
}
function isLocalTtsModelId(id: SherpaOnnxModelId): id is LocalTtsModelId {
return SHERPA_ONNX_MODEL_CATALOG[id].kind === "tts";
}
export const LOCAL_STT_MODEL_IDS: LocalSttModelId[] = ALL_MODEL_IDS.filter(isLocalSttModelId);
export const LOCAL_TTS_MODEL_IDS: LocalTtsModelId[] = ALL_MODEL_IDS.filter(isLocalTtsModelId);
function resolveDefaultModelId(role: "stt"): LocalSttModelId;
function resolveDefaultModelId(role: "tts"): LocalTtsModelId;
@@ -160,10 +166,10 @@ function resolveDefaultModelId(role: DefaultModelRole): SherpaOnnxModelId {
export const DEFAULT_LOCAL_STT_MODEL = resolveDefaultModelId("stt");
export const DEFAULT_LOCAL_TTS_MODEL = resolveDefaultModelId("tts");
function buildAliasMap<T extends string>(modelIds: readonly T[]): Record<string, T> {
function buildAliasMap<T extends SherpaOnnxModelId>(modelIds: readonly T[]): Record<string, T> {
const aliasMap: Record<string, T> = {};
for (const modelId of modelIds) {
const aliases = SHERPA_ONNX_MODEL_CATALOG[modelId as SherpaOnnxModelId].aliases ?? [];
const aliases = SHERPA_ONNX_MODEL_CATALOG[modelId].aliases ?? [];
for (const alias of aliases) {
aliasMap[alias.trim().toLowerCase()] = modelId;
}
@@ -175,14 +181,14 @@ function createAliasedModelIdSchema<T extends string>(params: {
modelIds: readonly T[];
aliases: Record<string, T>;
}): z.ZodType<T, z.ZodTypeDef, string> {
const validIds = new Set(params.modelIds);
const validIds = new Set<string>(params.modelIds);
return z
.string()
.trim()
.toLowerCase()
.refine(
(value): value is T =>
validIds.has(value as T) || Object.prototype.hasOwnProperty.call(params.aliases, value),
validIds.has(value) || Object.prototype.hasOwnProperty.call(params.aliases, value),
{
message: "Invalid model id",
},
@@ -55,7 +55,9 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const tmpPath = `${outputPath}.tmp-${Date.now()}`;
await mkdir(path.dirname(outputPath), { recursive: true });
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
// The fetch ReadableStream type is slightly different from what Readable.fromWeb expects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeStream = Readable.fromWeb(res.body as any);
try {
await pipeline(nodeStream, createWriteStream(tmpPath));
@@ -207,18 +209,16 @@ export async function ensureSherpaOnnxModels(options: {
logger: pino.Logger;
}): Promise<Record<SherpaOnnxModelId, string>> {
const uniq = Array.from(new Set(options.modelIds));
const out: Partial<Record<SherpaOnnxModelId, string>> = {};
const paths = await Promise.all(
uniq.map((id) =>
ensureSherpaOnnxModel({
const entries: Array<[SherpaOnnxModelId, string]> = await Promise.all(
uniq.map(async (id) => {
const modelPath = await ensureSherpaOnnxModel({
modelsDir: options.modelsDir,
modelId: id,
logger: options.logger,
}),
),
});
return [id, modelPath] as [SherpaOnnxModelId, string];
}),
);
for (let i = 0; i < uniq.length; i += 1) {
out[uniq[i]] = paths[i]!;
}
return out as Record<SherpaOnnxModelId, string>;
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.fromEntries(entries) as Record<SherpaOnnxModelId, string>;
}