773 lines
32 KiB
JavaScript
773 lines
32 KiB
JavaScript
/**
|
||
* 🏷️ Widget Inline Tags Section
|
||
* Builds an engine-aware inline tag panel for Step Audio EditX, Higgs Audio v3,
|
||
* CosyVoice3, and OmniVoice.
|
||
*/
|
||
|
||
import { api } from "/scripts/api.js";
|
||
import { INDEX_TTS_EMOTION_VISUALS } from "./emotion_radar_canvas_widget.js";
|
||
|
||
const INDEX_TTS_EMOTIONS = ["happy", "angry", "sad", "afraid", "disgusted", "melancholic", "surprised", "calm"];
|
||
const INDEX_TTS_RADIAL_EMOTIONS = ["happy", "surprised", "angry", "disgusted", "sad", "afraid", "calm", "melancholic"];
|
||
const INDEX_TTS_EMOTION_COLORS = Object.fromEntries(
|
||
INDEX_TTS_EMOTION_VISUALS.map(({ name, color }) => [name.toLowerCase(), color])
|
||
);
|
||
|
||
const HIGGS_TAGS = {
|
||
emotion: [
|
||
"elation", "amusement", "enthusiasm", "determination", "pride", "contentment", "affection",
|
||
"relief", "contemplation", "confusion", "surprise", "awe", "longing", "arousal", "anger",
|
||
"fear", "disgust", "bitterness", "sadness", "shame", "helplessness"
|
||
],
|
||
style: ["singing", "shouting", "whispering"],
|
||
prosody: [
|
||
"speed_very_slow", "speed_slow", "speed_fast", "speed_very_fast",
|
||
"pitch_low", "pitch_high", "pause", "long_pause", "expressive_high", "expressive_low"
|
||
],
|
||
sfx: ["cough", "laughter", "crying", "screaming", "burping", "humming", "sigh", "sniff", "sneeze"],
|
||
};
|
||
|
||
const COSY_SINGLE_TAGS = [
|
||
"breath", "quick_breath", "laughter", "cough", "sigh", "gasp", "noise",
|
||
"hissing", "vocalized-noise", "lipsmack", "mn", "clucking", "accent"
|
||
];
|
||
|
||
const COSY_WRAPPER_TAGS = ["laughing", "strong"];
|
||
|
||
const OMNIVOICE_NON_VERBAL_TAGS = [
|
||
"laughter", "sigh", "confirmation-en", "question-en", "question-ah",
|
||
"question-oh", "question-ei", "question-yi", "surprise-ah", "surprise-oh",
|
||
"surprise-wa", "surprise-yo", "dissatisfaction-hnn"
|
||
];
|
||
|
||
function stylePanelContainer(element, { separated = true } = {}) {
|
||
element.style.marginBottom = "8px";
|
||
if (separated) {
|
||
element.style.paddingBottom = "8px";
|
||
element.style.borderBottom = "1px solid #444";
|
||
}
|
||
}
|
||
|
||
function createPanelLabel(text, color, title = "") {
|
||
const label = document.createElement("div");
|
||
label.textContent = text;
|
||
label.style.fontWeight = "bold";
|
||
label.style.marginBottom = "5px";
|
||
label.style.fontSize = "11px";
|
||
label.style.color = color;
|
||
if (title) {
|
||
label.title = title;
|
||
}
|
||
return label;
|
||
}
|
||
|
||
function createSelect(options, placeholder, value = "") {
|
||
const select = document.createElement("select");
|
||
select.style.width = "100%";
|
||
select.style.marginBottom = "4px";
|
||
select.style.padding = "3px";
|
||
select.style.fontSize = "10px";
|
||
select.style.background = "#2a2a2a";
|
||
select.style.color = "#eee";
|
||
select.style.border = "1px solid #444";
|
||
select.innerHTML = `<option value="">${placeholder}</option>${options.map(({ value: optionValue, label }) => `<option value="${optionValue}">${label}</option>`).join("")}`;
|
||
if (value) {
|
||
select.value = value;
|
||
}
|
||
return select;
|
||
}
|
||
|
||
function createInfoText(text, title = "") {
|
||
const label = document.createElement("div");
|
||
label.textContent = text;
|
||
label.style.fontSize = "9px";
|
||
label.style.marginBottom = "4px";
|
||
label.style.color = "#999";
|
||
if (title) {
|
||
label.title = title;
|
||
}
|
||
return label;
|
||
}
|
||
|
||
function createRangeControl(state, storageKey, {
|
||
stateValueKey,
|
||
labelPrefix,
|
||
min = "1",
|
||
max = "5",
|
||
title = "",
|
||
}) {
|
||
const value = state[stateValueKey] || "1";
|
||
const label = createInfoText(`${labelPrefix}: ${value}`, title);
|
||
const slider = document.createElement("input");
|
||
slider.type = "range";
|
||
slider.min = min;
|
||
slider.max = max;
|
||
slider.value = value;
|
||
slider.style.width = "100%";
|
||
slider.style.marginBottom = "4px";
|
||
if (title) {
|
||
slider.title = title;
|
||
}
|
||
slider.addEventListener("input", () => {
|
||
label.textContent = `${labelPrefix}: ${slider.value}`;
|
||
state[stateValueKey] = slider.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
return { label, slider };
|
||
}
|
||
|
||
function createButton(text, title) {
|
||
const button = document.createElement("button");
|
||
button.textContent = text;
|
||
button.title = title;
|
||
button.style.width = "100%";
|
||
button.style.padding = "4px";
|
||
button.style.cursor = "pointer";
|
||
button.style.fontSize = "10px";
|
||
button.style.background = "#3a3a3a";
|
||
button.style.color = "#eee";
|
||
button.style.border = "1px solid #555";
|
||
button.style.borderRadius = "2px";
|
||
return button;
|
||
}
|
||
|
||
function buildStepSection(state, storageKey) {
|
||
const section = document.createElement("div");
|
||
section.style.display = "flex";
|
||
section.style.flexDirection = "column";
|
||
section.style.gap = "8px";
|
||
|
||
const paraSection = document.createElement("div");
|
||
stylePanelContainer(paraSection);
|
||
const paraSelect = createSelect([
|
||
{ value: "Laughter", label: "Laughter" },
|
||
{ value: "Breathing", label: "Breathing" },
|
||
{ value: "Sigh", label: "Sigh" },
|
||
{ value: "Uhm", label: "Uhm" },
|
||
{ value: "Surprise-oh", label: "Surprise (oh)" },
|
||
{ value: "Surprise-ah", label: "Surprise (ah)" },
|
||
{ value: "Surprise-wa", label: "Surprise (wa)" },
|
||
{ value: "Confirmation-en", label: "Confirmation (en)" },
|
||
{ value: "Question-ei", label: "Question (ei)" },
|
||
{ value: "Dissatisfaction-hnn", label: "Dissatisfaction (hnn)" },
|
||
], "Select sound...", state.lastParalinguisticType || "");
|
||
paraSelect.addEventListener("change", () => {
|
||
state.lastParalinguisticType = paraSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const { label: paraIterLabel, slider: paraIterSlider } = createRangeControl(state, storageKey, {
|
||
stateValueKey: "lastParalinguisticIter",
|
||
labelPrefix: "Iterations",
|
||
});
|
||
const addParaBtn = createButton("Add Paralinguistic", "Insert paralinguistic tag at cursor");
|
||
paraSection.append(
|
||
createPanelLabel("Paralinguistic", "#00ffff"),
|
||
paraSelect,
|
||
paraIterLabel,
|
||
paraIterSlider,
|
||
addParaBtn
|
||
);
|
||
|
||
const emotionSection = document.createElement("div");
|
||
stylePanelContainer(emotionSection);
|
||
const emotionSelect = createSelect([
|
||
"happy", "sad", "angry", "excited", "calm", "fearful", "surprised",
|
||
"disgusted", "confusion", "empathy", "embarrass", "depressed", "coldness", "admiration"
|
||
].map((value) => ({ value, label: value.charAt(0).toUpperCase() + value.slice(1) })), "Select emotion...", state.lastEmotionType || "");
|
||
emotionSelect.addEventListener("change", () => {
|
||
state.lastEmotionType = emotionSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const { label: emotionIterLabel, slider: emotionIterSlider } = createRangeControl(state, storageKey, {
|
||
stateValueKey: "lastEmotionIter",
|
||
labelPrefix: "Iterations",
|
||
});
|
||
const addEmotionBtn = createButton("Add Emotion", "Insert emotion tag at cursor");
|
||
emotionSection.append(
|
||
createPanelLabel("Emotion", "#ffaa00"),
|
||
emotionSelect,
|
||
emotionIterLabel,
|
||
emotionIterSlider,
|
||
addEmotionBtn
|
||
);
|
||
|
||
const styleSection = document.createElement("div");
|
||
stylePanelContainer(styleSection);
|
||
const styleSelect = createSelect([
|
||
"whisper", "serious", "child", "older", "girl", "pure", "sister", "sweet",
|
||
"exaggerated", "ethereal", "generous", "recite", "act_coy", "warm", "shy",
|
||
"comfort", "authority", "chat", "radio", "soulful", "gentle", "story", "vivid",
|
||
"program", "news", "advertising", "roar", "murmur", "shout", "deeply", "loudly",
|
||
"arrogant", "friendly"
|
||
].map((value) => ({ value, label: value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()) })), "Select style...", state.lastStyleType || "");
|
||
styleSelect.addEventListener("change", () => {
|
||
state.lastStyleType = styleSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const { label: styleIterLabel, slider: styleIterSlider } = createRangeControl(state, storageKey, {
|
||
stateValueKey: "lastStyleIter",
|
||
labelPrefix: "Iterations",
|
||
});
|
||
const addStyleBtn = createButton("Add Style", "Insert style tag at cursor");
|
||
styleSection.append(
|
||
createPanelLabel("Style", "#ff5555"),
|
||
styleSelect,
|
||
styleIterLabel,
|
||
styleIterSlider,
|
||
addStyleBtn
|
||
);
|
||
|
||
const speedSection = document.createElement("div");
|
||
stylePanelContainer(speedSection);
|
||
const speedSelect = createSelect([
|
||
{ value: "faster", label: "Faster" },
|
||
{ value: "slower", label: "Slower" },
|
||
{ value: "more_faster", label: "More Faster" },
|
||
{ value: "more_slower", label: "More Slower" },
|
||
], "Select speed...", state.lastSpeedType || "");
|
||
speedSelect.addEventListener("change", () => {
|
||
state.lastSpeedType = speedSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const { label: speedIterLabel, slider: speedIterSlider } = createRangeControl(state, storageKey, {
|
||
stateValueKey: "lastSpeedIter",
|
||
labelPrefix: "Iterations",
|
||
});
|
||
const addSpeedBtn = createButton("Add Speed", "Insert speed tag at cursor");
|
||
speedSection.append(
|
||
createPanelLabel("Speed", "#66ff66"),
|
||
speedSelect,
|
||
speedIterLabel,
|
||
speedIterSlider,
|
||
addSpeedBtn
|
||
);
|
||
|
||
const restoreSection = document.createElement("div");
|
||
stylePanelContainer(restoreSection, { separated: false });
|
||
const { label: restorePassLabel, slider: restorePassSlider } = createRangeControl(state, storageKey, {
|
||
stateValueKey: "lastRestorePasses",
|
||
labelPrefix: "VC Passes",
|
||
title: "How many restoration passes to run. More passes push harder toward the chosen reference voice.",
|
||
});
|
||
const restoreRefLabel = createInfoText(
|
||
"Reference Iteration (optional)",
|
||
"Optional edit-step reference. In <restore:1@2>, the @2 points to edit step 2 from earlier inline edits."
|
||
);
|
||
const restoreRefInput = document.createElement("input");
|
||
restoreRefInput.type = "number";
|
||
restoreRefInput.placeholder = "Leave empty for original";
|
||
restoreRefInput.min = "1";
|
||
restoreRefInput.max = "10";
|
||
restoreRefInput.value = state.lastRestoreRefIter || "";
|
||
restoreRefInput.style.width = "100%";
|
||
restoreRefInput.style.padding = "3px";
|
||
restoreRefInput.style.fontSize = "10px";
|
||
restoreRefInput.style.background = "#2a2a2a";
|
||
restoreRefInput.style.color = "#eee";
|
||
restoreRefInput.style.border = "1px solid #444";
|
||
restoreRefInput.style.marginBottom = "4px";
|
||
restoreRefInput.title = "Leave empty to use the original clean pre-edit audio. Enter an edit step number to use that earlier edited snapshot as the reference voice.";
|
||
restoreRefInput.addEventListener("change", () => {
|
||
state.lastRestoreRefIter = restoreRefInput.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const addRestoreBtn = createButton("Add Restore", "Insert <restore>, <restore:2>, or <restore:1@2>.");
|
||
restoreSection.append(
|
||
createPanelLabel("Voice Restoration", "#ffcc33", "Restore runs after all other inline edits."),
|
||
restorePassLabel,
|
||
restorePassSlider,
|
||
restoreRefLabel,
|
||
restoreRefInput,
|
||
addRestoreBtn
|
||
);
|
||
|
||
section.append(paraSection, emotionSection, styleSection, speedSection, restoreSection);
|
||
|
||
return {
|
||
panel: section,
|
||
controls: {
|
||
paraSelect, paraIterSlider, addParaBtn,
|
||
emotionSelect, emotionIterSlider, addEmotionBtn,
|
||
styleSelect, styleIterSlider, addStyleBtn,
|
||
speedSelect, speedIterSlider, addSpeedBtn,
|
||
restorePassSlider, restoreRefInput, addRestoreBtn,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildHiggsSection(state, storageKey) {
|
||
const section = document.createElement("div");
|
||
section.style.display = "flex";
|
||
section.style.flexDirection = "column";
|
||
section.style.gap = "8px";
|
||
|
||
state.lastHiggsInlineValues = state.lastHiggsInlineValues || {};
|
||
|
||
const buildHiggsTagSubsection = ({ category, label, color, separated = true }) => {
|
||
const subsection = document.createElement("div");
|
||
stylePanelContainer(subsection, { separated });
|
||
const savedValue = state.lastHiggsInlineValues[category] || HIGGS_TAGS[category][0] || "";
|
||
const select = createSelect(
|
||
HIGGS_TAGS[category].map((value) => ({ value, label: value })),
|
||
`Select ${label.toLowerCase()}...`,
|
||
savedValue
|
||
);
|
||
select.addEventListener("change", () => {
|
||
state.lastHiggsInlineValues[category] = select.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const addBtn = createButton(`Add ${label}`, `Insert canonical Higgs ${label.toLowerCase()} tag at cursor`);
|
||
subsection.append(
|
||
createPanelLabel(label, color),
|
||
select,
|
||
addBtn
|
||
);
|
||
return { subsection, select, addBtn };
|
||
};
|
||
|
||
const emotionSection = buildHiggsTagSubsection({
|
||
category: "emotion",
|
||
label: "Emotion",
|
||
color: "#64d7ff",
|
||
});
|
||
const styleSection = buildHiggsTagSubsection({
|
||
category: "style",
|
||
label: "Style",
|
||
color: "#8fcbff",
|
||
});
|
||
const prosodySection = buildHiggsTagSubsection({
|
||
category: "prosody",
|
||
label: "Prosody",
|
||
color: "#b5a3ff",
|
||
});
|
||
const sfxSection = buildHiggsTagSubsection({
|
||
category: "sfx",
|
||
label: "SFX",
|
||
color: "#ff9e7a",
|
||
separated: false,
|
||
});
|
||
|
||
section.append(
|
||
emotionSection.subsection,
|
||
styleSection.subsection,
|
||
prosodySection.subsection,
|
||
sfxSection.subsection
|
||
);
|
||
|
||
return {
|
||
panel: section,
|
||
controls: {
|
||
emotionSelect: emotionSection.select,
|
||
addEmotionBtn: emotionSection.addBtn,
|
||
styleSelect: styleSection.select,
|
||
addStyleBtn: styleSection.addBtn,
|
||
prosodySelect: prosodySection.select,
|
||
addProsodyBtn: prosodySection.addBtn,
|
||
sfxSelect: sfxSection.select,
|
||
addSfxBtn: sfxSection.addBtn,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildCosySection(state, storageKey) {
|
||
const section = document.createElement("div");
|
||
section.style.display = "flex";
|
||
section.style.flexDirection = "column";
|
||
section.style.gap = "8px";
|
||
|
||
const singleSection = document.createElement("div");
|
||
stylePanelContainer(singleSection);
|
||
const singleTagSelect = createSelect(
|
||
COSY_SINGLE_TAGS.map((value) => ({ value, label: value })),
|
||
"Select single tag...",
|
||
state.lastCosyInlineSingleTag || "breath"
|
||
);
|
||
singleTagSelect.addEventListener("change", () => {
|
||
state.lastCosyInlineSingleTag = singleTagSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const addSingleTagBtn = createButton("Add Single Tag", "Insert native CosyVoice3 single tag like <breath>");
|
||
singleSection.append(
|
||
createPanelLabel("Single Tags", "#8cff9d"),
|
||
singleTagSelect,
|
||
addSingleTagBtn
|
||
);
|
||
|
||
const wrapperSection = document.createElement("div");
|
||
stylePanelContainer(wrapperSection, { separated: false });
|
||
const wrapperTagSelect = createSelect(
|
||
COSY_WRAPPER_TAGS.map((value) => ({ value, label: value })),
|
||
"Select wrapper tag...",
|
||
state.lastCosyInlineWrapperTag || "laughing"
|
||
);
|
||
wrapperTagSelect.addEventListener("change", () => {
|
||
state.lastCosyInlineWrapperTag = wrapperTagSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const wrapperHint = createInfoText("If text is selected, the wrapper will surround it. Otherwise it inserts an example pair.");
|
||
const addWrapperTagBtn = createButton("Add Wrapper Tag", "Insert wrapper tag pair like <laughing>text</laughing>");
|
||
wrapperSection.append(
|
||
createPanelLabel("Wrapper Tags", "#f6c95b"),
|
||
wrapperTagSelect,
|
||
wrapperHint,
|
||
addWrapperTagBtn
|
||
);
|
||
|
||
section.append(singleSection, wrapperSection);
|
||
|
||
return {
|
||
panel: section,
|
||
controls: {
|
||
singleTagSelect,
|
||
addSingleTagBtn,
|
||
wrapperTagSelect,
|
||
addWrapperTagBtn,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildOmniVoiceSection(state, storageKey) {
|
||
const section = document.createElement("div");
|
||
section.style.display = "flex";
|
||
section.style.flexDirection = "column";
|
||
section.style.gap = "8px";
|
||
|
||
const tagSection = document.createElement("div");
|
||
stylePanelContainer(tagSection, { separated: false });
|
||
const tagSelect = createSelect(
|
||
OMNIVOICE_NON_VERBAL_TAGS.map((value) => ({ value, label: value })),
|
||
"Select OmniVoice tag...",
|
||
state.lastOmniVoiceInlineTag || "laughter"
|
||
);
|
||
tagSelect.addEventListener("change", () => {
|
||
state.lastOmniVoiceInlineTag = tagSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const tagHint = createInfoText(
|
||
"Editor inserts suite-default aliases like <laughter>. The OmniVoice processor converts them to official [laughter] tags at generation time.",
|
||
"OmniVoice also supports bracketed CMU pronunciation overrides like [B EY1 S], but those are typed manually."
|
||
);
|
||
const addTagBtn = createButton("Add OmniVoice Tag", "Insert suite-default OmniVoice tag like <laughter>");
|
||
tagSection.append(
|
||
createPanelLabel("Native Non-Verbal Tags", "#82d4ff"),
|
||
tagSelect,
|
||
tagHint,
|
||
addTagBtn
|
||
);
|
||
|
||
section.append(tagSection);
|
||
|
||
return {
|
||
panel: section,
|
||
controls: {
|
||
tagSelect,
|
||
addTagBtn,
|
||
},
|
||
};
|
||
}
|
||
|
||
function createIndexTTSRadialPicker(onPick) {
|
||
const button = createButton("Press + drag to pick emotion", "Drag toward an emotion; distance sets its magnitude");
|
||
button.style.background = `linear-gradient(90deg, ${INDEX_TTS_RADIAL_EMOTIONS.map(emotion => INDEX_TTS_EMOTION_COLORS[emotion]).join(", ")})`;
|
||
|
||
button.addEventListener("pointerdown", event => {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const size = 220;
|
||
const center = size / 2;
|
||
const maxRadius = 82;
|
||
const originX = event.clientX;
|
||
const originY = event.clientY;
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
Object.assign(canvas.style, {
|
||
position: "fixed", left: `${originX - center}px`, top: `${originY - center}px`,
|
||
width: `${size}px`, height: `${size}px`, zIndex: "100002", pointerEvents: "none",
|
||
borderRadius: "50%", filter: "drop-shadow(0 8px 20px rgba(0,0,0,.85))"
|
||
});
|
||
document.body.appendChild(canvas);
|
||
const ctx = canvas.getContext("2d");
|
||
let selectedIndex = 0;
|
||
let magnitude = 0;
|
||
|
||
const draw = () => {
|
||
ctx.clearRect(0, 0, size, size);
|
||
const selectedEmotion = INDEX_TTS_RADIAL_EMOTIONS[selectedIndex];
|
||
const selectedColor = INDEX_TTS_EMOTION_COLORS[selectedEmotion];
|
||
ctx.fillStyle = "rgba(25,25,27,.96)";
|
||
ctx.beginPath();
|
||
ctx.arc(center, center, 104, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = selectedColor;
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
|
||
const selectedAngle = selectedIndex * Math.PI / 4 - Math.PI / 2;
|
||
ctx.fillStyle = `${selectedColor}38`;
|
||
ctx.beginPath();
|
||
ctx.moveTo(center, center);
|
||
ctx.arc(center, center, 100, selectedAngle - Math.PI / 8, selectedAngle + Math.PI / 8);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
INDEX_TTS_RADIAL_EMOTIONS.forEach((emotion, index) => {
|
||
const angle = index * Math.PI / 4 - Math.PI / 2;
|
||
const x = center + Math.cos(angle) * maxRadius;
|
||
const y = center + Math.sin(angle) * maxRadius;
|
||
ctx.fillStyle = index === selectedIndex ? INDEX_TTS_EMOTION_COLORS[emotion] : "#aaa";
|
||
ctx.font = index === selectedIndex ? "bold 11px Arial" : "10px Arial";
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(emotion, x, y);
|
||
});
|
||
|
||
ctx.fillStyle = selectedColor;
|
||
ctx.beginPath();
|
||
ctx.arc(center, center, 28, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.fillStyle = selectedEmotion === "happy" ? "#201b00" : "#f5f5f5";
|
||
ctx.font = "bold 12px Arial";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(magnitude.toFixed(2), center, center);
|
||
};
|
||
|
||
const update = pointerEvent => {
|
||
const dx = pointerEvent.clientX - originX;
|
||
const dy = pointerEvent.clientY - originY;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
let angle = Math.atan2(dy, dx) + Math.PI / 2;
|
||
if (angle < 0) angle += Math.PI * 2;
|
||
selectedIndex = Math.round(angle / (Math.PI / 4)) % 8;
|
||
magnitude = Math.min(1.2, Math.max(0, distance / maxRadius * 1.2));
|
||
onPick(INDEX_TTS_RADIAL_EMOTIONS[selectedIndex], magnitude, false);
|
||
draw();
|
||
};
|
||
|
||
const finish = pointerEvent => {
|
||
update(pointerEvent);
|
||
window.removeEventListener("pointermove", update, true);
|
||
window.removeEventListener("pointerup", finish, true);
|
||
window.removeEventListener("pointercancel", cancel, true);
|
||
canvas.remove();
|
||
onPick(INDEX_TTS_RADIAL_EMOTIONS[selectedIndex], magnitude, true);
|
||
};
|
||
const cancel = () => {
|
||
window.removeEventListener("pointermove", update, true);
|
||
window.removeEventListener("pointerup", finish, true);
|
||
window.removeEventListener("pointercancel", cancel, true);
|
||
canvas.remove();
|
||
};
|
||
window.addEventListener("pointermove", update, true);
|
||
window.addEventListener("pointerup", finish, true);
|
||
window.addEventListener("pointercancel", cancel, true);
|
||
draw();
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function buildIndexTTSSection(state, storageKey) {
|
||
const section = document.createElement("div");
|
||
Object.assign(section.style, { display: "flex", flexDirection: "column", gap: "8px" });
|
||
|
||
const vectorSection = document.createElement("div");
|
||
stylePanelContainer(vectorSection);
|
||
const vectorModeSelect = createSelect([
|
||
{ value: "absolute", label: "Absolute vector" },
|
||
{ value: "delta", label: "Delta vector" },
|
||
], "Vector mode...", state.lastIndexTTSVectorMode || "absolute");
|
||
vectorModeSelect.addEventListener("change", () => {
|
||
state.lastIndexTTSVectorMode = vectorModeSelect.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const addVectorBtn = createButton("Add Vector Tag", "Insert a full vector tag; click it in the editor to open the radar");
|
||
vectorSection.append(
|
||
createPanelLabel("Full Vector Radar", "#45b7d1"),
|
||
vectorModeSelect,
|
||
createInfoText("Insert the tag, then click it in the editor for live radar editing."),
|
||
addVectorBtn
|
||
);
|
||
|
||
const namedSection = document.createElement("div");
|
||
stylePanelContainer(namedSection);
|
||
const namedEmotionSelect = createSelect(
|
||
INDEX_TTS_EMOTIONS.map(value => ({ value, label: value.charAt(0).toUpperCase() + value.slice(1) })),
|
||
"Select emotion...",
|
||
state.lastIndexTTSNamedEmotion || "happy"
|
||
);
|
||
const namedOperationSelect = createSelect([
|
||
{ value: "absolute", label: "Absolute value" },
|
||
{ value: "positive", label: "Positive delta (+)" },
|
||
{ value: "negative", label: "Negative delta (−)" },
|
||
], "Operation...", state.lastIndexTTSNamedOperation || "absolute");
|
||
const namedValueInput = document.createElement("input");
|
||
namedValueInput.type = "range";
|
||
namedValueInput.min = "0";
|
||
namedValueInput.max = "1.2";
|
||
namedValueInput.step = "0.05";
|
||
namedValueInput.value = state.lastIndexTTSNamedValue ?? "0.5";
|
||
Object.assign(namedValueInput.style, { width: "100%", boxSizing: "border-box", marginBottom: "4px" });
|
||
const namedValueLabel = createInfoText(`Magnitude: ${Number(namedValueInput.value).toFixed(2)}`);
|
||
const updateNamedSliderFill = () => {
|
||
const ratio = Math.max(0, Math.min(100, Number(namedValueInput.value) / 1.2 * 100));
|
||
const color = INDEX_TTS_EMOTION_COLORS[namedEmotionSelect.value] || "#20B2AA";
|
||
namedValueInput.dataset.fillColor = color;
|
||
namedValueInput.style.accentColor = color;
|
||
namedValueInput.style.background = `linear-gradient(90deg, ${color} 0%, ${color} ${ratio}%, rgba(53,53,52,.9) ${ratio}%, rgba(53,53,52,.9) 100%)`;
|
||
};
|
||
const saveNamedState = (persist = true) => {
|
||
state.lastIndexTTSNamedEmotion = namedEmotionSelect.value;
|
||
state.lastIndexTTSNamedOperation = namedOperationSelect.value;
|
||
state.lastIndexTTSNamedValue = namedValueInput.value;
|
||
if (persist) state.saveToLocalStorage(storageKey);
|
||
};
|
||
namedEmotionSelect.addEventListener("change", saveNamedState);
|
||
namedOperationSelect.addEventListener("change", saveNamedState);
|
||
namedValueInput.addEventListener("input", () => {
|
||
namedValueLabel.textContent = `Magnitude: ${Number(namedValueInput.value).toFixed(2)}`;
|
||
updateNamedSliderFill();
|
||
saveNamedState();
|
||
});
|
||
const radialPickerBtn = createIndexTTSRadialPicker((emotion, magnitude, isFinal) => {
|
||
namedEmotionSelect.value = emotion;
|
||
namedValueInput.value = magnitude.toFixed(2);
|
||
namedValueLabel.textContent = `Magnitude: ${magnitude.toFixed(2)} · ${emotion}`;
|
||
updateNamedSliderFill();
|
||
saveNamedState(isFinal);
|
||
});
|
||
namedEmotionSelect.addEventListener("change", updateNamedSliderFill);
|
||
updateNamedSliderFill();
|
||
const addNamedEmotionBtn = createButton("Add Named Emotion", "Insert [sad:0.5], [sad:+0.5], or [sad:-0.5]");
|
||
namedSection.append(
|
||
createPanelLabel("Named Emotion Value", "#ffcc66"),
|
||
radialPickerBtn,
|
||
namedEmotionSelect, namedOperationSelect, namedValueLabel, namedValueInput, addNamedEmotionBtn
|
||
);
|
||
|
||
const textSection = document.createElement("div");
|
||
stylePanelContainer(textSection, { separated: false });
|
||
const presetSelect = createSelect([], "Select saved preset...");
|
||
const populateTextPresets = (presets = {}) => {
|
||
const previous = presetSelect.value;
|
||
while (presetSelect.options.length > 1) presetSelect.remove(1);
|
||
Object.entries(presets)
|
||
.filter(([, preset]) => preset?.type === "text" || preset?.type === "vector")
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.forEach(([name, preset]) => {
|
||
const option = document.createElement("option");
|
||
option.value = name;
|
||
option.textContent = `${name} (${preset.type})`;
|
||
option.dataset.presetType = preset.type;
|
||
if (preset.type === "vector") option.dataset.vectorValues = JSON.stringify(preset.values || []);
|
||
presetSelect.appendChild(option);
|
||
});
|
||
if ([...presetSelect.options].some(option => option.value === previous)) presetSelect.value = previous;
|
||
};
|
||
const refreshTextPresets = () => api.fetchApi("/api/tts-audio-suite/index-tts-emotion-presets")
|
||
.then(response => response.json())
|
||
.then(({ presets = {} }) => populateTextPresets(presets))
|
||
.catch(error => console.warn("Could not load IndexTTS emotion presets", error));
|
||
refreshTextPresets();
|
||
presetSelect.addEventListener("focus", refreshTextPresets);
|
||
window.addEventListener("tts-audio-suite:index-tts-presets-changed", event => {
|
||
populateTextPresets(event.detail?.presets || {});
|
||
});
|
||
const addTextPresetBtn = createButton("Add Preset Tag", "Insert a saved text or vector emotion preset");
|
||
const managePresetsBtn = createButton("Manage Emotion Presets", "Open the IndexTTS emotion preset editor");
|
||
managePresetsBtn.style.marginBottom = "5px";
|
||
const emotionTextInput = document.createElement("input");
|
||
emotionTextInput.type = "text";
|
||
emotionTextInput.placeholder = "Emotion description; {seg} makes it dynamic";
|
||
emotionTextInput.value = state.lastIndexTTSEmotionText || "";
|
||
Object.assign(emotionTextInput.style, { width: "100%", boxSizing: "border-box", padding: "3px", margin: "4px 0", fontSize: "10px", color: "#eee", background: "#2a2a2a", border: "1px solid #444" });
|
||
emotionTextInput.addEventListener("change", () => {
|
||
state.lastIndexTTSEmotionText = emotionTextInput.value;
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
const addEmotionTextBtn = createButton("Add Quoted Text", "Insert a quoted text emotion; include {seg} for dynamic analysis");
|
||
textSection.append(
|
||
createPanelLabel("Text Emotion", "#d78cff"),
|
||
managePresetsBtn,
|
||
presetSelect, addTextPresetBtn,
|
||
emotionTextInput, addEmotionTextBtn
|
||
);
|
||
|
||
section.append(vectorSection, namedSection, textSection);
|
||
return {
|
||
panel: section,
|
||
controls: {
|
||
vectorModeSelect, addVectorBtn,
|
||
namedEmotionSelect, namedOperationSelect, namedValueInput, addNamedEmotionBtn,
|
||
presetSelect, addTextPresetBtn, managePresetsBtn, emotionTextInput, addEmotionTextBtn,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function buildInlineEditSection(state, storageKey) {
|
||
const container = document.createElement("div");
|
||
container.style.display = "flex";
|
||
container.style.flexDirection = "column";
|
||
container.style.gap = "8px";
|
||
container.style.overflowY = "visible";
|
||
container.style.flex = "0 0 auto";
|
||
container.style.paddingRight = "0";
|
||
|
||
const engineSection = document.createElement("div");
|
||
stylePanelContainer(engineSection);
|
||
const inlineEngineSelect = createSelect([
|
||
{ value: "step_audio_editx", label: "Step Audio EditX" },
|
||
{ value: "higgs_audio_v3", label: "Higgs Audio v3" },
|
||
{ value: "cosyvoice3", label: "CosyVoice3" },
|
||
{ value: "omnivoice", label: "OmniVoice" },
|
||
{ value: "index_tts", label: "IndexTTS-2" },
|
||
], "Select inline tag engine...", state.activeInlineTagEngine || "step_audio_editx");
|
||
inlineEngineSelect.addEventListener("change", () => {
|
||
state.activeInlineTagEngine = inlineEngineSelect.value;
|
||
updateVisiblePanel();
|
||
state.saveToLocalStorage(storageKey);
|
||
});
|
||
engineSection.append(
|
||
createPanelLabel("Tag Engine", "#d0d0d0"),
|
||
inlineEngineSelect
|
||
);
|
||
|
||
const stepSection = buildStepSection(state, storageKey);
|
||
const higgsSection = buildHiggsSection(state, storageKey);
|
||
const cosySection = buildCosySection(state, storageKey);
|
||
const omnivoiceSection = buildOmniVoiceSection(state, storageKey);
|
||
const indexTTSSection = buildIndexTTSSection(state, storageKey);
|
||
|
||
const panels = {
|
||
step_audio_editx: stepSection.panel,
|
||
higgs_audio_v3: higgsSection.panel,
|
||
cosyvoice3: cosySection.panel,
|
||
omnivoice: omnivoiceSection.panel,
|
||
index_tts: indexTTSSection.panel,
|
||
};
|
||
|
||
const updateVisiblePanel = () => {
|
||
const activeEngine = inlineEngineSelect.value || "step_audio_editx";
|
||
Object.entries(panels).forEach(([engineKey, panel]) => {
|
||
panel.style.display = engineKey === activeEngine ? "flex" : "none";
|
||
});
|
||
};
|
||
|
||
container.append(engineSection, stepSection.panel, higgsSection.panel, cosySection.panel, omnivoiceSection.panel, indexTTSSection.panel);
|
||
updateVisiblePanel();
|
||
|
||
return {
|
||
inlineEditSection: container,
|
||
inlineTagControls: {
|
||
inlineEngineSelect,
|
||
step: stepSection.controls,
|
||
higgs: higgsSection.controls,
|
||
cosy: cosySection.controls,
|
||
omnivoice: omnivoiceSection.controls,
|
||
indexTTS: indexTTSSection.controls,
|
||
},
|
||
updateInlineEnginePanelVisibility: updateVisiblePanel,
|
||
};
|
||
}
|