Add MicroslopSam TTS, Flextalk voice profiles, effects, and engine controls
This commit is contained in:
+636
-61
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user