Add crunch presets, echo options, and streaming word mode

This commit is contained in:
Fisto
2026-09-21 12:31:43 +02:00
parent bbb580deea
commit c66d00ee59
11 changed files with 371 additions and 17 deletions
+23 -1
View File
@@ -2025,6 +2025,12 @@ function fillProfileControls() {
const echo = Number(state.speech.echo);
$("#tts-echo").value = Number.isFinite(echo) && state.speech.echo !== null ? echo : 0;
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-echo-delay").value = state.speech.echoDelayMs ?? "";
$("#tts-echo-level").value = state.speech.echoLevel ?? "";
$("#tts-crunch").value = state.speech.crunch ?? "";
$("#tts-crunch-bits").value = state.speech.crunchBits ?? "";
$("#tts-crunch-rate").value = state.speech.crunchRate ?? "";
$("#tts-stream-words").checked = state.speech.streamWords !== false;
$("#tts-words").checked = state.speech.words === true;
$("#tts-preset").value = state.speech.preset ?? "";
@@ -2065,7 +2071,13 @@ function effectOptions() {
wordGapMs: Number($("#tts-word-gap").value),
vox: $("#tts-vox").checked,
voxClean: $("#tts-vox-clean").checked,
preset: $("#tts-preset").value || null
preset: $("#tts-preset").value || null,
echoDelayMs: $("#tts-echo-delay").value ? Number($("#tts-echo-delay").value) : null,
echoLevel: $("#tts-echo-level").value ? Number($("#tts-echo-level").value) : null,
crunch: $("#tts-crunch").value || null,
crunchBits: $("#tts-crunch-bits").value ? Number($("#tts-crunch-bits").value) : null,
crunchRate: $("#tts-crunch-rate").value ? Number($("#tts-crunch-rate").value) : null,
streamWords: $("#tts-stream-words").checked
};
}
function syncVoiceControls(voices, selected) {
@@ -2755,6 +2767,16 @@ function init() {
void saveSpeech();
});
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
for (const id of [
"#tts-echo-delay",
"#tts-echo-level",
"#tts-crunch",
"#tts-crunch-bits",
"#tts-crunch-rate",
"#tts-stream-words"
]) {
$(id).addEventListener("change", () => void saveSpeech());
}
$("#profile-open").addEventListener("click", () => {
const panel = document.querySelector(".vk-profile-panel");
if (panel) {
+18
View File
@@ -550,6 +550,21 @@
<span class="vk-micro-label">echo</span>
<input id="tts-echo" type="range" min="0" max="100" step="5" value="0" />
<span id="tts-echo-value" class="vk-mono vk-dim">off</span>
<input id="tts-echo-delay" class="input vk-num" type="number" min="10" max="400" placeholder="ms" />
<input id="tts-echo-level" class="input vk-num" type="number" min="0" max="0.95" step="0.05" placeholder="lvl" />
</div>
<div class="vk-profile-row">
<span class="vk-micro-label">crunch</span>
<select id="tts-crunch" class="input">
<option value="">off</option>
<option value="hl90">HL90 · 8-bit 11 kHz</option>
<option value="hl90hq">HL90 HQ · 8-bit 22 kHz</option>
<option value="telephone">telephone · 8 kHz</option>
<option value="adpcm">ADPCM · 4-bit</option>
<option value="crush">crush · custom</option>
</select>
<input id="tts-crunch-bits" class="input vk-num" type="number" min="1" max="16" placeholder="bits" />
<input id="tts-crunch-rate" class="input vk-num" type="number" min="4000" max="48000" step="250" placeholder="Hz" />
</div>
<div class="vk-profile-row">
<label class="vk-tts-check"><input id="tts-words" type="checkbox" /> word mode</label>
@@ -577,6 +592,9 @@
<label class="vk-tts-check">
<input id="tts-vox-clean" type="checkbox" /> clean (no stutter)
</label>
<label class="vk-tts-check" title="Write the WAV as words are synthesized so playback starts on the first word. Skips the loudness measurement; set a fixed gain below if needed.">
<input id="tts-stream-words" type="checkbox" /> stream word mode
</label>
</div>
</section>
<section class="vk-profile-section">
+35 -3
View File
@@ -6,6 +6,7 @@
* them in the transcript, and runs speech to text when a provider is set.
*/
import { statSync } from "node:fs";
import { AudioManager } from "./audio.js";
import type { CaptureSegment } from "./capture.js";
import { CapturePipeline } from "./capture.js";
@@ -146,6 +147,19 @@ export class AudioService {
return { queued: true, id, position: this.queue.length };
}
/** Wait for the wrapper's streaming file to appear, then play it while it grows. */
private async playWhenReady(file: string): Promise<void> {
for (let attempt = 0; attempt < 400; attempt++) {
try {
if (statSync(file).size > 44) break;
} catch {
// not created yet
}
await new Promise(resolve => setTimeout(resolve, 25));
}
await this.manager.play(file);
}
stop(): void {
this.queue.length = 0;
this.manager.stopPlayback();
@@ -350,9 +364,27 @@ export class AudioService {
this.playing = true;
try {
const file = await this.tts.synthesize(item.text, item.options);
await this.applyDuck(this.shouldDuck());
await this.manager.play(file);
// Streaming word mode: the wrapper writes the WAV while it synthesizes, so
// playback starts on the first word instead of after the whole line.
let streaming: Promise<void> | null = null;
const options: TtsOptions = { ...item.options };
if (item.options.stream === true) {
options.onOutputPath = path => {
streaming = (async () => {
await this.applyDuck(this.shouldDuck());
await this.playWhenReady(path);
})();
};
}
const file = await this.tts.synthesize(item.text, options);
const pending: Promise<void> | null = streaming;
if (pending) {
await pending;
} else {
await this.applyDuck(this.shouldDuck());
await this.manager.play(file);
}
} catch (error) {
console.error("[audio] speak failed:", (error as Error).message);
} finally {
+21 -3
View File
@@ -567,7 +567,17 @@ function buildServer(): McpServer {
preset: z
.enum(["vox", "hev"])
.optional()
.describe("Half-Life profile preset for the Keith Bell voice")
.describe("Half-Life profile preset for the Keith Bell voice"),
crunch: z
.enum(["hl90", "hl90hq", "telephone", "adpcm", "crush"])
.optional()
.describe("Lossy 1990s compression stage, applied last"),
crunchBits: z.number().min(1).max(16).optional().describe("Crunch bit depth"),
crunchRate: z.number().min(4000).max(48000).optional().describe("Crunch sample rate"),
streamWords: z
.boolean()
.optional()
.describe("Word mode: stream audio so playback starts on the first word")
}
},
async ({
@@ -584,7 +594,11 @@ function buildServer(): McpServer {
wordGapMs,
vox,
voxClean,
preset
preset,
crunch,
crunchBits,
crunchRate,
streamWords
}) =>
jsonText(
await audio.speak(text, {
@@ -600,7 +614,11 @@ function buildServer(): McpServer {
wordGapMs,
vox,
voxClean,
preset
preset,
crunch,
crunchBits,
crunchRate,
stream: streamWords === true && words === true
})
)
);
+43 -3
View File
@@ -36,6 +36,15 @@ export interface TtsPreferences {
voxClean: boolean;
/** Half-Life profile preset for Keith Bell: vox, hev, or null for factory values. */
preset: "vox" | "hev" | null;
/** Echo fine-tuning; null keeps the wrapper defaults (60 ms, 0.85). */
echoDelayMs: number | null;
echoLevel: number | null;
/** Lossy 1990s compression stage: preset, bits, rate. Null/off disables it. */
crunch: "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush" | null;
crunchBits: number | null;
crunchRate: number | null;
/** Stream word mode so playback starts on the first word. */
streamWords: boolean;
}
const PROFILE_RANGES: Record<string, [number, number]> = {
@@ -155,7 +164,13 @@ const DEFAULTS: WebPreferences = {
wordGapMs: null,
vox: false,
voxClean: false,
preset: null
preset: null,
echoDelayMs: null,
echoLevel: null,
crunch: null,
crunchBits: null,
crunchRate: null,
streamWords: true
},
audio: { duck: true },
favourites: { users: [], channels: [] },
@@ -298,6 +313,19 @@ function normalizeExpression(value: unknown): number | null {
return Math.round(expression);
}
function normalizeLevel(value: unknown): number | null {
if (isUnset(value)) return null;
const number = Number(value);
if (!Number.isFinite(number) || number < 0 || number > 0.95) return null;
return Math.round(number * 100) / 100;
}
function normalizeCrunch(value: unknown): TtsPreferences["crunch"] {
return value === "hl90" || value === "hl90hq" || value === "telephone" || value === "adpcm" || value === "crush"
? value
: null;
}
function normalizeRange(value: unknown, min: number, max: number): number | null {
if (isUnset(value)) return null;
const number = Number(value);
@@ -355,7 +383,13 @@ export class PreferencesStore {
preset:
patch.tts?.preset === "vox" || patch.tts?.preset === "hev"
? patch.tts.preset
: null
: null,
echoDelayMs: normalizeRange(patch.tts?.echoDelayMs, 10, 400),
echoLevel: normalizeLevel(patch.tts?.echoLevel),
crunch: normalizeCrunch(patch.tts?.crunch),
crunchBits: normalizeRange(patch.tts?.crunchBits, 1, 16),
crunchRate: normalizeRange(patch.tts?.crunchRate, 4000, 48000),
streamWords: patch.tts?.streamWords !== false
};
}
@@ -423,7 +457,13 @@ export class PreferencesStore {
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
vox: raw?.tts?.vox === true,
voxClean: raw?.tts?.voxClean === true,
preset: raw?.tts?.preset === "vox" || raw?.tts?.preset === "hev" ? raw.tts.preset : null
preset: raw?.tts?.preset === "vox" || raw?.tts?.preset === "hev" ? raw.tts.preset : null,
echoDelayMs: normalizeRange(raw?.tts?.echoDelayMs, 10, 400),
echoLevel: normalizeLevel(raw?.tts?.echoLevel),
crunch: normalizeCrunch(raw?.tts?.crunch),
crunchBits: normalizeRange(raw?.tts?.crunchBits, 1, 16),
crunchRate: normalizeRange(raw?.tts?.crunchRate, 4000, 48000),
streamWords: raw?.tts?.streamWords !== false
},
audio: {
duck: raw?.audio?.duck !== false
+35 -1
View File
@@ -40,6 +40,22 @@ export interface TtsOptions {
voxClean?: boolean;
/** Half-Life profile preset for Keith Bell: vox or hev. */
preset?: "vox" | "hev";
/** Echo delay in ms, 10-400. */
echoDelayMs?: number;
/** Echo output gain, 0-0.95. */
echoLevel?: number;
/** Lossy 1990s compression preset, applied as its own stage last. */
crunch?: "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush";
/** Bit depth for the crunch stage, 1-16 (overrides the preset). */
crunchBits?: number;
/** Sample rate for the crunch stage, 4000-48000 (overrides the preset). */
crunchRate?: number;
/** Stream word mode: the wrapper writes the WAV while it synthesizes. */
stream?: boolean;
/** Fixed gain for streaming lines (the measurement pass is skipped). */
streamGainDb?: number;
/** Called with the output path as soon as it is chosen, before synthesis. */
onOutputPath?: (path: string) => void;
}
export interface TtsEngine {
@@ -336,6 +352,7 @@ export class Tts {
const out = this.nextPath("wav");
const textFile = this.nextPath("txt");
writeFileSync(textFile, text);
options.onOutputPath?.(out);
const parts = command
? command
@@ -357,6 +374,12 @@ export class Tts {
.replace("{vox}", options.vox ? "1" : "")
.replace("{vox_clean}", options.voxClean ? "1" : "")
.replace("{preset}", options.preset ?? "")
.replace("{echo_delay_ms}", options.echoDelayMs !== undefined ? String(options.echoDelayMs) : "")
.replace("{echo_level}", options.echoLevel !== undefined ? String(options.echoLevel) : "")
.replace("{crunch}", options.crunch ?? "")
.replace("{crunch_bits}", options.crunchBits !== undefined ? String(options.crunchBits) : "")
.replace("{crunch_rate}", options.crunchRate !== undefined ? String(options.crunchRate) : "")
.replace("{stream}", options.stream ? "1" : "")
)
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
@@ -385,7 +408,18 @@ export class Tts {
// preset implication (which only fires when the variable is unset).
VESKSLOP_TTS_VOX: options.vox ? "1" : "0",
...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {}),
...(options.preset ? { VESKSLOP_TTS_PRESET: options.preset } : {})
...(options.preset ? { VESKSLOP_TTS_PRESET: options.preset } : {}),
...(options.echoDelayMs !== undefined
? { VESKSLOP_TTS_ECHO_DELAY_MS: String(options.echoDelayMs) }
: {}),
...(options.echoLevel !== undefined ? { VESKSLOP_TTS_ECHO_LEVEL: String(options.echoLevel) } : {}),
...(options.crunch ? { VESKSLOP_TTS_CRUNCH: options.crunch } : {}),
...(options.crunchBits !== undefined ? { VESKSLOP_TTS_CRUNCH_BITS: String(options.crunchBits) } : {}),
...(options.crunchRate !== undefined ? { VESKSLOP_TTS_CRUNCH_RATE: String(options.crunchRate) } : {}),
...(options.stream ? { VESKSLOP_TTS_STREAM: "1" } : {}),
...(options.streamGainDb !== undefined
? { VESKSLOP_TTS_STREAM_GAIN_DB: String(options.streamGainDb) }
: {})
},
stdio: ["ignore", "ignore", "pipe"]
});
+30 -2
View File
@@ -511,7 +511,13 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
wordGapMs: tts?.wordGapMs ?? null,
vox: tts?.vox === true,
voxClean: tts?.voxClean === true,
preset: tts?.preset === "vox" || tts?.preset === "hev" ? tts.preset : null
preset: tts?.preset === "vox" || tts?.preset === "hev" ? tts.preset : null,
echoDelayMs: tts?.echoDelayMs ?? null,
echoLevel: tts?.echoLevel ?? null,
crunch: tts?.crunch ?? null,
crunchBits: tts?.crunchBits ?? null,
crunchRate: tts?.crunchRate ?? null,
streamWords: tts?.streamWords !== false
};
}
@@ -767,6 +773,22 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
: req.body?.preset === null || req.body?.preset === ""
? null
: prefs.tts.preset;
const echoDelayMs =
req.body?.echoDelayMs !== undefined ? Number(req.body.echoDelayMs) : prefs.tts.echoDelayMs ?? undefined;
const echoLevel =
req.body?.echoLevel !== undefined ? Number(req.body.echoLevel) : prefs.tts.echoLevel ?? undefined;
const crunch =
["hl90", "hl90hq", "telephone", "adpcm", "crush"].includes(String(req.body?.crunch ?? ""))
? (String(req.body.crunch) as "hl90" | "hl90hq" | "telephone" | "adpcm" | "crush")
: req.body?.crunch === null || req.body?.crunch === "" || req.body?.crunch === "off"
? undefined
: prefs.tts.crunch ?? undefined;
const crunchBits =
req.body?.crunchBits !== undefined ? Number(req.body.crunchBits) : prefs.tts.crunchBits ?? undefined;
const crunchRate =
req.body?.crunchRate !== undefined ? Number(req.body.crunchRate) : prefs.tts.crunchRate ?? undefined;
const streamWords =
req.body?.streamWords !== undefined ? req.body.streamWords !== false : prefs.tts.streamWords;
const result = await deps.audio.speak(text, {
provider: provider ?? undefined,
@@ -781,7 +803,13 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
...(vox ? { vox: true } : {}),
...(voxClean ? { voxClean: true } : {}),
...(preset ? { preset } : {})
...(preset ? { preset } : {}),
...(Number.isFinite(echoDelayMs) ? { echoDelayMs } : {}),
...(Number.isFinite(echoLevel) ? { echoLevel } : {}),
...(crunch ? { crunch } : {}),
...(Number.isFinite(crunchBits) ? { crunchBits } : {}),
...(Number.isFinite(crunchRate) ? { crunchRate } : {}),
stream: streamWords && words === true
});
deps.getPuppet()?.appendAction({
type: "audio",