Update files

This commit is contained in:
Mohamed Boudra
2026-02-11 11:04:49 +07:00
parent 002c09681d
commit 0d60588431
23 changed files with 1264 additions and 320 deletions
@@ -0,0 +1,433 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { createRootLogger } from "../src/server/logger.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { float32ToPcm16le, parsePcmRateFromFormat } from "../src/server/speech/audio.js";
import {
ensureLocalSpeechModels,
getLocalSpeechModelDir,
type LocalTtsModelId,
} from "../src/server/speech/providers/local/models.js";
import { PocketTtsOnnxTTS } from "../src/server/speech/providers/local/pocket/pocket-tts-onnx.js";
import {
LOCAL_TTS_MODEL_IDS,
LocalTtsModelIdSchema,
} from "../src/server/speech/providers/local/sherpa/model-catalog.js";
import { loadSherpaOnnxNode } from "../src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.js";
const DEFAULT_SAMPLE_TEXT =
"Paseo keeps your coding agents in your pocket, with fast local speech and reliable remote control.";
type ScriptOptions = {
modelsDir: string;
outputDir: string;
text: string;
speed: number;
modelIds: LocalTtsModelId[];
};
type GeneratedSample = {
modelId: LocalTtsModelId;
voiceLabel: string;
speakerId: number | null;
sampleRate: number;
durationSeconds: number;
relativePath: string;
};
function usage(): string {
return [
"Generate a local Sherpa/Pocket TTS sample matrix.",
"",
"Options:",
" --models-dir <dir> Directory for downloaded local speech models",
" --output-dir <dir> Directory where WAV samples are written",
" --model <id> TTS model to include (repeatable)",
" --text <sentence> Sample sentence used for every generated file",
" --speed <number> TTS speed multiplier for Sherpa models (default: 1.0)",
" --help Show this help",
"",
`Known TTS model IDs: ${LOCAL_TTS_MODEL_IDS.join(", ")}`,
].join("\n");
}
function parsePositiveNumber(raw: string, flag: string): number {
const parsed = Number.parseFloat(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive number (received: ${raw})`);
}
return parsed;
}
function parseArgs(argv: string[]): ScriptOptions {
const paseoHome = resolvePaseoHome();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
let modelsDir = process.env.PASEO_LOCAL_MODELS_DIR ?? `${paseoHome}/models/local-speech`;
let outputDir = path.resolve(process.cwd(), ".debug", "sherpa-tts-matrix", timestamp);
let text = DEFAULT_SAMPLE_TEXT;
let speed = parsePositiveNumber(process.env.PASEO_VOICE_LOCAL_TTS_SPEED ?? "1.0", "--speed");
const requestedModelIds: LocalTtsModelId[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--help") {
// eslint-disable-next-line no-console
console.log(usage());
process.exit(0);
}
if (arg === "--models-dir") {
modelsDir = path.resolve(argv[i + 1] ?? modelsDir);
i += 1;
continue;
}
if (arg === "--output-dir") {
outputDir = path.resolve(argv[i + 1] ?? outputDir);
i += 1;
continue;
}
if (arg === "--text") {
text = argv[i + 1] ?? text;
i += 1;
continue;
}
if (arg === "--speed") {
const raw = argv[i + 1];
if (!raw) {
throw new Error("--speed requires a value");
}
speed = parsePositiveNumber(raw, "--speed");
i += 1;
continue;
}
if (arg === "--model") {
const raw = argv[i + 1];
if (!raw) {
throw new Error("--model requires a value");
}
requestedModelIds.push(LocalTtsModelIdSchema.parse(raw));
i += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
const trimmed = text.trim();
if (!trimmed) {
throw new Error("Sample text cannot be empty");
}
const modelIds =
requestedModelIds.length > 0
? Array.from(new Set(requestedModelIds))
: [...LOCAL_TTS_MODEL_IDS];
return {
modelsDir,
outputDir,
text: trimmed,
speed,
modelIds,
};
}
function pcm16MonoToWavBuffer(pcm16: Buffer, sampleRate: number): Buffer {
const channels = 1;
const bitsPerSample = 16;
const headerSize = 44;
const wav = Buffer.alloc(headerSize + pcm16.length);
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
const blockAlign = (channels * bitsPerSample) / 8;
wav.write("RIFF", 0);
wav.writeUInt32LE(36 + pcm16.length, 4);
wav.write("WAVE", 8);
wav.write("fmt ", 12);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(channels, 22);
wav.writeUInt32LE(sampleRate, 24);
wav.writeUInt32LE(byteRate, 28);
wav.writeUInt16LE(blockAlign, 32);
wav.writeUInt16LE(bitsPerSample, 34);
wav.write("data", 36);
wav.writeUInt32LE(pcm16.length, 40);
pcm16.copy(wav, 44);
return wav;
}
function sanitizeFilePart(input: string): string {
return input.replace(/[^a-zA-Z0-9._-]/g, "-");
}
function toFloatSamples(audio: unknown): Float32Array {
const raw = (audio as { samples?: unknown })?.samples;
if (raw instanceof Float32Array) {
return raw;
}
if (Array.isArray(raw)) {
return Float32Array.from(raw as number[]);
}
throw new Error("Unexpected Sherpa TTS output: missing Float32 samples");
}
function toSampleRate(audio: unknown, fallback: number): number {
const sampleRate = (audio as { sampleRate?: unknown })?.sampleRate;
if (typeof sampleRate === "number" && Number.isFinite(sampleRate) && sampleRate > 0) {
return sampleRate;
}
return fallback;
}
function createSherpaOfflineTts(params: {
modelId: "kokoro-en-v0_19" | "kitten-nano-en-v0_1-fp16";
modelDir: string;
speed: number;
}): { tts: any; speed: number } {
const sherpa = loadSherpaOnnxNode();
const model =
params.modelId === "kokoro-en-v0_19"
? {
kokoro: {
model: `${params.modelDir}/model.onnx`,
voices: `${params.modelDir}/voices.bin`,
tokens: `${params.modelDir}/tokens.txt`,
dataDir: `${params.modelDir}/espeak-ng-data`,
lengthScale: 1.0,
},
}
: {
kitten: {
model: `${params.modelDir}/model.fp16.onnx`,
voices: `${params.modelDir}/voices.bin`,
tokens: `${params.modelDir}/tokens.txt`,
dataDir: `${params.modelDir}/espeak-ng-data`,
lengthScale: 1.0,
},
};
return {
tts: new sherpa.OfflineTts({
model,
numThreads: 2,
provider: "cpu",
maxNumSentences: 1,
}),
speed: params.speed,
};
}
async function readStreamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream as AsyncIterable<Buffer | Uint8Array>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function toCsv(rows: GeneratedSample[]): string {
const header = [
"model_id",
"voice_label",
"speaker_id",
"sample_rate",
"duration_seconds",
"relative_path",
];
const lines = [header.join(",")];
for (const row of rows) {
const cells = [
row.modelId,
row.voiceLabel,
row.speakerId === null ? "" : String(row.speakerId),
String(row.sampleRate),
row.durationSeconds.toFixed(3),
row.relativePath,
];
lines.push(
cells
.map((cell) => {
const escaped = cell.replace(/"/g, "\"\"");
return escaped.includes(",") ? `"${escaped}"` : escaped;
})
.join(",")
);
}
return `${lines.join("\n")}\n`;
}
const logger = createRootLogger({ level: "info", format: "pretty" });
const options = parseArgs(process.argv.slice(2));
logger.info(
{
modelsDir: options.modelsDir,
outputDir: options.outputDir,
modelIds: options.modelIds,
speed: options.speed,
text: options.text,
},
"Generating TTS matrix across all model/voice combinations"
);
await ensureLocalSpeechModels({
modelsDir: options.modelsDir,
modelIds: options.modelIds,
autoDownload: true,
logger,
});
await mkdir(options.outputDir, { recursive: true });
const generated: GeneratedSample[] = [];
for (const modelId of options.modelIds) {
const modelDir = getLocalSpeechModelDir(options.modelsDir, modelId);
const modelOutputDir = path.join(options.outputDir, sanitizeFilePart(modelId));
await mkdir(modelOutputDir, { recursive: true });
if (modelId === "pocket-tts-onnx-int8") {
const tts = await PocketTtsOnnxTTS.create(
{
modelDir,
precision: "int8",
targetChunkMs: 50,
},
logger
);
const result = await tts.synthesizeSpeech(options.text);
const pcm16 = await readStreamToBuffer(result.stream);
const sampleRate = parsePcmRateFromFormat(result.format, 24000) ?? 24000;
const voiceLabel = "default";
const filename = `${sanitizeFilePart(modelId)}__voice-${voiceLabel}.wav`;
const absPath = path.join(modelOutputDir, filename);
const relPath = path.relative(options.outputDir, absPath);
await writeFile(absPath, pcm16MonoToWavBuffer(pcm16, sampleRate));
generated.push({
modelId,
voiceLabel,
speakerId: null,
sampleRate,
durationSeconds: pcm16.length / 2 / sampleRate,
relativePath: relPath,
});
logger.info({ modelId, voiceLabel, path: relPath }, "Generated sample");
continue;
}
if (modelId !== "kokoro-en-v0_19" && modelId !== "kitten-nano-en-v0_1-fp16") {
throw new Error(`Unsupported local Sherpa TTS model for this script: ${modelId}`);
}
const { tts, speed } = createSherpaOfflineTts({
modelId,
modelDir,
speed: options.speed,
});
try {
const rawNumSpeakers = (tts as { numSpeakers?: unknown }).numSpeakers;
const numSpeakers =
typeof rawNumSpeakers === "number" && Number.isFinite(rawNumSpeakers) && rawNumSpeakers > 0
? Math.floor(rawNumSpeakers)
: 1;
const pad = Math.max(2, String(Math.max(0, numSpeakers - 1)).length);
const fallbackSampleRate =
typeof (tts as { sampleRate?: unknown }).sampleRate === "number"
? Math.max(1, Math.floor((tts as { sampleRate: number }).sampleRate))
: 24000;
logger.info({ modelId, numSpeakers }, "Discovered Sherpa speaker count");
for (let sid = 0; sid < numSpeakers; sid += 1) {
const audio = (tts as { generate: (config: { text: string; sid: number; speed: number }) => unknown }).generate({
text: options.text,
sid,
speed,
});
const samples = toFloatSamples(audio);
const sampleRate = toSampleRate(audio, fallbackSampleRate);
const pcm16 = float32ToPcm16le(samples);
const voiceLabel = `sid-${String(sid).padStart(pad, "0")}`;
const filename = `${sanitizeFilePart(modelId)}__voice-${voiceLabel}.wav`;
const absPath = path.join(modelOutputDir, filename);
const relPath = path.relative(options.outputDir, absPath);
await writeFile(absPath, pcm16MonoToWavBuffer(pcm16, sampleRate));
generated.push({
modelId,
voiceLabel,
speakerId: sid,
sampleRate,
durationSeconds: pcm16.length / 2 / sampleRate,
relativePath: relPath,
});
logger.info({ modelId, voiceLabel, path: relPath }, "Generated sample");
}
} finally {
(tts as { free?: () => void }).free?.();
}
}
const manifest = {
createdAt: new Date().toISOString(),
text: options.text,
speed: options.speed,
modelsDir: options.modelsDir,
outputDir: options.outputDir,
namingConvention: "<model-id>/<model-id>__voice-<voice-label>.wav",
notes: [
"For Sherpa kitten/kokoro models, voice-label uses sid-<speaker-id>.",
"For Pocket TTS, voice-label is default.",
],
generated,
};
await writeFile(
path.join(options.outputDir, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`
);
await writeFile(path.join(options.outputDir, "samples.csv"), toCsv(generated));
await writeFile(
path.join(options.outputDir, "README.txt"),
[
"Sherpa TTS sample matrix",
"",
`Sample text: ${options.text}`,
`Speed: ${options.speed}`,
"",
"Naming convention:",
" <model-id>/<model-id>__voice-<voice-label>.wav",
" voice-label = sid-<speaker-id> for Sherpa models",
" voice-label = default for Pocket TTS",
"",
`Total files: ${generated.length}`,
].join("\n")
);
logger.info(
{
outputDir: options.outputDir,
generatedCount: generated.length,
models: Array.from(new Set(generated.map((item) => item.modelId))),
},
"TTS matrix generation complete"
);