Add the Half-Life VOX/HEV TTS presets
This commit is contained in:
@@ -40,6 +40,13 @@ mobile draft at 390px.
|
|||||||
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
||||||
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||||
|
- [x] C63 Half-Life presets. The wrapper now implements `VESKSLOP_TTS_PRESET=vox|hev` (Keith
|
||||||
|
Bell profile: VOX pitch 3/rate 17/prominence 22/head 52/volume 70, HEV pitch 15/rate 24,
|
||||||
|
expression 59; other voices untouched; preset < profile < expression; the `vox` preset implies
|
||||||
|
VOX prosody unless `VESKSLOP_TTS_VOX` is set). The daemon carries `tts.preset` (vox/hev/null),
|
||||||
|
per-line overrides on `/api/audio/speak` and MCP `speak`, the `VESKSLOP_TTS_PRESET` export and
|
||||||
|
`{preset}` placeholder, and the TTS panel has a preset select that auto-enables VOX (and drops
|
||||||
|
the preset if VOX is turned off). Wrapper syntax checked; preferences round-trip verified.
|
||||||
- [x] C62 VOX prosody controls. The last open Veskslop-side item from the MicroslopSam design:
|
- [x] C62 VOX prosody controls. The last open Veskslop-side item from the MicroslopSam design:
|
||||||
`tts.vox` and `tts.voxClean` preferences (per-line overrides on `/api/audio/speak` and MCP
|
`tts.vox` and `tts.voxClean` preferences (per-line overrides on `/api/audio/speak` and MCP
|
||||||
`speak`), `VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` exports plus `{vox}` / `{vox_clean}`
|
`speak`), `VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` exports plus `{vox}` / `{vox_clean}`
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ from the TTS panel:
|
|||||||
announcement energy; **clean** (`tts.voxClean`) drops the staccato wrapper. VOX outranks word
|
announcement energy; **clean** (`tts.voxClean`) drops the staccato wrapper. VOX outranks word
|
||||||
mode and needs `python3` on the host. Both travel to the command provider as
|
mode and needs `python3` on the host. Both travel to the command provider as
|
||||||
`VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` (or the `{vox}` / `{vox_clean}` placeholders).
|
`VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` (or the `{vox}` / `{vox_clean}` placeholders).
|
||||||
|
A **preset** select (`tts.preset`: factory, VOX, HEV) shifts the Keith Bell speaker profile to
|
||||||
|
the Half-Life approximation; `vox` also implies VOX prosody unless VOX is explicitly off, and
|
||||||
|
overrides travel as `VESKSLOP_TTS_PRESET` / `{preset}`. Precedence is preset < profile <
|
||||||
|
expression, so the sliders still win over the preset.
|
||||||
- **Test lines** - Attention, Laboratory, Critical, Health, and Radiation, plus two custom lines
|
- **Test lines** - Attention, Laboratory, Critical, Health, and Radiation, plus two custom lines
|
||||||
kept in `localStorage`. Each button speaks the line with the current profile, so a change can be
|
kept in `localStorage`. Each button speaks the line with the current profile, so a change can be
|
||||||
auditioned without touching the real queue.
|
auditioned without touching the real queue.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
# VESKSLOP_TTS_ECHO mono echo amount 0-100 (default 0)
|
# VESKSLOP_TTS_ECHO mono echo amount 0-100 (default 0)
|
||||||
# VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk)
|
# VESKSLOP_TTS_WORDS 1 = synthesize each word, merge (Flextalk)
|
||||||
# VESKSLOP_TTS_WORD_GAP_MS silence between words in ms (0-500)
|
# VESKSLOP_TTS_WORD_GAP_MS silence between words in ms (0-500)
|
||||||
|
# VESKSLOP_TTS_PRESET vox | hev (Keith Bell profile preset; vox implies VOX)
|
||||||
# VESKSLOP_TTS_VOX 1 = VOX prosody, NetTTS-style (Flextalk)
|
# VESKSLOP_TTS_VOX 1 = VOX prosody, NetTTS-style (Flextalk)
|
||||||
# VESKSLOP_TTS_VOX_CLEAN 1 = VOX without the \!wH1/\!wH0 stutter
|
# VESKSLOP_TTS_VOX_CLEAN 1 = VOX without the \!wH1/\!wH0 stutter
|
||||||
#
|
#
|
||||||
@@ -52,6 +53,12 @@ WORDS=${VESKSLOP_TTS_WORDS:-0}
|
|||||||
WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120}
|
WORD_GAP=${VESKSLOP_TTS_WORD_GAP_MS:-120}
|
||||||
VOX=${VESKSLOP_TTS_VOX:-0}
|
VOX=${VESKSLOP_TTS_VOX:-0}
|
||||||
VOX_CLEAN=${VESKSLOP_TTS_VOX_CLEAN:-0}
|
VOX_CLEAN=${VESKSLOP_TTS_VOX_CLEAN:-0}
|
||||||
|
PRESET=${VESKSLOP_TTS_PRESET:-}
|
||||||
|
|
||||||
|
# The vox preset implies VOX prosody unless the caller set VESKSLOP_TTS_VOX explicitly.
|
||||||
|
if [ "$PRESET" = "vox" ] && [ -z "${VESKSLOP_TTS_VOX+x}" ]; then
|
||||||
|
VOX=1
|
||||||
|
fi
|
||||||
|
|
||||||
case "$VOICE" in
|
case "$VOICE" in
|
||||||
sam) ENGVOICE="Sam" ;;
|
sam) ENGVOICE="Sam" ;;
|
||||||
@@ -173,6 +180,15 @@ if [ -n "$SPEAKER_GUID" ]; then
|
|||||||
P_LOWER=40
|
P_LOWER=40
|
||||||
P_EXPR=""
|
P_EXPR=""
|
||||||
|
|
||||||
|
# Half-Life presets target Keith Bell only. Precedence: preset < VESKSLOP_TTS_PROFILE
|
||||||
|
# < VESKSLOP_TTS_EXPRESSION.
|
||||||
|
if [ "$ENGVOICE" = "Keith Bell" ]; then
|
||||||
|
case "$PRESET" in
|
||||||
|
vox) P_PITCH=3; P_RATE=17; P_PROM=22; P_HEAD=52; P_LOWER=40; P_VOLUME=70; P_EXPR=59 ;;
|
||||||
|
hev) P_PITCH=15; P_RATE=24; P_PROM=22; P_HEAD=52; P_LOWER=40; P_VOLUME=70; P_EXPR=59 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$PROFILE" ]; then
|
if [ -n "$PROFILE" ]; then
|
||||||
OLDIFS=$IFS
|
OLDIFS=$IFS
|
||||||
IFS=','
|
IFS=','
|
||||||
|
|||||||
+14
-1
@@ -2027,6 +2027,7 @@ function fillProfileControls() {
|
|||||||
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
|
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
|
||||||
|
|
||||||
$("#tts-words").checked = state.speech.words === true;
|
$("#tts-words").checked = state.speech.words === true;
|
||||||
|
$("#tts-preset").value = state.speech.preset ?? "";
|
||||||
$("#tts-vox").checked = state.speech.vox === true;
|
$("#tts-vox").checked = state.speech.vox === true;
|
||||||
$("#tts-vox-clean").checked = state.speech.voxClean === true;
|
$("#tts-vox-clean").checked = state.speech.voxClean === true;
|
||||||
$("#tts-vox-clean").disabled = state.speech.vox !== true;
|
$("#tts-vox-clean").disabled = state.speech.vox !== true;
|
||||||
@@ -2064,7 +2065,8 @@ function effectOptions() {
|
|||||||
words: $("#tts-words").checked,
|
words: $("#tts-words").checked,
|
||||||
wordGapMs: Number($("#tts-word-gap").value),
|
wordGapMs: Number($("#tts-word-gap").value),
|
||||||
vox: $("#tts-vox").checked,
|
vox: $("#tts-vox").checked,
|
||||||
voxClean: $("#tts-vox-clean").checked
|
voxClean: $("#tts-vox-clean").checked,
|
||||||
|
preset: $("#tts-preset").value || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function syncVoiceControls(voices, selected) {
|
function syncVoiceControls(voices, selected) {
|
||||||
@@ -2743,6 +2745,17 @@ function init() {
|
|||||||
$("#tts-vox").addEventListener("change", () => {
|
$("#tts-vox").addEventListener("change", () => {
|
||||||
$("#tts-vox-clean").disabled = !$("#tts-vox").checked;
|
$("#tts-vox-clean").disabled = !$("#tts-vox").checked;
|
||||||
$("#tts-words").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();
|
void saveSpeech();
|
||||||
});
|
});
|
||||||
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
|
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
|
||||||
|
|||||||
@@ -556,6 +556,15 @@
|
|||||||
<input id="tts-word-gap" type="range" min="0" max="500" step="10" value="120" />
|
<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>
|
<span id="tts-word-gap-value" class="vk-mono vk-dim">120ms</span>
|
||||||
</div>
|
</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">
|
<div class="vk-profile-row">
|
||||||
<label class="vk-tts-check">
|
<label class="vk-tts-check">
|
||||||
<input id="tts-vox" type="checkbox" /> VOX prosody
|
<input id="tts-vox" type="checkbox" /> VOX prosody
|
||||||
|
|||||||
@@ -563,7 +563,11 @@ function buildServer(): McpServer {
|
|||||||
words: z.boolean().optional().describe("Synthesize each word and merge with gaps"),
|
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"),
|
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)"),
|
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 ({
|
async ({
|
||||||
@@ -579,7 +583,8 @@ function buildServer(): McpServer {
|
|||||||
words,
|
words,
|
||||||
wordGapMs,
|
wordGapMs,
|
||||||
vox,
|
vox,
|
||||||
voxClean
|
voxClean,
|
||||||
|
preset
|
||||||
}) =>
|
}) =>
|
||||||
jsonText(
|
jsonText(
|
||||||
await audio.speak(text, {
|
await audio.speak(text, {
|
||||||
@@ -594,7 +599,8 @@ function buildServer(): McpServer {
|
|||||||
words,
|
words,
|
||||||
wordGapMs,
|
wordGapMs,
|
||||||
vox,
|
vox,
|
||||||
voxClean
|
voxClean,
|
||||||
|
preset
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
+11
-3
@@ -34,6 +34,8 @@ export interface TtsPreferences {
|
|||||||
vox: boolean;
|
vox: boolean;
|
||||||
/** VOX without the staccato wrapper. */
|
/** VOX without the staccato wrapper. */
|
||||||
voxClean: boolean;
|
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]> = {
|
const PROFILE_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -152,7 +154,8 @@ const DEFAULTS: WebPreferences = {
|
|||||||
words: false,
|
words: false,
|
||||||
wordGapMs: null,
|
wordGapMs: null,
|
||||||
vox: false,
|
vox: false,
|
||||||
voxClean: false
|
voxClean: false,
|
||||||
|
preset: null
|
||||||
},
|
},
|
||||||
audio: { duck: true },
|
audio: { duck: true },
|
||||||
favourites: { users: [], channels: [] },
|
favourites: { users: [], channels: [] },
|
||||||
@@ -348,7 +351,11 @@ export class PreferencesStore {
|
|||||||
words: patch.tts?.words === true,
|
words: patch.tts?.words === true,
|
||||||
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500),
|
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500),
|
||||||
vox: patch.tts?.vox === true,
|
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,
|
words: raw?.tts?.words === true,
|
||||||
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
|
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
|
||||||
vox: raw?.tts?.vox === true,
|
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: {
|
audio: {
|
||||||
duck: raw?.audio?.duck !== false
|
duck: raw?.audio?.duck !== false
|
||||||
|
|||||||
+5
-1
@@ -38,6 +38,8 @@ export interface TtsOptions {
|
|||||||
vox?: boolean;
|
vox?: boolean;
|
||||||
/** VOX without the staccato wrapper. */
|
/** VOX without the staccato wrapper. */
|
||||||
voxClean?: boolean;
|
voxClean?: boolean;
|
||||||
|
/** Half-Life profile preset for Keith Bell: vox or hev. */
|
||||||
|
preset?: "vox" | "hev";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TtsEngine {
|
export interface TtsEngine {
|
||||||
@@ -354,6 +356,7 @@ export class Tts {
|
|||||||
.replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "")
|
.replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "")
|
||||||
.replace("{vox}", options.vox ? "1" : "")
|
.replace("{vox}", options.vox ? "1" : "")
|
||||||
.replace("{vox_clean}", options.voxClean ? "1" : "")
|
.replace("{vox_clean}", options.voxClean ? "1" : "")
|
||||||
|
.replace("{preset}", options.preset ?? "")
|
||||||
)
|
)
|
||||||
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
|
: ["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) }
|
? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) }
|
||||||
: {}),
|
: {}),
|
||||||
...(options.vox ? { VESKSLOP_TTS_VOX: "1" } : {}),
|
...(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"]
|
stdio: ["ignore", "ignore", "pipe"]
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-2
@@ -510,7 +510,8 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
words: tts?.words === true,
|
words: tts?.words === true,
|
||||||
wordGapMs: tts?.wordGapMs ?? null,
|
wordGapMs: tts?.wordGapMs ?? null,
|
||||||
vox: tts?.vox === true,
|
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;
|
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 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 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, {
|
const result = await deps.audio.speak(text, {
|
||||||
provider: provider ?? undefined,
|
provider: provider ?? undefined,
|
||||||
@@ -773,7 +780,8 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
...(words ? { words: true } : {}),
|
...(words ? { words: true } : {}),
|
||||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
||||||
...(vox ? { vox: true } : {}),
|
...(vox ? { vox: true } : {}),
|
||||||
...(voxClean ? { voxClean: true } : {})
|
...(voxClean ? { voxClean: true } : {}),
|
||||||
|
...(preset ? { preset } : {})
|
||||||
});
|
});
|
||||||
deps.getPuppet()?.appendAction({
|
deps.getPuppet()?.appendAction({
|
||||||
type: "audio",
|
type: "audio",
|
||||||
|
|||||||
Reference in New Issue
Block a user