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
|
- [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] 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
|
- [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).
|
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
|
`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
|
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
|
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.
|
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
|
## 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
|
## Web UI follow-ups
|
||||||
|
|
||||||
Ordered after the voice backend unless the owner asks first.
|
Ordered after the voice backend unless the owner asks first.
|
||||||
|
|||||||
+9
-4
@@ -156,15 +156,20 @@ from the TTS panel:
|
|||||||
profile.
|
profile.
|
||||||
- **Radio kit** - high-pass (`tts.highpass`, 40-400 Hz, default 40), mono echo (`tts.echo`, 0-100,
|
- **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
|
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
|
- **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.
|
||||||
|
|
||||||
The window is movable and recenters every time it opens, so it cannot be lost off screen. Changed
|
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
|
profile keys travel as `profile` (with `highpass`, `echo`, `words`, `wordGapMs`, `vox`, and
|
||||||
provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`, `VESKSLOP_TTS_ECHO`,
|
`voxClean`) to the command provider, which exports `VESKSLOP_TTS_PROFILE`, `VESKSLOP_TTS_HIGHPASS`,
|
||||||
`VESKSLOP_TTS_WORDS`, and `VESKSLOP_TTS_WORD_GAP_MS`. The wrapper contract and the registry
|
`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
|
pitfalls (`wine reg import`; never `echo` for the `.reg` key path) are in
|
||||||
[TTS-DESIGN.md](../TTS-DESIGN.md).
|
[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-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-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);
|
const gap = Number(state.speech.wordGapMs);
|
||||||
$("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120;
|
$("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120;
|
||||||
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
|
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
|
||||||
@@ -2058,7 +2062,9 @@ function effectOptions() {
|
|||||||
highpass: Number($("#tts-highpass").value),
|
highpass: Number($("#tts-highpass").value),
|
||||||
echo: Number($("#tts-echo").value),
|
echo: Number($("#tts-echo").value),
|
||||||
words: $("#tts-words").checked,
|
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) {
|
function syncVoiceControls(voices, selected) {
|
||||||
@@ -2734,6 +2740,12 @@ function init() {
|
|||||||
$(`#${id}`).addEventListener("change", () => void saveSpeech());
|
$(`#${id}`).addEventListener("change", () => void saveSpeech());
|
||||||
}
|
}
|
||||||
$("#tts-words").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", () => {
|
$("#profile-open").addEventListener("click", () => {
|
||||||
const panel = document.querySelector(".vk-profile-panel");
|
const panel = document.querySelector(".vk-profile-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
|
|||||||
@@ -556,6 +556,19 @@
|
|||||||
<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">
|
||||||
|
<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>
|
||||||
<section class="vk-profile-section">
|
<section class="vk-profile-section">
|
||||||
<div class="vk-section-head">
|
<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"),
|
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"),
|
echo: z.number().min(0).max(100).optional().describe("Mono echo amount"),
|
||||||
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)"),
|
||||||
|
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(
|
jsonText(
|
||||||
await audio.speak(text, {
|
await audio.speak(text, {
|
||||||
provider,
|
provider,
|
||||||
@@ -576,7 +592,9 @@ function buildServer(): McpServer {
|
|||||||
highpass,
|
highpass,
|
||||||
echo,
|
echo,
|
||||||
words,
|
words,
|
||||||
wordGapMs
|
wordGapMs,
|
||||||
|
vox,
|
||||||
|
voxClean
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-3
@@ -30,6 +30,10 @@ export interface TtsPreferences {
|
|||||||
words: boolean;
|
words: boolean;
|
||||||
/** Silence between words in milliseconds, 0-500. */
|
/** Silence between words in milliseconds, 0-500. */
|
||||||
wordGapMs: number | null;
|
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]> = {
|
const PROFILE_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -146,7 +150,9 @@ const DEFAULTS: WebPreferences = {
|
|||||||
highpass: null,
|
highpass: null,
|
||||||
echo: null,
|
echo: null,
|
||||||
words: false,
|
words: false,
|
||||||
wordGapMs: null
|
wordGapMs: null,
|
||||||
|
vox: false,
|
||||||
|
voxClean: false
|
||||||
},
|
},
|
||||||
audio: { duck: true },
|
audio: { duck: true },
|
||||||
favourites: { users: [], channels: [] },
|
favourites: { users: [], channels: [] },
|
||||||
@@ -340,7 +346,9 @@ export class PreferencesStore {
|
|||||||
highpass: normalizeRange(patch.tts?.highpass, 40, 400),
|
highpass: normalizeRange(patch.tts?.highpass, 40, 400),
|
||||||
echo: normalizeRange(patch.tts?.echo, 0, 100),
|
echo: normalizeRange(patch.tts?.echo, 0, 100),
|
||||||
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,
|
||||||
|
voxClean: patch.tts?.voxClean === true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +413,9 @@ export class PreferencesStore {
|
|||||||
highpass: normalizeRange(raw?.tts?.highpass, 40, 400),
|
highpass: normalizeRange(raw?.tts?.highpass, 40, 400),
|
||||||
echo: normalizeRange(raw?.tts?.echo, 0, 100),
|
echo: normalizeRange(raw?.tts?.echo, 0, 100),
|
||||||
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,
|
||||||
|
voxClean: raw?.tts?.voxClean === true
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
duck: raw?.audio?.duck !== false
|
duck: raw?.audio?.duck !== false
|
||||||
|
|||||||
+9
-1
@@ -34,6 +34,10 @@ export interface TtsOptions {
|
|||||||
words?: boolean;
|
words?: boolean;
|
||||||
/** Silence between words in milliseconds, 0 to 500. */
|
/** Silence between words in milliseconds, 0 to 500. */
|
||||||
wordGapMs?: number;
|
wordGapMs?: number;
|
||||||
|
/** NetTTS-style VOX prosody (Flextalk voices only). */
|
||||||
|
vox?: boolean;
|
||||||
|
/** VOX without the staccato wrapper. */
|
||||||
|
voxClean?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TtsEngine {
|
export interface TtsEngine {
|
||||||
@@ -348,6 +352,8 @@ export class Tts {
|
|||||||
.replace("{echo}", options.echo !== undefined ? String(options.echo) : "")
|
.replace("{echo}", options.echo !== undefined ? String(options.echo) : "")
|
||||||
.replace("{words}", options.words ? "1" : "")
|
.replace("{words}", options.words ? "1" : "")
|
||||||
.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_clean}", options.voxClean ? "1" : "")
|
||||||
)
|
)
|
||||||
: ["espeak-ng", ...(options.voice ? ["-v", options.voice] : []), "-w", out, "-f", textFile];
|
: ["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.words ? { VESKSLOP_TTS_WORDS: "1" } : {}),
|
||||||
...(options.wordGapMs !== undefined
|
...(options.wordGapMs !== undefined
|
||||||
? { VESKSLOP_TTS_WORD_GAP_MS: String(options.wordGapMs) }
|
? { 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"]
|
stdio: ["ignore", "ignore", "pipe"]
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -508,7 +508,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
highpass: tts?.highpass ?? null,
|
highpass: tts?.highpass ?? null,
|
||||||
echo: tts?.echo ?? null,
|
echo: tts?.echo ?? null,
|
||||||
words: tts?.words === true,
|
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 words = req.body?.words !== undefined ? req.body.words === true : prefs.tts.words;
|
||||||
const wordGapMs =
|
const wordGapMs =
|
||||||
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 voxClean = req.body?.voxClean !== undefined ? req.body.voxClean === true : prefs.tts.voxClean;
|
||||||
|
|
||||||
const result = await deps.audio.speak(text, {
|
const result = await deps.audio.speak(text, {
|
||||||
provider: provider ?? undefined,
|
provider: provider ?? undefined,
|
||||||
@@ -767,7 +771,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
...(Number.isFinite(highpass) ? { highpass } : {}),
|
...(Number.isFinite(highpass) ? { highpass } : {}),
|
||||||
...(Number.isFinite(echo) ? { echo } : {}),
|
...(Number.isFinite(echo) ? { echo } : {}),
|
||||||
...(words ? { words: true } : {}),
|
...(words ? { words: true } : {}),
|
||||||
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {})
|
...(Number.isFinite(wordGapMs) ? { wordGapMs } : {}),
|
||||||
|
...(vox ? { vox: true } : {}),
|
||||||
|
...(voxClean ? { voxClean: true } : {})
|
||||||
});
|
});
|
||||||
deps.getPuppet()?.appendAction({
|
deps.getPuppet()?.appendAction({
|
||||||
type: "audio",
|
type: "audio",
|
||||||
|
|||||||
Reference in New Issue
Block a user