Add the Half-Life VOX/HEV TTS presets

This commit is contained in:
Fisto
2026-09-21 11:20:24 +02:00
parent 293d131d2b
commit ac23ee712a
9 changed files with 85 additions and 10 deletions
+14 -1
View File
@@ -2027,6 +2027,7 @@ function fillProfileControls() {
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-words").checked = state.speech.words === true;
$("#tts-preset").value = state.speech.preset ?? "";
$("#tts-vox").checked = state.speech.vox === true;
$("#tts-vox-clean").checked = state.speech.voxClean === true;
$("#tts-vox-clean").disabled = state.speech.vox !== true;
@@ -2064,7 +2065,8 @@ function effectOptions() {
words: $("#tts-words").checked,
wordGapMs: Number($("#tts-word-gap").value),
vox: $("#tts-vox").checked,
voxClean: $("#tts-vox-clean").checked
voxClean: $("#tts-vox-clean").checked,
preset: $("#tts-preset").value || null
};
}
function syncVoiceControls(voices, selected) {
@@ -2743,6 +2745,17 @@ function init() {
$("#tts-vox").addEventListener("change", () => {
$("#tts-vox-clean").disabled = !$("#tts-vox").checked;
$("#tts-words").disabled = $("#tts-vox").checked;
// The vox preset implies VOX; turning VOX off means "factory/custom" again.
if (!$("#tts-vox").checked && $("#tts-preset").value === "vox") $("#tts-preset").value = "";
void saveSpeech();
});
$("#tts-preset").addEventListener("change", () => {
// Picking the VOX preset turns on VOX prosody so the panel matches the engine.
if ($("#tts-preset").value === "vox") {
$("#tts-vox").checked = true;
$("#tts-vox-clean").disabled = false;
$("#tts-words").disabled = true;
}
void saveSpeech();
});
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
+9
View File
@@ -556,6 +556,15 @@
<input id="tts-word-gap" type="range" min="0" max="500" step="10" value="120" />
<span id="tts-word-gap-value" class="vk-mono vk-dim">120ms</span>
</div>
<div class="vk-profile-row">
<span class="vk-micro-label">preset</span>
<select id="tts-preset" class="input">
<option value="">factory</option>
<option value="vox">VOX (Black Mesa)</option>
<option value="hev">HEV suit</option>
</select>
<span class="vk-mono vk-dim">Keith Bell</span>
</div>
<div class="vk-profile-row">
<label class="vk-tts-check">
<input id="tts-vox" type="checkbox" /> VOX prosody
+9 -3
View File
@@ -563,7 +563,11 @@ function buildServer(): McpServer {
words: z.boolean().optional().describe("Synthesize each word and merge with gaps"),
wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms"),
vox: z.boolean().optional().describe("NetTTS-style VOX prosody (Flextalk voices)"),
voxClean: z.boolean().optional().describe("VOX without the staccato wrapper")
voxClean: z.boolean().optional().describe("VOX without the staccato wrapper"),
preset: z
.enum(["vox", "hev"])
.optional()
.describe("Half-Life profile preset for the Keith Bell voice")
}
},
async ({
@@ -579,7 +583,8 @@ function buildServer(): McpServer {
words,
wordGapMs,
vox,
voxClean
voxClean,
preset
}) =>
jsonText(
await audio.speak(text, {
@@ -594,7 +599,8 @@ function buildServer(): McpServer {
words,
wordGapMs,
vox,
voxClean
voxClean,
preset
})
)
);
+11 -3
View File
@@ -34,6 +34,8 @@ export interface TtsPreferences {
vox: boolean;
/** VOX without the staccato wrapper. */
voxClean: boolean;
/** Half-Life profile preset for Keith Bell: vox, hev, or null for factory values. */
preset: "vox" | "hev" | null;
}
const PROFILE_RANGES: Record<string, [number, number]> = {
@@ -152,7 +154,8 @@ const DEFAULTS: WebPreferences = {
words: false,
wordGapMs: null,
vox: false,
voxClean: false
voxClean: false,
preset: null
},
audio: { duck: true },
favourites: { users: [], channels: [] },
@@ -348,7 +351,11 @@ export class PreferencesStore {
words: patch.tts?.words === true,
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500),
vox: patch.tts?.vox === true,
voxClean: patch.tts?.voxClean === true
voxClean: patch.tts?.voxClean === true,
preset:
patch.tts?.preset === "vox" || patch.tts?.preset === "hev"
? patch.tts.preset
: null
};
}
@@ -415,7 +422,8 @@ export class PreferencesStore {
words: raw?.tts?.words === true,
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
vox: raw?.tts?.vox === true,
voxClean: raw?.tts?.voxClean === true
voxClean: raw?.tts?.voxClean === true,
preset: raw?.tts?.preset === "vox" || raw?.tts?.preset === "hev" ? raw.tts.preset : null
},
audio: {
duck: raw?.audio?.duck !== false
+5 -1
View File
@@ -38,6 +38,8 @@ export interface TtsOptions {
vox?: boolean;
/** VOX without the staccato wrapper. */
voxClean?: boolean;
/** Half-Life profile preset for Keith Bell: vox or hev. */
preset?: "vox" | "hev";
}
export interface TtsEngine {
@@ -354,6 +356,7 @@ export class Tts {
.replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "")
.replace("{vox}", options.vox ? "1" : "")
.replace("{vox_clean}", options.voxClean ? "1" : "")
.replace("{preset}", options.preset ?? "")
)
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
@@ -379,7 +382,8 @@ export class Tts {
? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) }
: {}),
...(options.vox ? { VESKSLOP_TTS_VOX: "1" } : {}),
...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {})
...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {}),
...(options.preset ? { VESKSLOP_TTS_PRESET: options.preset } : {})
},
stdio: ["ignore", "ignore", "pipe"]
});
+10 -2
View File
@@ -510,7 +510,8 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
words: tts?.words === true,
wordGapMs: tts?.wordGapMs ?? null,
vox: tts?.vox === true,
voxClean: tts?.voxClean === true
voxClean: tts?.voxClean === true,
preset: tts?.preset === "vox" || tts?.preset === "hev" ? tts.preset : null
};
}
@@ -760,6 +761,12 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
req.body?.wordGapMs !== undefined ? Number(req.body.wordGapMs) : prefs.tts.wordGapMs ?? undefined;
const vox = req.body?.vox !== undefined ? req.body.vox === true : prefs.tts.vox;
const voxClean = req.body?.voxClean !== undefined ? req.body.voxClean === true : prefs.tts.voxClean;
const preset =
req.body?.preset === "vox" || req.body?.preset === "hev"
? req.body.preset
: req.body?.preset === null || req.body?.preset === ""
? null
: prefs.tts.preset;
const result = await deps.audio.speak(text, {
provider: provider ?? undefined,
@@ -773,7 +780,8 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
...(words ? { words: true } : {}),
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
...(vox ? { vox: true } : {}),
...(voxClean ? { voxClean: true } : {})
...(voxClean ? { voxClean: true } : {}),
...(preset ? { preset } : {})
});
deps.getPuppet()?.appendAction({
type: "audio",