Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls

This commit is contained in:
Fisto
2026-09-15 15:09:23 +02:00
parent eab94132bb
commit 302436f95f
42 changed files with 4675 additions and 180 deletions
+636 -61
View File
@@ -26,8 +26,11 @@ const state = {
visibleGuildsSaved: null,
timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "",
speech: { provider: null, language: null, voice: null },
profileDefaults: {},
expressionDefault: null,
scope: "guild",
speaking: new Set(),
speakingFade: new Map(),
people: new Map(),
favourites: { users: [], channels: [] },
favouriteUsers: [],
@@ -661,16 +664,19 @@ function renderVisibleGuilds() {
function voiceMarks(member) {
const marks = el("span", { class: "vk-marks" });
const speaking = state.speaking.has(member.id);
const muted = member.suppress || member.mute || member.selfMute;
// Fixed order: audio → video → share.
if (speaking) {
const levels = el("span", { class: "vk-levels", title: "speaking" });
for (let index = 0; index < 4; index++) levels.append(el("span"));
marks.append(levels);
}
// The bars are always in the layout; they scale up while speaking and
// decay back to nothing when the voice stops.
const classes = ["vk-levels"];
if (!muted && state.speaking.has(member.id)) classes.push("speaking");
if (!muted && state.speakingFade.has(member.id)) classes.push("stopping");
if (member.suppress || member.mute || member.selfMute) {
const levels = el("span", { class: classes.join(" "), title: "speaking" });
for (let index = 0; index < 4; index++) levels.append(el("span"));
marks.append(levels);
if (muted) {
const title = member.suppress ? "muted by server" : member.mute ? "server-muted" : "self-muted";
marks.append(el("span", { class: "neutral", title, html: VK.icon("micSlash") }));
}
@@ -702,6 +708,53 @@ function memberNameNode(member, className = "vk-name") {
});
}
/* Speaking marks. Bars stay on until an explicit stop, a mute, or a channel
change: Discord's speaking events are not reliable enough for an idle decay.
The fade runs the equalizer animation inside a shrinking container, so the
amplitude drops instead of the whole block collapsing at full height. */
const SPEAKING_FADE_MS = 750;
function renderSpeakingViews() {
renderVoiceRail();
renderPeople();
renderRightVoice();
renderRightFavourites();
renderCallStrip();
}
function scheduleSpeakingRender() {
const now = Date.now();
if (now - state.speakingRenderAt < 250) return;
state.speakingRenderAt = now;
renderSpeakingViews();
}
function beginSpeaking(userId) {
const fade = state.speakingFade.get(userId);
if (fade) {
clearTimeout(fade);
state.speakingFade.delete(userId);
}
const wasSpeaking = state.speaking.has(userId);
state.speaking.add(userId);
if (!wasSpeaking) scheduleSpeakingRender();
}
function endSpeaking(userId) {
if (!state.speaking.delete(userId)) return;
const fadeTimer = setTimeout(() => {
state.speakingFade.delete(userId);
renderSpeakingViews();
}, SPEAKING_FADE_MS);
state.speakingFade.set(userId, fadeTimer);
scheduleSpeakingRender();
}
function trackPeople(members) {
for (const member of members ?? []) {
if (!member?.id || member.id === state.user?.id) continue;
@@ -1208,6 +1261,7 @@ function toggleTts() {
const panel = $("#tts-panel");
panel.hidden = !panel.hidden;
$("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`;
if (!panel.hidden) void refreshSpeech();
}
/* Right column */
@@ -1664,10 +1718,10 @@ function fillDeviceSelect(select, devices, defaultLabel) {
async function refreshAudio() {
try {
const status = await VK.api.get("/api/audio");
state.audioRecording = !!status.recording;
state.audioRecording = !!status.capturing;
$("#audio-status").textContent = status.available
? `ok · ${status.backend ?? "pulse"} · duck ${status.duckPercent}%${status.recording ? " · recording" : ""}`
? `ok · ${status.backend ?? "pulse"} · duck ${status.duckPercent}%${status.capturing ? " · capturing" : ""}`
: (status.error ?? "unavailable");
const hintRow = $("#audio-hint-row");
@@ -1678,7 +1732,15 @@ async function refreshAudio() {
$("#audio-tts").textContent = `local ${tts.local ? "yes" : "no"} · api ${
tts.api ? "yes" : "no"
} · player ${status.player ? "yes" : "no"} · queue ${status.queue ?? 0}`;
$("#audio-capture").textContent = status.recording ? "Stop capture" : "Start capture";
$("#audio-capture").textContent = status.capturing ? "Stop capture" : "Start capture";
const duck = $("#tts-duck");
if (duck) {
const on = status.duckEnabled !== false;
duck.classList.toggle("vk-chip-on", on);
duck.classList.toggle("vk-chip-off", !on);
duck.textContent = on ? "duck on voice" : "duck off";
}
} catch (error) {
$("#audio-status").textContent = error.message;
}
@@ -1690,11 +1752,17 @@ async function refreshAudio() {
/* Speech settings (server-side preferences). */
async function refreshSpeech() {
let engines = [];
let defaultProvider = "local";
try {
const status = await VK.api.get("/api/audio");
const tts = status.tts ?? {};
const stt = status.stt ?? {};
engines = tts.engines ?? [];
defaultProvider = tts.defaultProvider ?? "local";
$("#speech-status").textContent = `${tts.engine ?? "no local engine"}${tts.api ? " + api" : ""}`;
$("#speech-stt").textContent = stt.local
? `local ready${stt.engine ? ` (${stt.engine})` : ""}`
@@ -1705,36 +1773,77 @@ async function refreshSpeech() {
// the daemon status is optional here
}
let groups = [];
try {
groups = await VK.api.get("/api/audio/tts/voices");
} catch {
// voices need a local engine
}
try {
const prefs = await VK.api.get("/api/preferences");
state.speech = prefs.tts ?? { provider: null, language: null, voice: null };
const languageSelect = $("#speech-language");
languageSelect.replaceChildren(el("option", { value: "", text: "Auto" }));
for (const group of groups) {
languageSelect.append(el("option", { value: group.id, text: `${group.name} (${group.id})` }));
}
languageSelect.value = state.speech.language ?? "";
fillVoiceOptions(groups, state.speech.language, state.speech.voice);
$("#speech-provider").value = state.speech.provider ?? "";
$("#tts-provider").value = state.speech.provider ?? "";
state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null };
} catch {
// preferences are optional
}
const selected = resolveEngine(state.speech.provider, engines, defaultProvider);
const selectedEngine = engines.find(engine => engine.id === selected);
const hasLanguage = selectedEngine?.language !== false;
fillEngineOptions($("#speech-provider"), engines, state.speech.provider ?? selected);
fillEngineOptions($("#tts-provider"), engines, state.speech.provider ?? selected);
let groups = [];
try {
groups = await VK.api.get(`/api/audio/tts/voices?engine=${encodeURIComponent(selected)}`);
} catch {
// voices need an engine
}
const languageSelect = $("#speech-language");
languageSelect.replaceChildren(el("option", { value: "", text: "Auto" }));
for (const group of groups) {
languageSelect.append(el("option", { value: group.id, text: `${group.name} (${group.id})` }));
}
languageSelect.value = state.speech.language ?? "";
$("#speech-language-row").hidden = !hasLanguage;
const voices = voiceList(groups, hasLanguage ? state.speech.language : null, hasLanguage);
fillVoiceSelect($("#speech-voice"), voices, state.speech.voice);
fillVoiceSelect($("#tts-voice"), voices, state.speech.voice);
syncVoiceControls(voices, state.speech.voice);
fillSpeechSliders(state.speech);
}
function fillVoiceOptions(groups, language, selected) {
const select = $("#speech-voice");
const voices = groups.find(group => group.id === language)?.voices ?? [];
/** Resolve the engine that will actually run: provider override first, then the auto default. */
function resolveEngine(provider, engines, defaultProvider) {
if (provider === "espeak" || provider === "command" || provider === "api") return provider;
if (defaultProvider === "espeak" || defaultProvider === "command" || defaultProvider === "api") return defaultProvider;
const available = id => engines.some(engine => engine.id === id && engine.available);
if (available("command")) return "command";
if (available("espeak")) return "espeak";
return "api";
}
function fillEngineOptions(select, engines, value) {
if (!select) return;
select.replaceChildren(el("option", { value: "", text: "Auto" }));
for (const engine of engines) {
const option = el("option", {
value: engine.id,
text: engine.available ? engine.name : `${engine.name} (unavailable)`
});
option.disabled = !engine.available;
select.append(option);
}
select.value = value;
}
function voiceList(groups, language, hasLanguage) {
if (!hasLanguage) return (groups ?? []).flatMap(group => group.voices ?? []);
if (!language) return [];
return groups?.find(group => group.id === language)?.voices ?? [];
}
function fillVoiceSelect(select, voices, selected) {
if (!select) return;
select.replaceChildren(el("option", { value: "", text: "Default" }));
for (const voice of voices) {
@@ -1745,15 +1854,38 @@ function fillVoiceOptions(groups, language, selected) {
select.value = selected && voices.some(voice => voice.id === selected) ? selected : "";
}
async function saveSpeech() {
function fillVoiceOptions(groups, language, selected) {
fillVoiceSelect($("#speech-voice"), voiceList(groups, language, !!language), selected);
}
async function saveSpeech(extra = {}) {
const provider = $("#speech-provider").value || null;
const language = $("#speech-language").value || null;
const language = $("#speech-language-row").hidden
? (state.speech.language ?? null)
: $("#speech-language").value || null;
const voice = $("#speech-voice").value || null;
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1" || typeof state.speech.expression === "number"
? Number(expressionEl.value)
: null;
try {
const prefs = await VK.api.post("/api/preferences", { tts: { provider, language, voice } });
state.speech = prefs.tts ?? { provider: null, language: null, voice: null };
const prefs = await VK.api.post("/api/preferences", {
tts: { provider, language, voice, speed, pitch, expression, ...effectOptions(), ...extra }
});
state.speech = prefs.tts ?? {
provider: null,
language: null,
voice: null,
speed: null,
pitch: null,
expression: null
};
$("#tts-provider").value = state.speech.provider ?? "";
fillSpeechSliders(state.speech);
await refreshSpeech();
toast("Speech settings saved", "ok");
} catch (error) {
@@ -1761,6 +1893,205 @@ async function saveSpeech() {
}
}
async function warmEngine() {
const button = $("#speech-warm");
const engine = $("#speech-provider").value || null;
const label = $("#speech-provider").selectedOptions[0]?.textContent ?? "engine";
button.disabled = true;
button.textContent = "Starting…";
try {
const result = await VK.api.post("/api/audio/tts/warm", engine ? { engine } : {});
toast(result.warmed ? `${label} ready in ${result.ms} ms` : `${label} needs no warm-up`, "ok");
$("#speech-status").textContent = result.warmed ? `${label} ready (${result.ms} ms)` : label;
} catch (error) {
toast(error.message, "error");
} finally {
button.disabled = false;
button.textContent = "Start engine";
}
}
function fillSpeechSliders(tts) {
const speed = typeof tts?.speed === "number" ? tts.speed : 1;
const pitch = typeof tts?.pitch === "number" ? tts.pitch : 0;
$("#tts-speed").value = Number.isFinite(speed) ? speed : 1;
$("#tts-pitch").value = Number.isFinite(pitch) ? pitch : 0;
syncSpeechSliderLabels();
}
function syncSpeechSliderLabels() {
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const pitchSlider = $("#tts-pitch");
$("#tts-speed-value").textContent = `${speed.toFixed(2)}x`;
$("#tts-pitch-value").textContent = pitchSlider.disabled ? "n/a" : `${pitch > 0 ? "+" : ""}${pitch} st`;
const expressionSlider = $("#tts-expression");
if (expressionSlider) {
const explicit = expressionSlider.dataset.dirty === "1" || expressionSlider.dataset.stored === "1";
$("#tts-expression-value").textContent = explicit ? expressionSlider.value : "default";
}
for (const button of $$("#tts-speed-presets .vk-chip")) {
const active = Math.abs(Number(button.dataset.speed) - speed) < 0.001;
button.classList.toggle("vk-chip-on", active);
button.classList.toggle("vk-chip-off", !active);
}
}
/* Flextalk profile controls (VOX/HEV kit): profile values, effects, word mode. */
const PROFILE_SPEC = [
["pitch", 0, 120],
["rate", 0, 100],
["prominences", 0, 100],
["head_size", 0, 100],
["lowering", 0, 100]
];
function buildProfileSliders() {
const root = $("#tts-profile-sliders");
if (!root) return;
for (const [key, min, max] of PROFILE_SPEC) {
const slider = el("input", { type: "range", min: String(min), max: String(max), step: "1" });
slider.id = `tts-profile-${key}`;
slider.dataset.key = key;
const value = el("span", { class: "vk-mono vk-dim", text: "default" });
root.append(
el(
"div",
{ class: "vk-tts-profile-row" },
el("span", { class: "vk-micro-label", text: key.replace("_", " ") }),
slider,
value
)
);
slider.addEventListener("input", () => {
const engineDefault = state.profileDefaults?.[key];
const snapped = typeof engineDefault === "number" && Math.abs(Number(slider.value) - engineDefault) <= 2;
if (snapped) {
slider.value = engineDefault;
slider.dataset.dirty = "";
value.textContent = "default";
} else {
slider.dataset.dirty = "1";
value.textContent = slider.value;
}
});
slider.addEventListener("change", () => void saveSpeech());
}
}
function profileFromControls() {
const profile = {};
for (const [key] of PROFILE_SPEC) {
const slider = $(`#tts-profile-${key}`);
if (slider?.dataset.dirty === "1") profile[key] = Number(slider.value);
}
return profile;
}
function fillProfileControls() {
const stored = state.speech.profile ?? {};
fillProfileSlots();
for (const [key] of PROFILE_SPEC) {
const slider = $(`#tts-profile-${key}`);
if (!slider) continue;
if (typeof stored[key] === "number") {
slider.value = stored[key];
slider.dataset.dirty = "1";
} else {
const engineDefault = state.profileDefaults?.[key];
if (typeof engineDefault === "number") slider.value = engineDefault;
slider.dataset.dirty = "";
}
const value = slider.parentElement?.querySelector(".vk-mono");
if (value) value.textContent = slider.dataset.dirty === "1" ? slider.value : "default";
}
const highpass = Number(state.speech.highpass);
$("#tts-highpass").value = Number.isFinite(highpass) && state.speech.highpass !== null ? highpass : 40;
$("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`;
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-words").checked = state.speech.words === 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`;
}
function fillProfileSlots() {
const select = $("#tts-profile-slot");
if (!select) return;
const presets = state.speech.profilePresets ?? [];
const current = select.value || "0";
select.replaceChildren(
...Array.from({ length: 5 }, (_, index) => {
const preset = presets[index];
const label = preset ? `${index + 1} · ${preset.name ?? "profile"}` : `${index + 1} · empty`;
return el("option", { value: String(index), text: label });
})
);
select.value = current;
const preset = presets[Number(select.value)];
const name = $("#tts-profile-name");
if (name) name.value = preset?.name ?? "";
}
function effectOptions() {
return {
profile: profileFromControls(),
highpass: Number($("#tts-highpass").value),
echo: Number($("#tts-echo").value),
words: $("#tts-words").checked,
wordGapMs: Number($("#tts-word-gap").value)
};
}
function syncVoiceControls(voices, selected) {
const voice = (voices ?? []).find(entry => entry.id === selected);
const pitchable = !voice || voice.supportsPitch !== false;
$("#tts-pitch-control").hidden = !pitchable;
$("#tts-pitch").disabled = !pitchable;
const expressive = !!voice?.supportsExpression;
state.profileDefaults = voice?.profileDefaults ?? {};
state.expressionDefault = typeof voice?.expressionDefault === "number" ? voice.expressionDefault : null;
if (expressive) fillProfileControls();
{
const slider = $("#tts-expression");
const pref = state.speech.expression;
if (typeof pref === "number") {
slider.value = pref;
} else if (typeof voice.expressionDefault === "number") {
slider.value = voice.expressionDefault;
} else {
slider.value = 50;
}
slider.dataset.stored = typeof pref === "number" ? "1" : "0";
delete slider.dataset.dirty;
}
syncSpeechSliderLabels();
}
/* Browser audio (puppeting) — separate from the bot's own audio devices. */
async function refreshBrowserAudio() {
@@ -1793,19 +2124,38 @@ async function refreshBrowserAudio() {
}
async function grantBrowserAudio() {
if (await ensureBrowserAudio()) toast("Browser audio enabled", "ok");
}
/** Get the browser ready for playback: permission, device labels, and a live
* AudioContext. Must run inside the click so the context is not suspended. */
async function ensureBrowserAudio() {
if (!navigator.mediaDevices?.getUserMedia) {
toast("Browser audio needs HTTPS or localhost", "error");
return;
return false;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
for (const track of stream.getTracks()) track.stop();
toast("Browser audio enabled", "ok");
await refreshBrowserAudio();
} catch (error) {
toast(`Could not enable browser audio: ${error.message}`, "error");
return false;
}
if (!state.clientAudio.ctx) {
state.clientAudio.ctx = new AudioContext({ sampleRate: 48000 });
applyBrowserSink();
}
if (state.clientAudio.ctx.state === "suspended") {
await state.clientAudio.ctx.resume().catch(error => {
console.warn("[client-audio] could not resume the audio context:", error);
});
}
await refreshBrowserAudio();
return true;
}
function applyBrowserSink() {
@@ -1824,6 +2174,8 @@ function playPcm(arrayBuffer) {
}
const context = state.clientAudio.ctx;
if (context.state === "suspended") void context.resume();
const samples = new Int16Array(arrayBuffer);
const buffer = context.createBuffer(1, samples.length, 48000);
const data = buffer.getChannelData(0);
@@ -1846,7 +2198,10 @@ function updateClientAudioButton() {
if (!button) return;
button.classList.toggle("active", state.clientAudio.on);
button.textContent = state.clientAudio.on ? "Client audio on" : "Client audio";
const label = button.querySelector(".vk-btn-text");
const text = state.clientAudio.on ? "Client audio on" : "Client audio";
if (label) label.textContent = text;
else button.textContent = text;
const status = $("#browser-audio-status");
if (status) status.textContent = state.clientAudio.on ? "on" : "off";
@@ -1911,9 +2266,17 @@ function stopClientAudio() {
updateClientAudioButton();
}
function toggleClientAudio() {
if (state.clientAudio.on || state.clientAudio.ws) stopClientAudio();
else startClientAudio();
async function toggleClientAudio() {
if (state.clientAudio.on || state.clientAudio.ws) {
stopClientAudio();
return;
}
// Clicking for audio is the consent: unlock the browser in the same gesture
// so playback is not blocked by a suspended AudioContext.
if (!(await ensureBrowserAudio())) return;
startClientAudio();
}
function syncTtsControls() {
@@ -1921,6 +2284,17 @@ function syncTtsControls() {
if (speak) speak.disabled = !state.takeover;
}
async function setCapture(start) {
try {
await VK.api.post("/api/audio/capture", { start });
toast(start ? "Capture started" : "Capture stopped", "ok");
await refreshTranscript();
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
}
async function speakText() {
if (!state.takeover) {
toast("Composer is read-only — turn Takeover on to speak", "error");
@@ -1932,7 +2306,20 @@ async function speakText() {
try {
const provider = $("#tts-provider").value;
await VK.api.post("/api/audio/speak", { text, provider });
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1" ? Number(expressionEl.value) : state.speech.expression ?? undefined;
await VK.api.post("/api/audio/speak", {
text,
provider,
speed,
pitch,
...(typeof expression === "number" ? { expression } : {}),
...effectOptions()
});
$("#tts-status").textContent = "queued";
toast("Queued for speech", "ok");
void refreshAudio();
@@ -1961,6 +2348,7 @@ function transcriptLine(segment) {
async function refreshTranscript() {
try {
const result = await VK.api.get("/api/transcript?limit=200");
const capture = await VK.api.get("/api/audio").catch(() => null);
const list = $("#transcript-list");
list.replaceChildren();
@@ -1969,7 +2357,7 @@ async function refreshTranscript() {
list.append(
el("div", {
class: "vk-empty",
text: "No captured segments yet. Start capture in Settings → Client → Bot audio."
text: "No captured segments yet. Press Start above."
})
);
} else {
@@ -1978,9 +2366,10 @@ async function refreshTranscript() {
}
const active = [...(result.sessions ?? [])].reverse().find(session => !session.endedAt);
const captureState = capture?.capturing ? "capturing" : "capture off";
$("#transcript-status").textContent = active
? `session ${active.id.slice(-8)} · ${segments.length} segments`
: `${segments.length} segments`;
? `${captureState} · session ${active.id.slice(-8)} · ${segments.length} segments`
: `${captureState} · ${segments.length} segments`;
} catch (error) {
toast(error.message, "error");
}
@@ -2139,22 +2528,16 @@ function handleLiveEvent(event) {
}
if (event.type === "VOICE_SPEAKING") {
if (event.speaking) state.speaking.add(event.userId);
else state.speaking.delete(event.userId);
const now = Date.now();
if (now - state.speakingRenderAt > 500) {
state.speakingRenderAt = now;
renderVoiceRail();
renderPeople();
renderRightVoice();
renderRightFavourites();
renderCallStrip();
}
if (event.speaking) beginSpeaking(event.userId);
else endSpeaking(event.userId);
return;
}
if (event.type === "VOICE_STATE_UPDATE") {
// A mute or a channel change ends speaking immediately: a muted member
// never transmits, and the stop event may not arrive.
if (!event.channelId || event.mute || event.selfMute) endSpeaking(event.userId);
const now = Date.now();
if (now - state.voiceReloadAt > 1000) {
state.voiceReloadAt = now;
@@ -2282,6 +2665,18 @@ function init() {
if (isMobileLayout()) setMobileTab("activity");
});
$("#transcript-refresh").addEventListener("click", () => void refreshTranscript());
$("#transcript-start").addEventListener("click", () => void setCapture(true));
$("#transcript-stop").addEventListener("click", () => void setCapture(false));
$("#tts-duck").addEventListener("click", async () => {
const on = !$("#tts-duck").classList.contains("vk-chip-on");
try {
await VK.api.post("/api/preferences", { audio: { duck: on } });
toast(on ? "TTS ducking on" : "TTS ducking off", "ok");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
});
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
$("#guilds-show-all").addEventListener("click", () => {
@@ -2310,13 +2705,193 @@ function init() {
void saveSpeech();
});
for (const id of ["tts-speed", "tts-pitch"]) {
$(`#${id}`).addEventListener("input", syncSpeechSliderLabels);
$(`#${id}`).addEventListener("change", () => void saveSpeech());
}
$("#tts-expression").addEventListener("input", () => {
const slider = $("#tts-expression");
const engineDefault = state.expressionDefault;
const snapped =
typeof engineDefault === "number" && Math.abs(Number(slider.value) - engineDefault) <= 2;
if (snapped) {
slider.value = engineDefault;
slider.dataset.dirty = "";
} else {
slider.dataset.dirty = "1";
}
syncSpeechSliderLabels();
});
$("#tts-expression").addEventListener("change", () => void saveSpeech());
buildProfileSliders();
for (const id of ["tts-highpass", "tts-echo", "tts-word-gap"]) {
$(`#${id}`).addEventListener("input", () => {
$("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`;
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
});
$(`#${id}`).addEventListener("change", () => void saveSpeech());
}
$("#tts-words").addEventListener("change", () => void saveSpeech());
$("#profile-open").addEventListener("click", () => {
const panel = document.querySelector(".vk-profile-panel");
if (panel) {
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
}
$("#profile-overlay").hidden = false;
fillProfileControls();
});
$("#profile-close").addEventListener("click", () => {
$("#profile-overlay").hidden = true;
});
$("#profile-overlay").addEventListener("click", event => {
if (window.__profileDragged) {
window.__profileDragged = false;
return;
}
if (event.target === $("#profile-overlay")) $("#profile-overlay").hidden = true;
});
(() => {
const panel = document.querySelector(".vk-profile-panel");
const handle = $("#profile-drag");
if (!panel || !handle) return;
let dragging = false;
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
handle.addEventListener("mousedown", event => {
const rect = panel.getBoundingClientRect();
dragging = true;
startX = event.clientX;
startY = event.clientY;
originX = rect.left;
originY = rect.top;
panel.style.transform = "none";
panel.style.left = `${originX}px`;
panel.style.top = `${originY}px`;
event.preventDefault();
});
window.addEventListener("mousemove", event => {
if (!dragging) return;
if (Math.abs(event.clientX - startX) + Math.abs(event.clientY - startY) > 3) {
window.__profileDragged = true;
}
panel.style.left = `${originX + event.clientX - startX}px`;
panel.style.top = `${originY + event.clientY - startY}px`;
});
window.addEventListener("mouseup", () => {
dragging = false;
});
})();
for (const slot of ["1", "2"]) {
const input = $(`#tts-test-custom-${slot}`);
input.value = localStorage.getItem(`veskslop-test-line-${slot}`) ?? "";
input.addEventListener("input", () =>
localStorage.setItem(`veskslop-test-line-${slot}`, input.value)
);
}
$$(".vk-profile-tests button").forEach(button => {
button.addEventListener("click", async () => {
let text = button.dataset.test ?? "";
if (button.dataset.testCustom) {
text = $(`#tts-test-custom-${button.dataset.testCustom}`).value.trim();
if (!text) {
toast("Custom line is empty", "error");
return;
}
}
try {
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1"
? Number(expressionEl.value)
: state.speech.expression ?? undefined;
await VK.api.post("/api/audio/speak", {
text,
provider: $("#tts-provider").value,
...effectOptions(),
...(typeof expression === "number" ? { expression } : {})
});
toast(`Playing "${text}"`, "ok");
} catch (error) {
toast(error.message, "error");
}
});
});
$("#tts-profile-slot").addEventListener("change", () => {
const preset = (state.speech.profilePresets ?? [])[Number($("#tts-profile-slot").value)];
$("#tts-profile-name").value = preset?.name ?? "";
});
$("#tts-profile-save").addEventListener("click", async () => {
const slot = Number($("#tts-profile-slot").value);
const presets = (state.speech.profilePresets ?? [null, null, null, null, null]).slice(0, 5);
const name = $("#tts-profile-name").value.trim() || `Profile ${slot + 1}`;
presets[slot] = {
name,
...profileFromControls(),
...(typeof state.speech.expression === "number" ? { expression: state.speech.expression } : {})
};
await saveSpeech({ profilePresets: presets });
toast(`Saved "${name}" to slot ${slot + 1}`, "ok");
});
$("#tts-profile-load").addEventListener("click", async () => {
const slot = Number($("#tts-profile-slot").value);
const preset = (state.speech.profilePresets ?? [])[slot];
if (!preset) {
toast(`Slot ${slot + 1} is empty`, "error");
return;
}
for (const [key] of PROFILE_SPEC) {
const slider = $(`#tts-profile-${key}`);
if (!slider || typeof preset[key] !== "number") continue;
slider.value = preset[key];
slider.dataset.dirty = "1";
}
if (typeof preset.expression === "number") {
$("#tts-expression").value = preset.expression;
$("#tts-expression").dataset.dirty = "1";
}
fillProfileControls();
syncSpeechSliderLabels();
await saveSpeech();
toast(`Loaded "${preset.name ?? `Profile ${slot + 1}`}"`, "ok");
});
$("#tts-speed-presets").addEventListener("click", event => {
const button = event.target.closest("button[data-speed]");
if (!button) return;
$("#tts-speed").value = button.dataset.speed;
syncSpeechSliderLabels();
void saveSpeech();
});
syncSpeechSliderLabels();
$("#speech-provider").addEventListener("change", () => void saveSpeech());
$("#speech-warm").addEventListener("click", () => void warmEngine());
$("#speech-language").addEventListener("change", () => {
$("#speech-voice").value = "";
void saveSpeech();
});
$("#speech-voice").addEventListener("change", () => void saveSpeech());
$("#tts-voice").addEventListener("change", () => {
$("#speech-voice").value = $("#tts-voice").value;
void saveSpeech();
});
$("#scope-guild").addEventListener("click", () => void applyScope("guild"));
$("#scope-dm").addEventListener("click", () => void applyScope("dm"));