mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Revamp voice mixer UI with new layout and enhanced voice management features
This commit is contained in:
+191
-39
@@ -509,80 +509,223 @@ progress.progress::-moz-progress-bar {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-editor__grid {
|
.voice-editor__canvas {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr);
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
max-height: 60vh;
|
align-items: start;
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card {
|
.voice-available,
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
.voice-mix {
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(15, 23, 42, 0.33);
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
transition: border 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card:hover {
|
.voice-available__header,
|
||||||
border-color: var(--accent);
|
.voice-mix__header {
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-card__header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__toggle {
|
.voice-available__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__card {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(15, 23, 42, 0.33);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: space-between;
|
||||||
cursor: pointer;
|
gap: 0.75rem;
|
||||||
|
cursor: grab;
|
||||||
|
transition: border 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__checkbox {
|
.voice-available__card:focus-visible {
|
||||||
width: 1rem;
|
outline: 2px solid var(--accent);
|
||||||
height: 1rem;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__name {
|
.voice-available__card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__card.is-active {
|
||||||
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__meta {
|
.voice-available__meta {
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__value {
|
.voice-available__add {
|
||||||
margin-left: auto;
|
border: none;
|
||||||
font-size: 0.8rem;
|
border-radius: 12px;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__body {
|
.voice-available__add:hover:not(:disabled) {
|
||||||
|
background: rgba(56, 189, 248, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__add:disabled {
|
||||||
|
cursor: default;
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-mix__dropzone {
|
||||||
|
border: 2px dashed rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
min-height: 260px;
|
||||||
|
background: rgba(15, 23, 42, 0.25);
|
||||||
|
transition: border 0.2s ease, background 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-mix__dropzone.is-hovered {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-mix__empty {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-mix__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-voice {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-voice__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__slider {
|
.mix-voice__info {
|
||||||
flex: 1;
|
display: flex;
|
||||||
accent-color: var(--accent);
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-card__number {
|
.mix-voice__title {
|
||||||
width: 4.5rem;
|
font-weight: 600;
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
|
.mix-voice__meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-voice__weight {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-voice__remove {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(248, 113, 113, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-voice__remove:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-slider {
|
||||||
|
width: 100%;
|
||||||
|
appearance: none;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.25);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-slider:focus-visible {
|
||||||
|
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mix-slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-editor__actions {
|
.voice-editor__actions {
|
||||||
@@ -651,4 +794,13 @@ progress.progress::-moz-progress-bar {
|
|||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-editor__canvas {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__list {
|
||||||
|
max-height: none;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+347
-223
@@ -1,13 +1,16 @@
|
|||||||
const setupVoiceMixer = () => {
|
const setupVoiceMixer = () => {
|
||||||
const data = window.ABOGEN_VOICE_MIXER_DATA || {};
|
const data = window.ABOGEN_VOICE_MIXER_DATA || {};
|
||||||
const languages = data.languages || {};
|
const languages = data.languages || {};
|
||||||
const voiceCatalog = data.voice_catalog || [];
|
const voiceCatalog = Array.isArray(data.voice_catalog) ? data.voice_catalog : [];
|
||||||
const samples = data.sample_voice_texts || {};
|
const samples = data.sample_voice_texts || {};
|
||||||
let profiles = data.voice_profiles_data || {};
|
let profiles = data.voice_profiles_data || {};
|
||||||
|
|
||||||
const app = document.getElementById("voice-mixer-app");
|
const app = document.getElementById("voice-mixer-app");
|
||||||
|
if (!app) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const profileListEl = app.querySelector('[data-role="profile-list"]');
|
const profileListEl = app.querySelector('[data-role="profile-list"]');
|
||||||
const voiceGridEl = app.querySelector('[data-role="voice-grid"]');
|
|
||||||
const statusEl = app.querySelector('[data-role="status"]');
|
const statusEl = app.querySelector('[data-role="status"]');
|
||||||
const saveBtn = app.querySelector('[data-role="save-profile"]');
|
const saveBtn = app.querySelector('[data-role="save-profile"]');
|
||||||
const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]');
|
const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]');
|
||||||
@@ -22,18 +25,25 @@ const setupVoiceMixer = () => {
|
|||||||
const languageSelect = document.getElementById("profile-language");
|
const languageSelect = document.getElementById("profile-language");
|
||||||
const speedInput = document.getElementById("preview-speed");
|
const speedInput = document.getElementById("preview-speed");
|
||||||
const importInput = document.getElementById("voice-import-input");
|
const importInput = document.getElementById("voice-import-input");
|
||||||
const headerActions = document.querySelector('.voice-mixer__header-actions');
|
const headerActions = document.querySelector(".voice-mixer__header-actions");
|
||||||
|
const availableListEl = app.querySelector('[data-role="available-voices"]');
|
||||||
|
const selectedListEl = app.querySelector('[data-role="selected-voices"]');
|
||||||
|
const dropzoneEl = app.querySelector('[data-role="dropzone"]');
|
||||||
|
const emptyStateEl = app.querySelector('[data-role="mix-empty"]');
|
||||||
|
|
||||||
if (!app) {
|
if (!profileListEl || !availableListEl || !selectedListEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!voiceCatalog.length) {
|
const voiceLookup = new Map();
|
||||||
if (profileListEl) {
|
voiceCatalog.forEach((voice) => {
|
||||||
profileListEl.innerHTML = "<p class=\"tag\">No voices available.</p>";
|
if (voice && voice.id) {
|
||||||
|
voiceLookup.set(voice.id, voice);
|
||||||
}
|
}
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
const availableCards = new Map();
|
||||||
|
const selectedControls = new Map();
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
selectedProfile: null,
|
selectedProfile: null,
|
||||||
@@ -47,11 +57,25 @@ const setupVoiceMixer = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const voiceControls = new Map();
|
|
||||||
let statusTimeout = null;
|
let statusTimeout = null;
|
||||||
|
|
||||||
const voiceGenderIcon = (gender) => (gender === "Female" ? "♀" : gender === "Male" ? "♂" : "•");
|
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||||
const voiceLanguageLabel = (code) => languages[code] || code.toUpperCase();
|
const formatWeight = (value) => value.toFixed(2);
|
||||||
|
|
||||||
|
const setSliderFill = (slider, weight) => {
|
||||||
|
const percent = Math.round(clamp(weight, 0, 1) * 100);
|
||||||
|
slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const voiceGenderIcon = (gender) => {
|
||||||
|
if (!gender) return "•";
|
||||||
|
const initial = gender[0].toLowerCase();
|
||||||
|
if (initial === "f") return "♀";
|
||||||
|
if (initial === "m") return "♂";
|
||||||
|
return "•";
|
||||||
|
};
|
||||||
|
|
||||||
|
const voiceLanguageLabel = (code) => languages[code] || code?.toUpperCase() || "";
|
||||||
|
|
||||||
const clearStatus = () => {
|
const clearStatus = () => {
|
||||||
if (statusTimeout) {
|
if (statusTimeout) {
|
||||||
@@ -76,10 +100,6 @@ const setupVoiceMixer = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
||||||
|
|
||||||
const formatWeight = (value) => value.toFixed(2);
|
|
||||||
|
|
||||||
const mixTotal = () => {
|
const mixTotal = () => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
state.draft.voices.forEach((weight) => {
|
state.draft.voices.forEach((weight) => {
|
||||||
@@ -88,16 +108,6 @@ const setupVoiceMixer = () => {
|
|||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateActionButtons = () => {
|
|
||||||
const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]);
|
|
||||||
if (duplicateBtn) {
|
|
||||||
duplicateBtn.disabled = !hasSelection;
|
|
||||||
}
|
|
||||||
if (deleteBtn) {
|
|
||||||
deleteBtn.disabled = !hasSelection;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMixSummary = () => {
|
const updateMixSummary = () => {
|
||||||
if (mixTotalEl) {
|
if (mixTotalEl) {
|
||||||
mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`;
|
mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`;
|
||||||
@@ -127,6 +137,213 @@ const setupVoiceMixer = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureEmptyState = () => {
|
||||||
|
if (!emptyStateEl) return;
|
||||||
|
emptyStateEl.hidden = state.draft.voices.size > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAvailableState = () => {
|
||||||
|
availableCards.forEach(({ card, addButton }, voiceId) => {
|
||||||
|
const isActive = state.draft.voices.has(voiceId);
|
||||||
|
card.classList.toggle("is-active", isActive);
|
||||||
|
if (addButton) {
|
||||||
|
addButton.disabled = isActive;
|
||||||
|
addButton.textContent = isActive ? "Added" : "Add";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSliderFocus = (voiceId) => {
|
||||||
|
const control = selectedControls.get(voiceId);
|
||||||
|
if (control?.slider) {
|
||||||
|
control.slider.focus({ preventScroll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedVoices = () => {
|
||||||
|
selectedControls.clear();
|
||||||
|
selectedListEl.innerHTML = "";
|
||||||
|
|
||||||
|
state.draft.voices.forEach((weight, voiceId) => {
|
||||||
|
const meta = voiceLookup.get(voiceId) || {};
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "mix-voice";
|
||||||
|
card.dataset.voiceId = voiceId;
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "mix-voice__header";
|
||||||
|
|
||||||
|
const titleWrap = document.createElement("div");
|
||||||
|
titleWrap.className = "mix-voice__info";
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "mix-voice__title";
|
||||||
|
title.textContent = meta.display_name || meta.name || voiceId;
|
||||||
|
|
||||||
|
const metaLabel = document.createElement("div");
|
||||||
|
metaLabel.className = "mix-voice__meta";
|
||||||
|
const languageCode = meta.language || voiceId.charAt(0) || "a";
|
||||||
|
metaLabel.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(meta.gender)}`;
|
||||||
|
|
||||||
|
titleWrap.appendChild(title);
|
||||||
|
titleWrap.appendChild(metaLabel);
|
||||||
|
|
||||||
|
const weightLabel = document.createElement("span");
|
||||||
|
weightLabel.className = "mix-voice__weight";
|
||||||
|
weightLabel.textContent = formatWeight(weight);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "mix-voice__remove";
|
||||||
|
removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from mix`);
|
||||||
|
removeBtn.innerHTML = "×";
|
||||||
|
removeBtn.addEventListener("click", () => {
|
||||||
|
state.draft.voices.delete(voiceId);
|
||||||
|
renderSelectedVoices();
|
||||||
|
updateAvailableState();
|
||||||
|
updateMixSummary();
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
|
||||||
|
header.appendChild(titleWrap);
|
||||||
|
header.appendChild(weightLabel);
|
||||||
|
header.appendChild(removeBtn);
|
||||||
|
|
||||||
|
const slider = document.createElement("input");
|
||||||
|
slider.type = "range";
|
||||||
|
slider.min = "0";
|
||||||
|
slider.max = "100";
|
||||||
|
slider.step = "1";
|
||||||
|
slider.className = "mix-slider";
|
||||||
|
slider.value = String(Math.round(clamp(weight, 0, 1) * 100));
|
||||||
|
setSliderFill(slider, weight);
|
||||||
|
slider.addEventListener("input", () => {
|
||||||
|
const value = clamp(Number(slider.value) / 100, 0, 1);
|
||||||
|
state.draft.voices.set(voiceId, value);
|
||||||
|
weightLabel.textContent = formatWeight(value);
|
||||||
|
setSliderFill(slider, value);
|
||||||
|
updateMixSummary();
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(header);
|
||||||
|
card.appendChild(slider);
|
||||||
|
selectedListEl.appendChild(card);
|
||||||
|
|
||||||
|
selectedControls.set(voiceId, { slider, weightLabel });
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureEmptyState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAvailableVoices = () => {
|
||||||
|
availableCards.clear();
|
||||||
|
availableListEl.innerHTML = "";
|
||||||
|
|
||||||
|
const sortedVoices = voiceCatalog
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id));
|
||||||
|
|
||||||
|
sortedVoices.forEach((voice) => {
|
||||||
|
if (!voice?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "voice-available__card";
|
||||||
|
card.draggable = true;
|
||||||
|
card.dataset.voiceId = voice.id;
|
||||||
|
card.tabIndex = 0;
|
||||||
|
|
||||||
|
card.addEventListener("dragstart", (event) => {
|
||||||
|
card.classList.add("is-dragging");
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData("text/plain", voice.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("dragend", () => {
|
||||||
|
card.classList.remove("is-dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("dblclick", () => {
|
||||||
|
addVoiceToDraft(voice.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
addVoiceToDraft(voice.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "voice-available__info";
|
||||||
|
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "voice-available__name";
|
||||||
|
name.textContent = voice.display_name || voice.id;
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "voice-available__meta";
|
||||||
|
const languageCode = voice.language || voice.id.charAt(0) || "a";
|
||||||
|
meta.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(voice.gender)}`;
|
||||||
|
|
||||||
|
info.appendChild(name);
|
||||||
|
info.appendChild(meta);
|
||||||
|
|
||||||
|
const addButton = document.createElement("button");
|
||||||
|
addButton.type = "button";
|
||||||
|
addButton.className = "voice-available__add";
|
||||||
|
addButton.textContent = "Add";
|
||||||
|
addButton.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
addVoiceToDraft(voice.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(info);
|
||||||
|
card.appendChild(addButton);
|
||||||
|
availableListEl.appendChild(card);
|
||||||
|
|
||||||
|
availableCards.set(voice.id, { card, addButton });
|
||||||
|
});
|
||||||
|
|
||||||
|
updateAvailableState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addVoiceToDraft = (voiceId, weight = 0.6) => {
|
||||||
|
if (!voiceLookup.has(voiceId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.draft.voices.has(voiceId)) {
|
||||||
|
setSliderFocus(voiceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.draft.voices.set(voiceId, clamp(weight, 0.05, 1));
|
||||||
|
renderSelectedVoices();
|
||||||
|
updateAvailableState();
|
||||||
|
updateMixSummary();
|
||||||
|
markDirty();
|
||||||
|
setSliderFocus(voiceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProfilePayload = () =>
|
||||||
|
Array.from(state.draft.voices.entries()).map(([voiceId, weight]) => ({
|
||||||
|
id: voiceId,
|
||||||
|
weight,
|
||||||
|
enabled: weight > 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateActionButtons = () => {
|
||||||
|
const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]);
|
||||||
|
if (duplicateBtn) {
|
||||||
|
duplicateBtn.disabled = !hasSelection;
|
||||||
|
}
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.disabled = !hasSelection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const applyDraftToControls = () => {
|
const applyDraftToControls = () => {
|
||||||
if (nameInput) {
|
if (nameInput) {
|
||||||
nameInput.value = state.draft.name || "";
|
nameInput.value = state.draft.name || "";
|
||||||
@@ -134,213 +351,23 @@ const setupVoiceMixer = () => {
|
|||||||
if (languageSelect) {
|
if (languageSelect) {
|
||||||
languageSelect.value = state.draft.language || "a";
|
languageSelect.value = state.draft.language || "a";
|
||||||
}
|
}
|
||||||
|
renderSelectedVoices();
|
||||||
voiceControls.forEach((control, voiceId) => {
|
|
||||||
const weight = state.draft.voices.get(voiceId) || 0;
|
|
||||||
const enabled = weight > 0;
|
|
||||||
control.checkbox.checked = enabled;
|
|
||||||
control.slider.disabled = !enabled;
|
|
||||||
control.number.disabled = !enabled;
|
|
||||||
control.slider.value = String(Math.round(weight * 100));
|
|
||||||
control.number.value = formatWeight(enabled ? weight : 0);
|
|
||||||
control.weightLabel.textContent = `${formatWeight(weight)}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
updateMixSummary();
|
updateMixSummary();
|
||||||
resetDirty();
|
updateAvailableState();
|
||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
};
|
resetDirty();
|
||||||
|
|
||||||
const setVoiceWeight = (voiceId, weight, enabled) => {
|
|
||||||
const normalized = enabled ? clamp(weight, 0, 1) : 0;
|
|
||||||
if (normalized > 0) {
|
|
||||||
state.draft.voices.set(voiceId, normalized);
|
|
||||||
} else {
|
|
||||||
state.draft.voices.delete(voiceId);
|
|
||||||
}
|
|
||||||
updateMixSummary();
|
|
||||||
markDirty();
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildVoiceCard = (voice) => {
|
|
||||||
const card = document.createElement("div");
|
|
||||||
card.className = "voice-card";
|
|
||||||
card.dataset.voiceId = voice.id;
|
|
||||||
|
|
||||||
const header = document.createElement("div");
|
|
||||||
header.className = "voice-card__header";
|
|
||||||
|
|
||||||
const toggleLabel = document.createElement("label");
|
|
||||||
toggleLabel.className = "voice-card__toggle";
|
|
||||||
|
|
||||||
const checkbox = document.createElement("input");
|
|
||||||
checkbox.type = "checkbox";
|
|
||||||
checkbox.className = "voice-card__checkbox";
|
|
||||||
toggleLabel.appendChild(checkbox);
|
|
||||||
|
|
||||||
const nameSpan = document.createElement("span");
|
|
||||||
nameSpan.className = "voice-card__name";
|
|
||||||
nameSpan.textContent = voice.display_name || voice.id;
|
|
||||||
toggleLabel.appendChild(nameSpan);
|
|
||||||
|
|
||||||
header.appendChild(toggleLabel);
|
|
||||||
|
|
||||||
const meta = document.createElement("span");
|
|
||||||
meta.className = "voice-card__meta";
|
|
||||||
meta.textContent = `${voiceLanguageLabel(voice.language)} · ${voiceGenderIcon(voice.gender)}`;
|
|
||||||
header.appendChild(meta);
|
|
||||||
|
|
||||||
const weightLabel = document.createElement("span");
|
|
||||||
weightLabel.className = "voice-card__value";
|
|
||||||
weightLabel.textContent = "0.00";
|
|
||||||
header.appendChild(weightLabel);
|
|
||||||
|
|
||||||
const body = document.createElement("div");
|
|
||||||
body.className = "voice-card__body";
|
|
||||||
|
|
||||||
const slider = document.createElement("input");
|
|
||||||
slider.type = "range";
|
|
||||||
slider.min = "0";
|
|
||||||
slider.max = "100";
|
|
||||||
slider.step = "1";
|
|
||||||
slider.value = "0";
|
|
||||||
slider.disabled = true;
|
|
||||||
slider.className = "voice-card__slider";
|
|
||||||
|
|
||||||
const number = document.createElement("input");
|
|
||||||
number.type = "number";
|
|
||||||
number.min = "0";
|
|
||||||
number.max = "1";
|
|
||||||
number.step = "0.01";
|
|
||||||
number.value = "0.00";
|
|
||||||
number.disabled = true;
|
|
||||||
number.className = "voice-card__number";
|
|
||||||
|
|
||||||
body.appendChild(slider);
|
|
||||||
body.appendChild(number);
|
|
||||||
|
|
||||||
card.appendChild(header);
|
|
||||||
card.appendChild(body);
|
|
||||||
|
|
||||||
checkbox.addEventListener("change", () => {
|
|
||||||
const enabled = checkbox.checked;
|
|
||||||
slider.disabled = !enabled;
|
|
||||||
number.disabled = !enabled;
|
|
||||||
if (!enabled) {
|
|
||||||
slider.value = "0";
|
|
||||||
number.value = "0.00";
|
|
||||||
}
|
|
||||||
const weight = enabled ? parseFloat(number.value || "0") : 0;
|
|
||||||
weightLabel.textContent = formatWeight(enabled ? weight : 0);
|
|
||||||
setVoiceWeight(voice.id, weight, enabled);
|
|
||||||
});
|
|
||||||
|
|
||||||
slider.addEventListener("input", () => {
|
|
||||||
const weight = clamp(parseInt(slider.value, 10) / 100, 0, 1);
|
|
||||||
number.value = formatWeight(weight);
|
|
||||||
weightLabel.textContent = formatWeight(weight);
|
|
||||||
if (!checkbox.checked && weight > 0) {
|
|
||||||
checkbox.checked = true;
|
|
||||||
slider.disabled = false;
|
|
||||||
number.disabled = false;
|
|
||||||
}
|
|
||||||
setVoiceWeight(voice.id, weight, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
number.addEventListener("change", () => {
|
|
||||||
const weight = clamp(parseFloat(number.value || "0"), 0, 1);
|
|
||||||
number.value = formatWeight(weight);
|
|
||||||
slider.value = String(Math.round(weight * 100));
|
|
||||||
weightLabel.textContent = formatWeight(weight);
|
|
||||||
if (!checkbox.checked && weight > 0) {
|
|
||||||
checkbox.checked = true;
|
|
||||||
slider.disabled = false;
|
|
||||||
number.disabled = false;
|
|
||||||
}
|
|
||||||
setVoiceWeight(voice.id, weight, checkbox.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
voiceControls.set(voice.id, { checkbox, slider, number, weightLabel });
|
|
||||||
return card;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildVoiceGrid = () => {
|
|
||||||
if (!voiceGridEl) return;
|
|
||||||
voiceGridEl.innerHTML = "";
|
|
||||||
voiceCatalog.forEach((voice) => {
|
|
||||||
voiceGridEl.appendChild(buildVoiceCard(voice));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSampleText = () => {
|
|
||||||
if (!previewTextEl || !languageSelect) return;
|
|
||||||
const lang = languageSelect.value || "a";
|
|
||||||
previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice.";
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectProfile = (name) => {
|
|
||||||
state.selectedProfile = name;
|
|
||||||
state.originalName = name;
|
|
||||||
const profile = profiles[name];
|
|
||||||
state.draft = {
|
|
||||||
name,
|
|
||||||
language: profile?.language || "a",
|
|
||||||
voices: new Map(),
|
|
||||||
};
|
|
||||||
if (Array.isArray(profile?.voices)) {
|
|
||||||
profile.voices.forEach((entry) => {
|
|
||||||
if (Array.isArray(entry) && entry.length >= 2) {
|
|
||||||
const [voiceId, weight] = entry;
|
|
||||||
const value = parseFloat(weight);
|
|
||||||
if (!Number.isNaN(value) && value > 0) {
|
|
||||||
state.draft.voices.set(String(voiceId), clamp(value, 0, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
applyDraftToControls();
|
|
||||||
renderProfileList();
|
|
||||||
loadSampleText();
|
|
||||||
setStatus(`Loaded profile “${name}”.`, "info", 2500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewProfile = () => {
|
|
||||||
state.selectedProfile = null;
|
|
||||||
state.originalName = null;
|
|
||||||
state.draft = {
|
|
||||||
name: "",
|
|
||||||
language: languageSelect ? languageSelect.value || "a" : "a",
|
|
||||||
voices: new Map(),
|
|
||||||
};
|
|
||||||
applyDraftToControls();
|
|
||||||
renderProfileList();
|
|
||||||
loadSampleText();
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildProfilePayload = () => {
|
|
||||||
const payload = [];
|
|
||||||
voiceControls.forEach((control, voiceId) => {
|
|
||||||
const enabled = control.checkbox.checked;
|
|
||||||
const weight = enabled ? clamp(parseFloat(control.number.value || "0"), 0, 1) : 0;
|
|
||||||
payload.push({ id: voiceId, weight, enabled });
|
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderProfileList = () => {
|
const renderProfileList = () => {
|
||||||
if (!profileListEl) return;
|
|
||||||
profileListEl.innerHTML = "";
|
profileListEl.innerHTML = "";
|
||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "voice-list__header";
|
header.className = "voice-list__header";
|
||||||
const title = document.createElement("h2");
|
const heading = document.createElement("h2");
|
||||||
title.textContent = "Saved profiles";
|
heading.textContent = "Saved profiles";
|
||||||
header.appendChild(title);
|
header.appendChild(heading);
|
||||||
profileListEl.appendChild(header);
|
profileListEl.appendChild(header);
|
||||||
|
|
||||||
const list = document.createElement("ul");
|
|
||||||
list.className = "voice-list";
|
|
||||||
|
|
||||||
const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b));
|
const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b));
|
||||||
if (!names.length) {
|
if (!names.length) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
@@ -350,6 +377,9 @@ const setupVoiceMixer = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "voice-list";
|
||||||
|
|
||||||
names.forEach((name) => {
|
names.forEach((name) => {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "voice-list__item";
|
li.className = "voice-list__item";
|
||||||
@@ -400,8 +430,48 @@ const setupVoiceMixer = () => {
|
|||||||
profileListEl.appendChild(list);
|
profileListEl.appendChild(list);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectProfile = (name) => {
|
||||||
|
state.selectedProfile = name;
|
||||||
|
state.originalName = name;
|
||||||
|
const profile = profiles[name];
|
||||||
|
state.draft = {
|
||||||
|
name,
|
||||||
|
language: profile?.language || "a",
|
||||||
|
voices: new Map(),
|
||||||
|
};
|
||||||
|
if (Array.isArray(profile?.voices)) {
|
||||||
|
profile.voices.forEach((entry) => {
|
||||||
|
if (Array.isArray(entry) && entry.length >= 2) {
|
||||||
|
const [voiceId, weight] = entry;
|
||||||
|
const value = clamp(parseFloat(weight), 0, 1);
|
||||||
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
|
state.draft.voices.set(String(voiceId), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyDraftToControls();
|
||||||
|
renderProfileList();
|
||||||
|
loadSampleText();
|
||||||
|
setStatus(`Loaded profile “${name}”.`, "info", 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewProfile = () => {
|
||||||
|
state.selectedProfile = null;
|
||||||
|
state.originalName = null;
|
||||||
|
state.draft = {
|
||||||
|
name: "",
|
||||||
|
language: languageSelect ? languageSelect.value || "a" : "a",
|
||||||
|
voices: new Map(),
|
||||||
|
};
|
||||||
|
applyDraftToControls();
|
||||||
|
renderProfileList();
|
||||||
|
loadSampleText();
|
||||||
|
};
|
||||||
|
|
||||||
const refreshProfiles = (nextProfiles, selectedName = null) => {
|
const refreshProfiles = (nextProfiles, selectedName = null) => {
|
||||||
profiles = nextProfiles || {};
|
profiles = nextProfiles || {};
|
||||||
|
renderProfileList();
|
||||||
if (selectedName && profiles[selectedName]) {
|
if (selectedName && profiles[selectedName]) {
|
||||||
selectProfile(selectedName);
|
selectProfile(selectedName);
|
||||||
} else if (state.selectedProfile && profiles[state.selectedProfile]) {
|
} else if (state.selectedProfile && profiles[state.selectedProfile]) {
|
||||||
@@ -417,6 +487,12 @@ const setupVoiceMixer = () => {
|
|||||||
updateActionButtons();
|
updateActionButtons();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSampleText = () => {
|
||||||
|
if (!previewTextEl || !languageSelect) return;
|
||||||
|
const lang = languageSelect.value || "a";
|
||||||
|
previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice.";
|
||||||
|
};
|
||||||
|
|
||||||
const withJson = async (response) => {
|
const withJson = async (response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -625,6 +701,7 @@ const setupVoiceMixer = () => {
|
|||||||
nameInput.addEventListener("input", () => {
|
nameInput.addEventListener("input", () => {
|
||||||
state.draft.name = nameInput.value;
|
state.draft.name = nameInput.value;
|
||||||
markDirty();
|
markDirty();
|
||||||
|
updateMixSummary();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,15 +731,62 @@ const setupVoiceMixer = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
buildVoiceGrid();
|
if (dropzoneEl) {
|
||||||
|
const setHover = (hovered) => {
|
||||||
|
dropzoneEl.classList.toggle("is-hovered", hovered);
|
||||||
|
};
|
||||||
|
[dropzoneEl, selectedListEl].forEach((target) => {
|
||||||
|
target.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setHover(true);
|
||||||
|
});
|
||||||
|
target.addEventListener("dragenter", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setHover(true);
|
||||||
|
});
|
||||||
|
target.addEventListener("dragleave", (event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
setHover(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
target.addEventListener("drop", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const voiceId = event.dataTransfer?.getData("text/plain");
|
||||||
|
if (voiceId) {
|
||||||
|
addVoiceToDraft(voiceId);
|
||||||
|
}
|
||||||
|
setHover(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzoneEl.addEventListener("click", () => {
|
||||||
|
const firstInactive = Array.from(availableCards.entries()).find(
|
||||||
|
([voiceId]) => !state.draft.voices.has(voiceId),
|
||||||
|
);
|
||||||
|
if (firstInactive) {
|
||||||
|
addVoiceToDraft(firstInactive[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAvailableVoices();
|
||||||
renderProfileList();
|
renderProfileList();
|
||||||
createNewProfile();
|
createNewProfile();
|
||||||
|
|
||||||
if (Object.keys(profiles).length) {
|
if (Object.keys(profiles).length) {
|
||||||
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
|
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
|
||||||
selectProfile(first);
|
selectProfile(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSampleText();
|
loadSampleText();
|
||||||
|
updateActionButtons();
|
||||||
app.dataset.state = "ready";
|
app.dataset.state = "ready";
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
if (state.previewUrl) {
|
||||||
|
URL.revokeObjectURL(state.previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -47,7 +47,25 @@
|
|||||||
<span data-role="profile-summary">Select or create a profile to begin.</span>
|
<span data-role="profile-summary">Select or create a profile to begin.</span>
|
||||||
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
|
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="voice-editor__grid" data-role="voice-grid"></div>
|
<div class="voice-editor__canvas">
|
||||||
|
<section class="voice-available">
|
||||||
|
<header class="voice-available__header">
|
||||||
|
<h2>Available voices</h2>
|
||||||
|
<p class="tag">Drag into the mixer or click Add.</p>
|
||||||
|
</header>
|
||||||
|
<div class="voice-available__list" data-role="available-voices"></div>
|
||||||
|
</section>
|
||||||
|
<section class="voice-mix">
|
||||||
|
<header class="voice-mix__header">
|
||||||
|
<h2>Your mix</h2>
|
||||||
|
<p class="tag">Drop voices below to set levels.</p>
|
||||||
|
</header>
|
||||||
|
<div class="voice-mix__dropzone" data-role="dropzone">
|
||||||
|
<p class="voice-mix__empty" data-role="mix-empty">Drag voices here or tap “Add”.</p>
|
||||||
|
<div class="voice-mix__list" data-role="selected-voices"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<div class="voice-editor__actions">
|
<div class="voice-editor__actions">
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button type="submit" class="button" data-role="save-profile" disabled>Save profile</button>
|
<button type="submit" class="button" data-role="save-profile" disabled>Save profile</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user