Add the VOX prosody preference and panel controls
This commit is contained in:
@@ -40,6 +40,14 @@ mobile draft at 390px.
|
||||
- [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`:
|
||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||
- [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
|
||||
`speak`), `VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` exports plus `{vox}` / `{vox_clean}`
|
||||
placeholders for the command provider, and a Radio-kit row in the TTS panel with the checkboxes
|
||||
and a help tooltip. VOX disables word mode in the panel; the wrapper keeps its documented
|
||||
precedence. No other MicroslopSam work is outstanding: the engine branch is already an ancestor
|
||||
of main, and the remaining contract note (HTTP `/voices` discovery) is not needed for the
|
||||
command-provider path in use.
|
||||
- [x] C61 Media browsing performance. The grid's merge step rescanned every scrape database per
|
||||
request (about 300 ms for a page, 250 ms for stats, both refetched on every scroll append).
|
||||
`MediaService` now keeps a candidate cache keyed by a scrape revision plus the normalized
|
||||
|
||||
@@ -58,12 +58,16 @@ Ordered backlog. `CURRENT.md` tracks the live session state.
|
||||
10. Mobile web UI: first draft at 390px with the four tabs (Users, Chat, Call, Activity) is
|
||||
implemented and rendered headless. Next: move the transcript into the Call tab, put the TTS
|
||||
box in the Call tab as in 1c, and verify with real data on a phone-width viewport.
|
||||
11. TTS VOX preference: a `tts.vox` toggle that exports `VESKSLOP_TTS_VOX` (like `words`), an
|
||||
optional `VOX_CLEAN`, and a help line in the TTS panel. Requested in `TTS-DESIGN.md`; the
|
||||
wrapper side is ready and verified. Inline `/rate` and `/pitch` need no UI.
|
||||
|
||||
## Media archive follow-ups
|
||||
|
||||
## TTS follow-ups
|
||||
|
||||
1. **HTTP provider voice discovery.** `TTS-DESIGN.md` specifies `GET /voices` (id, name, language,
|
||||
gender, speed/pitch ranges) for the HTTP provider; Veskslop still lists voices from espeak or
|
||||
`VESKSLOP_TTS_VOICES` only. The command-provider path in use does not need it; implement when an
|
||||
HTTP engine with multiple voices is deployed.
|
||||
|
||||
## Web UI follow-ups
|
||||
|
||||
Ordered after the voice backend unless the owner asks first.
|
||||
|
||||
+9
-4
@@ -156,15 +156,20 @@ from the TTS panel:
|
||||
profile.
|
||||
- **Radio kit** - high-pass (`tts.highpass`, 40-400 Hz, default 40), mono echo (`tts.echo`, 0-100,
|
||||
default 0), and word mode (`tts.words`) with a gap in ms (`tts.wordGapMs`) for the radio/robot
|
||||
character.
|
||||
character. **VOX prosody** (`tts.vox`) routes the line through the NetTTS-style encoder first -
|
||||
sentence breaks, lead-ins, weighted pauses, and Flextalk vendor tags - for Black Mesa
|
||||
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
|
||||
`VESKSLOP_TTS_VOX` / `VESKSLOP_TTS_VOX_CLEAN` (or the `{vox}` / `{vox_clean}` placeholders).
|
||||
- **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
|
||||
auditioned without touching the real queue.
|
||||
|
||||
The window is movable and recenters every time it opens, so it cannot be lost off screen. Changed
|
||||
profile keys travel as `profile` (with `highpass`, `echo`, `words`, and `wordGapMs`) to the command
|
||||
provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`, `VESKSLOP_TTS_ECHO`,
|
||||
`VESKSLOP_TTS_WORDS`, and `VESKSLOP_TTS_WORD_GAP_MS`. The wrapper contract and the registry
|
||||
profile keys travel as `profile` (with `highpass`, `echo`, `words`, `wordGapMs`, `vox`, and
|
||||
`voxClean`) to the command provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`,
|
||||
`VESKSLOP_TTS_ECHO`, `VESKSLOP_TTS_WORDS`, `VESKSLOP_TTS_WORD_GAP_MS`, `VESKSLOP_TTS_VOX`, and
|
||||
`VESKSLOP_TTS_VOX_CLEAN`. The wrapper contract and the registry
|
||||
pitfalls (`wine reg import`; never `echo` for the `.reg` key path) are in
|
||||
[TTS-DESIGN.md](../TTS-DESIGN.md).
|
||||
|
||||
|
||||
+13
-1
@@ -2027,6 +2027,10 @@ function fillProfileControls() {
|
||||
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
|
||||
|
||||
$("#tts-words").checked = state.speech.words === true;
|
||||
$("#tts-vox").checked = state.speech.vox === true;
|
||||
$("#tts-vox-clean").checked = state.speech.voxClean === true;
|
||||
$("#tts-vox-clean").disabled = state.speech.vox !== true;
|
||||
$("#tts-words").disabled = state.speech.vox === true;
|
||||
const gap = Number(state.speech.wordGapMs);
|
||||
$("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120;
|
||||
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
|
||||
@@ -2058,7 +2062,9 @@ function effectOptions() {
|
||||
highpass: Number($("#tts-highpass").value),
|
||||
echo: Number($("#tts-echo").value),
|
||||
words: $("#tts-words").checked,
|
||||
wordGapMs: Number($("#tts-word-gap").value)
|
||||
wordGapMs: Number($("#tts-word-gap").value),
|
||||
vox: $("#tts-vox").checked,
|
||||
voxClean: $("#tts-vox-clean").checked
|
||||
};
|
||||
}
|
||||
function syncVoiceControls(voices, selected) {
|
||||
@@ -2734,6 +2740,12 @@ function init() {
|
||||
$(`#${id}`).addEventListener("change", () => void saveSpeech());
|
||||
}
|
||||
$("#tts-words").addEventListener("change", () => void saveSpeech());
|
||||
$("#tts-vox").addEventListener("change", () => {
|
||||
$("#tts-vox-clean").disabled = !$("#tts-vox").checked;
|
||||
$("#tts-words").disabled = $("#tts-vox").checked;
|
||||
void saveSpeech();
|
||||
});
|
||||
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
|
||||
$("#profile-open").addEventListener("click", () => {
|
||||
const panel = document.querySelector(".vk-profile-panel");
|
||||
if (panel) {
|
||||
|
||||
@@ -556,6 +556,19 @@
|
||||
<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">
|
||||
<label class="vk-tts-check">
|
||||
<input id="tts-vox" type="checkbox" /> VOX prosody
|
||||
<span
|
||||
class="vk-info"
|
||||
title="NetTTS-style Black Mesa delivery for the Flextalk voices: sentence breaks, lead-ins, weighted pauses, and vendor tags. Needs python3. Overrides word mode."
|
||||
>i</span
|
||||
>
|
||||
</label>
|
||||
<label class="vk-tts-check">
|
||||
<input id="tts-vox-clean" type="checkbox" /> clean (no stutter)
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="vk-profile-section">
|
||||
<div class="vk-section-head">
|
||||
|
||||
@@ -561,10 +561,26 @@ function buildServer(): McpServer {
|
||||
highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"),
|
||||
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
|
||||
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)"),
|
||||
voxClean: z.boolean().optional().describe("VOX without the staccato wrapper")
|
||||
}
|
||||
},
|
||||
async ({ text, provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) =>
|
||||
async ({
|
||||
text,
|
||||
provider,
|
||||
voice,
|
||||
speed,
|
||||
pitch,
|
||||
expression,
|
||||
profile,
|
||||
highpass,
|
||||
echo,
|
||||
words,
|
||||
wordGapMs,
|
||||
vox,
|
||||
voxClean
|
||||
}) =>
|
||||
jsonText(
|
||||
await audio.speak(text, {
|
||||
provider,
|
||||
@@ -576,7 +592,9 @@ function buildServer(): McpServer {
|
||||
highpass,
|
||||
echo,
|
||||
words,
|
||||
wordGapMs
|
||||
wordGapMs,
|
||||
vox,
|
||||
voxClean
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
+13
-3
@@ -30,6 +30,10 @@ export interface TtsPreferences {
|
||||
words: boolean;
|
||||
/** Silence between words in milliseconds, 0-500. */
|
||||
wordGapMs: number | null;
|
||||
/** NetTTS-style VOX prosody (Flextalk voices only). */
|
||||
vox: boolean;
|
||||
/** VOX without the staccato wrapper. */
|
||||
voxClean: boolean;
|
||||
}
|
||||
|
||||
const PROFILE_RANGES: Record<string, [number, number]> = {
|
||||
@@ -146,7 +150,9 @@ const DEFAULTS: WebPreferences = {
|
||||
highpass: null,
|
||||
echo: null,
|
||||
words: false,
|
||||
wordGapMs: null
|
||||
wordGapMs: null,
|
||||
vox: false,
|
||||
voxClean: false
|
||||
},
|
||||
audio: { duck: true },
|
||||
favourites: { users: [], channels: [] },
|
||||
@@ -340,7 +346,9 @@ export class PreferencesStore {
|
||||
highpass: normalizeRange(patch.tts?.highpass, 40, 400),
|
||||
echo: normalizeRange(patch.tts?.echo, 0, 100),
|
||||
words: patch.tts?.words === true,
|
||||
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500)
|
||||
wordGapMs: normalizeRange(patch.tts?.wordGapMs, 0, 500),
|
||||
vox: patch.tts?.vox === true,
|
||||
voxClean: patch.tts?.voxClean === true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,7 +413,9 @@ export class PreferencesStore {
|
||||
highpass: normalizeRange(raw?.tts?.highpass, 40, 400),
|
||||
echo: normalizeRange(raw?.tts?.echo, 0, 100),
|
||||
words: raw?.tts?.words === true,
|
||||
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500)
|
||||
wordGapMs: normalizeRange(raw?.tts?.wordGapMs, 0, 500),
|
||||
vox: raw?.tts?.vox === true,
|
||||
voxClean: raw?.tts?.voxClean === true
|
||||
},
|
||||
audio: {
|
||||
duck: raw?.audio?.duck !== false
|
||||
|
||||
+9
-1
@@ -34,6 +34,10 @@ export interface TtsOptions {
|
||||
words?: boolean;
|
||||
/** Silence between words in milliseconds, 0 to 500. */
|
||||
wordGapMs?: number;
|
||||
/** NetTTS-style VOX prosody (Flextalk voices only). */
|
||||
vox?: boolean;
|
||||
/** VOX without the staccato wrapper. */
|
||||
voxClean?: boolean;
|
||||
}
|
||||
|
||||
export interface TtsEngine {
|
||||
@@ -348,6 +352,8 @@ export class Tts {
|
||||
.replace("{echo}", options.echo !== undefined ? String(options.echo) : "")
|
||||
.replace("{words}", options.words ? "1" : "")
|
||||
.replace("{word_gap_ms}", options.wordGapMs !== undefined ? String(options.wordGapMs) : "")
|
||||
.replace("{vox}", options.vox ? "1" : "")
|
||||
.replace("{vox_clean}", options.voxClean ? "1" : "")
|
||||
)
|
||||
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
|
||||
|
||||
@@ -371,7 +377,9 @@ export class Tts {
|
||||
...(options.words ? { VESKSLOP_TTS_WORDS: "1" } : {}),
|
||||
...(options.wordGapMs !== undefined
|
||||
? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) }
|
||||
: {})
|
||||
: {}),
|
||||
...(options.vox ? { VESKSLOP_TTS_VOX: "1" } : {}),
|
||||
...(options.voxClean ? { VESKSLOP_TTS_VOX_CLEAN: "1" } : {})
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"]
|
||||
});
|
||||
|
||||
+8
-2
@@ -508,7 +508,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
highpass: tts?.highpass ?? null,
|
||||
echo: tts?.echo ?? null,
|
||||
words: tts?.words === true,
|
||||
wordGapMs: tts?.wordGapMs ?? null
|
||||
wordGapMs: tts?.wordGapMs ?? null,
|
||||
vox: tts?.vox === true,
|
||||
voxClean: tts?.voxClean === true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -756,6 +758,8 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
const words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
|
||||
const wordGapMs =
|
||||
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 result = await deps.audio.speak(text, {
|
||||
provider: provider ?? undefined,
|
||||
@@ -767,7 +771,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
...(Number.isFinite(highpass) ? { highpass } : {}),
|
||||
...(Number.isFinite(echo) ? { echo } : {}),
|
||||
...(words ? { words: true } : {}),
|
||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {})
|
||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
||||
...(vox ? { vox: true } : {}),
|
||||
...(voxClean ? { voxClean: true } : {})
|
||||
});
|
||||
deps.getPuppet()?.appendAction({
|
||||
type: "audio",
|
||||
|
||||
Reference in New Issue
Block a user