feat: Implement voice mixer UI and functionality

- Added new styles for the voice mixer components in styles.css.
- Updated base.html to include a block for scripts.
- Refactored voices.html to create a structured voice mixer interface with profile management features.
- Introduced voices.js to handle voice mixer logic, including profile creation, editing, and previewing.
- Implemented actions for importing and exporting voice profiles.
- Enhanced user experience with loading states and status messages.
This commit is contained in:
JB
2025-10-06 05:10:32 -07:00
parent 9ba2362528
commit b718dae1b3
6 changed files with 1457 additions and 86 deletions
+108 -1
View File
@@ -1,5 +1,8 @@
import os
import json
import os
from typing import Dict, Iterable, List, Tuple
from abogen.constants import VOICES_INTERNAL
from abogen.utils import get_user_config_path
@@ -57,3 +60,107 @@ def export_profiles(export_path):
profiles = load_profiles()
with open(export_path, "w", encoding="utf-8") as f:
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
"""Return profiles in canonical dictionary form."""
return load_profiles()
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = []
for item in entries or []:
if isinstance(item, dict):
voice = item.get("id") or item.get("voice")
weight = item.get("weight")
elif isinstance(item, (list, tuple)) and len(item) >= 2:
voice, weight = item[0], item[1]
else:
continue
if voice not in VOICES_INTERNAL:
continue
if weight is None:
continue
try:
weight_val = float(weight)
except (TypeError, ValueError):
continue
if weight_val <= 0:
continue
normalized.append((voice, weight_val))
return normalized
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
return _normalize_voice_entries(entries)
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
"""Persist a single profile after validating its data."""
name = (name or "").strip()
if not name:
raise ValueError("Profile name is required")
normalized = _normalize_voice_entries(voices)
if not normalized:
raise ValueError("At least one voice with a weight above zero is required")
if not language:
language = "a"
profiles = load_profiles()
profiles[name] = {"language": language, "voices": normalized}
save_profiles(profiles)
def remove_profile(name: str) -> None:
delete_profile(name)
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
"""Merge profiles from a dictionary structure and persist them.
Returns the list of profile names that were added or updated.
"""
if not isinstance(data, dict):
raise ValueError("Invalid profile payload")
if "abogen_voice_profiles" in data:
data = data["abogen_voice_profiles"]
if not isinstance(data, dict):
raise ValueError("Invalid profile payload")
current = load_profiles()
updated: List[str] = []
for name, entry in data.items():
if not isinstance(entry, dict):
continue
voices = _normalize_voice_entries(entry.get("voices", []))
if not voices:
continue
language = entry.get("language", "a")
if name in current and not replace_existing:
# skip duplicates unless explicit replacement is requested
continue
current[name] = {"language": language, "voices": voices}
updated.append(name)
if updated:
save_profiles(current)
return updated
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
"""Return profiles limited to the provided names for download/export."""
profiles = load_profiles()
if names is None:
subset = profiles
else:
subset = {name: profiles[name] for name in names if name in profiles}
return {"abogen_voice_profiles": subset}
+333 -29
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
import io
import json
import mimetypes
import threading
import uuid
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
from flask import (
Blueprint,
@@ -19,28 +22,67 @@ from flask import (
)
from werkzeug.utils import secure_filename
import numpy as np
import soundfile as sf
from abogen.constants import (
LANGUAGE_DESCRIPTIONS,
SAMPLE_VOICE_TEXTS,
SUBTITLE_FORMATS,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
)
from abogen.utils import calculate_text_length, clean_text
from abogen.voice_profiles import delete_profile, load_profiles, save_profiles
from abogen.utils import calculate_text_length, clean_text, load_config, load_numpy_kpipeline
from abogen.voice_profiles import (
delete_profile,
duplicate_profile,
export_profiles_payload,
import_profiles_data,
load_profiles,
normalize_voice_entries,
remove_profile,
save_profile,
save_profiles,
serialize_profiles,
)
from abogen.voice_formulas import get_new_voice
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
from .service import ConversionService, Job, JobStatus
web_bp = Blueprint("web", __name__)
api_bp = Blueprint("api", __name__)
_preview_pipeline_lock = threading.RLock()
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
def _service() -> ConversionService:
return current_app.extensions["conversion_service"]
def _build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"}
for voice_id in VOICES_INTERNAL:
prefix, _, rest = voice_id.partition("_")
language_code = prefix[0] if prefix else "a"
gender_code = prefix[1] if len(prefix) > 1 else ""
catalog.append(
{
"id": voice_id,
"language": language_code,
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
"gender": gender_map.get(gender_code, "Unknown"),
"display_name": rest.replace("_", " ").title() if rest else voice_id,
}
)
return catalog
def _template_options() -> Dict[str, Any]:
profiles = load_profiles()
profiles = serialize_profiles()
ordered_profiles = sorted(profiles.items())
return {
"languages": LANGUAGE_DESCRIPTIONS,
@@ -50,6 +92,9 @@ def _template_options() -> Dict[str, Any]:
"output_formats": SUPPORTED_SOUND_FORMATS,
"voice_profiles": ordered_profiles,
"separate_formats": ["wav", "flac", "mp3", "opus"],
"voice_catalog": _build_voice_catalog(),
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
"voice_profiles_data": profiles,
}
@@ -120,6 +165,54 @@ def _parse_voice_formula(formula: str) -> List[tuple[str, float]]:
return voices
def _sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
sanitized: List[Dict[str, Any]] = []
for entry in entries or []:
if isinstance(entry, dict):
voice_id = entry.get("id") or entry.get("voice")
if not voice_id:
continue
enabled = entry.get("enabled", True)
if not enabled:
continue
sanitized.append({"voice": voice_id, "weight": entry.get("weight")})
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
sanitized.append({"voice": entry[0], "weight": entry[1]})
return sanitized
def _pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0]
if not voices:
return None
total = sum(weight for _, weight in voices)
if total <= 0:
return None
def _format_value(value: float) -> str:
normalized = value / total if total else 0.0
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
return "+".join(parts)
def _profiles_payload() -> Dict[str, Any]:
return {"profiles": serialize_profiles()}
def _get_preview_pipeline(language: str, device: str):
key = (language, device)
with _preview_pipeline_lock:
pipeline = _preview_pipelines.get(key)
if pipeline is not None:
return pipeline
_, KPipeline = load_numpy_kpipeline()
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
_preview_pipelines[key] = pipeline
return pipeline
@web_bp.app_template_filter("datetimeformat")
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
if not value:
@@ -142,22 +235,8 @@ def index() -> str:
@web_bp.get("/voices")
def voice_profiles_page() -> str:
profiles = load_profiles()
rendered = []
for name, data in sorted(profiles.items()):
rendered.append(
{
"name": name,
"language": data.get("language", "a"),
"formula": _formula_from_profile(data) or "",
}
)
return render_template(
"voices.html",
profiles=rendered,
languages=LANGUAGE_DESCRIPTIONS,
voices=VOICES_INTERNAL,
)
options = _template_options()
return render_template("voices.html", options=options)
@web_bp.post("/voices")
@@ -180,6 +259,223 @@ def delete_voice_profile_route(name: str) -> Response:
return redirect(url_for("web.voice_profiles_page"))
@api_bp.get("/voice-profiles")
def api_list_voice_profiles() -> Response:
return jsonify(_profiles_payload())
@api_bp.post("/voice-profiles")
def api_save_voice_profile() -> Response:
payload = request.get_json(force=True, silent=False)
name = (payload.get("name") or "").strip()
if not name:
abort(400, "Profile name is required")
original = (payload.get("originalName") or "").strip()
language = (payload.get("language") or "a").strip() or "a"
formula = (payload.get("formula") or "").strip()
try:
if formula:
voices = _parse_voice_formula(formula)
else:
voices_raw = _sanitize_voice_entries(payload.get("voices", []))
voices = normalize_voice_entries(voices_raw)
if not voices:
raise ValueError("At least one voice must be enabled with a weight above zero")
save_profile(name, language=language, voices=voices)
if original and original != name:
remove_profile(original)
except ValueError as exc:
abort(400, str(exc))
return jsonify({"ok": True, "profile": name, **_profiles_payload()})
@api_bp.delete("/voice-profiles/<name>")
def api_delete_voice_profile(name: str) -> Response:
remove_profile(name)
return jsonify({"ok": True, **_profiles_payload()})
@api_bp.post("/voice-profiles/<name>/duplicate")
def api_duplicate_voice_profile(name: str) -> Response:
payload = request.get_json(silent=True) or {}
new_name = (payload.get("name") or payload.get("new_name") or "").strip()
if not new_name:
abort(400, "Duplicate name is required")
duplicate_profile(name, new_name)
return jsonify({"ok": True, "profile": new_name, **_profiles_payload()})
@api_bp.post("/voice-profiles/import")
def api_import_voice_profiles() -> Response:
replace = False
data: Optional[Dict[str, Any]] = None
if "file" in request.files:
file_storage = request.files["file"]
try:
data = json.load(file_storage)
except Exception as exc: # pragma: no cover - defensive
abort(400, f"Invalid JSON file: {exc}")
replace = request.form.get("replace_existing") in {"true", "1", "on"}
else:
payload = request.get_json(force=True, silent=False)
replace = bool(payload.get("replace_existing", False))
data = payload.get("profiles") or payload.get("data") or payload
if not isinstance(data, dict):
data = None
if data is None:
abort(400, "Import payload must be a dictionary")
data_dict = cast(Dict[str, Any], data)
imported: List[str] = []
try:
imported = import_profiles_data(data_dict, replace_existing=replace)
except ValueError as exc:
abort(400, str(exc))
return jsonify({"ok": True, "imported": imported, **_profiles_payload()})
@api_bp.get("/voice-profiles/export")
def api_export_voice_profiles() -> Response:
names_param = request.args.get("names")
names = None
if names_param:
names = [name.strip() for name in names_param.split(",") if name.strip()]
payload = export_profiles_payload(names)
buffer = io.BytesIO()
buffer.write(json.dumps(payload, indent=2).encode("utf-8"))
buffer.seek(0)
filename = request.args.get("filename") or "voice_profiles.json"
return send_file(
buffer,
mimetype="application/json",
as_attachment=True,
download_name=filename,
)
@api_bp.post("/voice-profiles/preview")
def api_preview_voice_mix() -> Response:
payload = request.get_json(force=True, silent=False)
language = (payload.get("language") or "a").strip() or "a"
text = (payload.get("text") or "").strip()
speed = float(payload.get("speed", 1.0) or 1.0)
max_seconds = float(payload.get("max_seconds", 12.0) or 12.0)
profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip()
formula = (payload.get("formula") or "").strip()
voices: List[Tuple[str, float]] = []
if profile_name:
profiles = load_profiles()
entry = profiles.get(profile_name)
if entry is None:
abort(404, "Profile not found")
if not isinstance(entry, dict):
abort(400, "Profile data is invalid")
entry_dict = cast(Dict[str, Any], entry)
language = entry_dict.get("language", language)
profile_voices = entry_dict.get("voices", [])
for item in profile_voices:
if isinstance(item, (list, tuple)) and len(item) >= 2:
try:
voices.append((str(item[0]), float(item[1])))
except (TypeError, ValueError):
continue
else:
try:
if formula:
voices = _parse_voice_formula(formula)
else:
voices_raw = _sanitize_voice_entries(payload.get("voices", []))
voices = normalize_voice_entries(voices_raw)
except ValueError as exc:
abort(400, str(exc))
if not voices:
abort(400, "At least one voice must be provided for preview")
if not text:
text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice."))
cfg = load_config()
use_gpu_cfg = bool(cfg.get("use_gpu", True))
use_gpu = use_gpu_cfg if payload.get("use_gpu") is None else bool(payload.get("use_gpu"))
device = "cpu"
if use_gpu:
try:
device = _select_device()
except Exception: # pragma: no cover - fallback
device = "cpu"
use_gpu = False
pipeline: Any = None
try:
pipeline = _get_preview_pipeline(language, device)
except Exception as exc: # pragma: no cover - defensive guard
abort(500, f"Failed to initialise preview pipeline: {exc}")
if pipeline is None: # pragma: no cover - defensive double-check
abort(500, "Preview pipeline initialisation failed")
voice_choice: Any = None
if len(voices) == 1:
voice_choice = voices[0][0]
else:
formula_value = _pairs_to_formula(voices)
if not formula_value:
abort(400, "Invalid voice weights provided")
try:
voice_choice = get_new_voice(pipeline, formula_value, use_gpu)
except ValueError as exc:
abort(400, str(exc))
if voice_choice is None:
abort(400, "Unable to resolve voice selection")
segments = pipeline(
text,
voice=voice_choice,
speed=speed,
split_pattern=SPLIT_PATTERN,
)
audio_chunks: List[np.ndarray] = []
accumulated = 0
max_samples = int(max_seconds * SAMPLE_RATE)
for segment in segments:
graphemes = segment.graphemes.strip()
if not graphemes:
continue
audio = _to_float32(segment.audio)
if audio.size == 0:
continue
remaining = max_samples - accumulated
if remaining <= 0:
break
if audio.shape[0] > remaining:
audio = audio[:remaining]
audio_chunks.append(audio)
accumulated += audio.shape[0]
if accumulated >= max_samples:
break
if not audio_chunks:
abort(500, "Preview could not be generated")
audio_data = np.concatenate(audio_chunks)
buffer = io.BytesIO()
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
buffer.seek(0)
response = send_file(
buffer,
mimetype="audio/wav",
as_attachment=False,
download_name="voice_preview.wav",
)
response.headers["Cache-Control"] = "no-store"
return response
@web_bp.post("/jobs")
def enqueue_job() -> Response:
service = _service()
@@ -298,19 +594,23 @@ def delete_job(job_id: str) -> Response:
@web_bp.get("/jobs/<job_id>/download")
def download_job(job_id: str) -> Response:
job = _service().get_job(job_id)
if not job or job.status != JobStatus.COMPLETED:
if job is None or job.status != JobStatus.COMPLETED:
abort(404)
if not job.result.audio_path:
result = getattr(job, "result", None)
audio_path = getattr(result, "audio_path", None)
if audio_path is None:
abort(404)
path = job.result.audio_path
if not path.exists():
if not isinstance(audio_path, Path): # pragma: no cover - sanity guard
abort(404)
mime_type, _ = mimetypes.guess_type(str(path))
audio_path_path = cast(Path, audio_path)
if not audio_path_path.exists():
abort(404)
mime_type, _ = mimetypes.guess_type(str(audio_path_path))
return send_file(
path,
audio_path_path,
mimetype=mime_type or "application/octet-stream",
as_attachment=True,
download_name=path.name,
download_name=audio_path_path.name,
)
@@ -330,6 +630,10 @@ def job_logs_partial(job_id: str) -> str:
@api_bp.get("/jobs/<job_id>")
def job_json(job_id: str) -> Response:
job = _service().get_job(job_id)
if not job:
if job is None:
abort(404)
return jsonify(job.as_dict())
if not isinstance(job, Job): # pragma: no cover - defensive guard
abort(404)
job_obj = cast(Job, job)
payload = job_obj.as_dict()
return jsonify(payload)
+272
View File
@@ -380,3 +380,275 @@ progress.progress::-moz-progress-bar {
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
border-radius: 999px;
}
.voice-mixer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.voice-mixer__header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.voice-mixer__layout {
display: grid;
grid-template-columns: minmax(240px, 280px) 1fr;
gap: 2rem;
align-items: start;
}
.voice-mixer__profiles {
display: flex;
flex-direction: column;
gap: 1rem;
border-right: 1px solid var(--panel-border);
padding-right: 1.5rem;
}
.voice-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.voice-list__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.voice-list__item {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: rgba(15, 23, 42, 0.35);
transition: border 0.2s ease, background 0.2s ease;
}
.voice-list__item.is-selected {
border-color: var(--accent);
background: rgba(56, 189, 248, 0.1);
}
.voice-list__select {
all: unset;
display: flex;
flex-direction: column;
gap: 0.25rem;
cursor: pointer;
}
.voice-list__select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.voice-list__name {
font-weight: 600;
}
.voice-list__meta {
font-size: 0.78rem;
color: var(--muted);
}
.voice-list__actions {
display: flex;
gap: 0.5rem;
}
.voice-list__action {
border: none;
border-radius: 999px;
padding: 0.35rem 0.65rem;
font-size: 0.75rem;
cursor: pointer;
background: rgba(148, 163, 184, 0.15);
color: var(--muted);
transition: background 0.2s ease, color 0.2s ease;
}
.voice-list__action:hover {
background: rgba(56, 189, 248, 0.18);
color: var(--accent);
}
.voice-list__action--danger:hover {
background: rgba(248, 113, 113, 0.2);
color: var(--danger);
}
.voice-editor {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.voice-editor__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.voice-editor__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.voice-editor__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
max-height: 60vh;
overflow-y: auto;
padding-right: 0.5rem;
}
.voice-card {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 18px;
background: rgba(15, 23, 42, 0.33);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border 0.2s ease, transform 0.2s ease;
}
.voice-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.voice-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.voice-card__toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.voice-card__checkbox {
width: 1rem;
height: 1rem;
}
.voice-card__name {
font-weight: 600;
}
.voice-card__meta {
font-size: 0.8rem;
color: var(--muted);
}
.voice-card__value {
margin-left: auto;
font-size: 0.8rem;
color: var(--accent);
}
.voice-card__body {
display: flex;
align-items: center;
gap: 0.75rem;
}
.voice-card__slider {
flex: 1;
accent-color: var(--accent);
}
.voice-card__number {
width: 4.5rem;
text-align: center;
}
.voice-editor__actions {
display: grid;
gap: 1.25rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.voice-preview {
display: grid;
gap: 0.75rem;
}
.voice-preview__controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.voice-preview audio {
width: 100%;
border-radius: 12px;
background: rgba(15, 23, 42, 0.45);
}
.voice-status {
min-height: 1.2rem;
font-size: 0.9rem;
}
.voice-status--info {
color: var(--accent);
}
.voice-status--success {
color: var(--success);
}
.voice-status--warning {
color: var(--warning);
}
.voice-status--danger {
color: var(--danger);
}
.voice-mixer[data-state="loading"] .voice-editor {
opacity: 0.6;
pointer-events: none;
}
@media (max-width: 980px) {
.voice-mixer__layout {
grid-template-columns: 1fr;
}
.voice-mixer__profiles {
border-right: none;
border-bottom: 1px solid var(--panel-border);
padding-right: 0;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
}
+672
View File
@@ -0,0 +1,672 @@
const setupVoiceMixer = () => {
const data = window.ABOGEN_VOICE_MIXER_DATA || {};
const languages = data.languages || {};
const voiceCatalog = data.voice_catalog || [];
const samples = data.sample_voice_texts || {};
let profiles = data.voice_profiles_data || {};
const app = document.getElementById("voice-mixer-app");
const profileListEl = app.querySelector('[data-role="profile-list"]');
const voiceGridEl = app.querySelector('[data-role="voice-grid"]');
const statusEl = app.querySelector('[data-role="status"]');
const saveBtn = app.querySelector('[data-role="save-profile"]');
const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]');
const deleteBtn = app.querySelector('[data-role="delete-profile"]');
const previewBtn = app.querySelector('[data-role="preview-button"]');
const loadSampleBtn = app.querySelector('[data-role="load-sample"]');
const previewTextEl = app.querySelector('[data-role="preview-text"]');
const previewAudio = app.querySelector('[data-role="preview-audio"]');
const profileSummaryEl = app.querySelector('[data-role="profile-summary"]');
const mixTotalEl = app.querySelector('[data-role="mix-total"]');
const nameInput = document.getElementById("profile-name");
const languageSelect = document.getElementById("profile-language");
const speedInput = document.getElementById("preview-speed");
const importInput = document.getElementById("voice-import-input");
const headerActions = document.querySelector('.voice-mixer__header-actions');
if (!app) {
return;
}
if (!voiceCatalog.length) {
if (profileListEl) {
profileListEl.innerHTML = "<p class=\"tag\">No voices available.</p>";
}
return;
}
const state = {
selectedProfile: null,
originalName: null,
dirty: false,
previewUrl: null,
draft: {
name: "",
language: "a",
voices: new Map(),
},
};
const voiceControls = new Map();
let statusTimeout = null;
const voiceGenderIcon = (gender) => (gender === "Female" ? "♀" : gender === "Male" ? "♂" : "•");
const voiceLanguageLabel = (code) => languages[code] || code.toUpperCase();
const clearStatus = () => {
if (statusTimeout) {
clearTimeout(statusTimeout);
statusTimeout = null;
}
if (statusEl) {
statusEl.textContent = "";
statusEl.className = "voice-status";
}
};
const setStatus = (message, tone = "info", timeout = 4000) => {
if (!statusEl) return;
clearStatus();
statusEl.textContent = message;
statusEl.className = `voice-status voice-status--${tone}`;
if (timeout > 0) {
statusTimeout = window.setTimeout(() => {
clearStatus();
}, timeout);
}
};
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const formatWeight = (value) => value.toFixed(2);
const mixTotal = () => {
let total = 0;
state.draft.voices.forEach((weight) => {
total += weight;
});
return total;
};
const updateActionButtons = () => {
const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]);
if (duplicateBtn) {
duplicateBtn.disabled = !hasSelection;
}
if (deleteBtn) {
deleteBtn.disabled = !hasSelection;
}
};
const updateMixSummary = () => {
if (mixTotalEl) {
mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`;
}
if (profileSummaryEl) {
const voiceCount = state.draft.voices.size;
if (!state.draft.name && !voiceCount) {
profileSummaryEl.textContent = "Select or create a profile to begin.";
} else {
const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved profile";
profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`;
}
}
};
const markDirty = () => {
state.dirty = true;
if (saveBtn) {
saveBtn.disabled = false;
}
};
const resetDirty = () => {
state.dirty = false;
if (saveBtn) {
saveBtn.disabled = true;
}
};
const applyDraftToControls = () => {
if (nameInput) {
nameInput.value = state.draft.name || "";
}
if (languageSelect) {
languageSelect.value = state.draft.language || "a";
}
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();
resetDirty();
updateActionButtons();
};
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 = () => {
if (!profileListEl) return;
profileListEl.innerHTML = "";
const header = document.createElement("div");
header.className = "voice-list__header";
const title = document.createElement("h2");
title.textContent = "Saved profiles";
header.appendChild(title);
profileListEl.appendChild(header);
const list = document.createElement("ul");
list.className = "voice-list";
const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b));
if (!names.length) {
const empty = document.createElement("p");
empty.className = "tag";
empty.textContent = "No profiles yet. Create one on the right.";
profileListEl.appendChild(empty);
return;
}
names.forEach((name) => {
const li = document.createElement("li");
li.className = "voice-list__item";
if (state.selectedProfile === name) {
li.classList.add("is-selected");
}
const selectBtn = document.createElement("button");
selectBtn.type = "button";
selectBtn.className = "voice-list__select";
selectBtn.dataset.name = name;
const profile = profiles[name] || {};
selectBtn.innerHTML = `
<span class="voice-list__name">${name}</span>
<span class="voice-list__meta">${voiceLanguageLabel(profile.language || "a")}</span>
`;
selectBtn.addEventListener("click", () => selectProfile(name));
const actions = document.createElement("div");
actions.className = "voice-list__actions";
const duplicateAction = document.createElement("button");
duplicateAction.type = "button";
duplicateAction.className = "voice-list__action";
duplicateAction.textContent = "Duplicate";
duplicateAction.addEventListener("click", (event) => {
event.stopPropagation();
runDuplicate(name);
});
const deleteAction = document.createElement("button");
deleteAction.type = "button";
deleteAction.className = "voice-list__action voice-list__action--danger";
deleteAction.textContent = "Delete";
deleteAction.addEventListener("click", (event) => {
event.stopPropagation();
runDelete(name);
});
actions.appendChild(duplicateAction);
actions.appendChild(deleteAction);
li.appendChild(selectBtn);
li.appendChild(actions);
list.appendChild(li);
});
profileListEl.appendChild(list);
};
const refreshProfiles = (nextProfiles, selectedName = null) => {
profiles = nextProfiles || {};
if (selectedName && profiles[selectedName]) {
selectProfile(selectedName);
} else if (state.selectedProfile && profiles[state.selectedProfile]) {
selectProfile(state.selectedProfile);
} else {
const names = Object.keys(profiles);
if (names.length) {
selectProfile(names[0]);
} else {
createNewProfile();
}
}
updateActionButtons();
};
const withJson = async (response) => {
if (response.ok) {
return response.json();
}
let message = "Unexpected error";
try {
const data = await response.json();
message = data.error || data.message || message;
} catch (err) {
message = await response.text();
}
throw new Error(message);
};
const runSave = async () => {
if (!nameInput) return;
const name = nameInput.value.trim();
if (!name) {
setStatus("Give your profile a name first.", "warning");
return;
}
const payload = {
name,
originalName: state.originalName,
language: languageSelect ? languageSelect.value : "a",
voices: buildProfilePayload(),
};
try {
const response = await fetch("/api/voice-profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await withJson(response);
refreshProfiles(result.profiles, result.profile);
resetDirty();
setStatus(`Saved profile “${result.profile}”.`, "success");
} catch (error) {
setStatus(error.message || "Failed to save profile", "danger", 7000);
}
};
const runDelete = async (targetName = null) => {
const name = targetName || state.selectedProfile;
if (!name) {
setStatus("Select a profile to delete.", "warning");
return;
}
const confirmed = window.confirm(`Delete profile “${name}”?`);
if (!confirmed) return;
try {
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, {
method: "DELETE",
});
const result = await withJson(response);
refreshProfiles(result.profiles);
setStatus(`Deleted profile “${name}”.`, "info");
} catch (error) {
setStatus(error.message || "Failed to delete profile", "danger", 7000);
}
};
const runDuplicate = async (targetName = null) => {
const name = targetName || state.selectedProfile;
if (!name) {
setStatus("Select a profile to duplicate.", "warning");
return;
}
const newName = window.prompt("Duplicate profile as…", `${name} copy`);
if (!newName) return;
try {
const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName }),
});
const result = await withJson(response);
refreshProfiles(result.profiles, result.profile);
setStatus(`Duplicated to “${result.profile}”.`, "success");
} catch (error) {
setStatus(error.message || "Failed to duplicate profile", "danger", 7000);
}
};
const runImport = async (file) => {
try {
const text = await file.text();
const parsed = JSON.parse(text);
const replace = window.confirm("Replace existing profiles if duplicates are found?");
const response = await fetch("/api/voice-profiles/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: parsed, replace_existing: replace }),
});
const result = await withJson(response);
refreshProfiles(result.profiles);
setStatus(`Imported ${result.imported.length} profile${result.imported.length === 1 ? "" : "s"}.`, "success");
} catch (error) {
setStatus(error.message || "Import failed", "danger", 7000);
} finally {
importInput.value = "";
}
};
const runExport = async () => {
const name = state.selectedProfile;
const query = name ? `?names=${encodeURIComponent(name)}` : "";
try {
const response = await fetch(`/api/voice-profiles/export${query}`);
if (!response.ok) {
throw new Error("Export failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name ? `${name}.json` : "voice_profiles.json";
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
setStatus("Export complete.", "success");
} catch (error) {
setStatus(error.message || "Export failed", "danger", 7000);
}
};
const runPreview = async () => {
if (!previewBtn) return;
const payload = {
language: languageSelect ? languageSelect.value : "a",
voices: buildProfilePayload(),
text: previewTextEl ? previewTextEl.value : "",
speed: speedInput ? parseFloat(speedInput.value || "1") : 1,
};
const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0);
if (!enabledVoices.length) {
setStatus("Enable at least one voice to preview.", "warning");
return;
}
previewBtn.disabled = true;
previewBtn.dataset.loading = "true";
setStatus("Generating preview…", "info", 0);
try {
const response = await fetch("/api/voice-profiles/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await response.text());
}
const blob = await response.blob();
if (state.previewUrl) {
URL.revokeObjectURL(state.previewUrl);
}
state.previewUrl = URL.createObjectURL(blob);
if (previewAudio) {
previewAudio.src = state.previewUrl;
previewAudio.play().catch(() => {});
}
setStatus("Preview ready.", "success");
} catch (error) {
setStatus(error.message || "Preview failed", "danger", 7000);
} finally {
previewBtn.disabled = false;
previewBtn.dataset.loading = "false";
}
};
if (saveBtn) {
const form = saveBtn.closest("form");
if (form) {
form.addEventListener("submit", (event) => {
event.preventDefault();
runSave();
});
}
}
if (duplicateBtn) {
duplicateBtn.addEventListener("click", () => runDuplicate());
}
if (deleteBtn) {
deleteBtn.addEventListener("click", () => runDelete());
}
if (previewBtn) {
previewBtn.addEventListener("click", () => runPreview());
}
if (loadSampleBtn) {
loadSampleBtn.addEventListener("click", loadSampleText);
}
if (languageSelect) {
languageSelect.addEventListener("change", () => {
state.draft.language = languageSelect.value;
markDirty();
loadSampleText();
});
}
if (nameInput) {
nameInput.addEventListener("input", () => {
state.draft.name = nameInput.value;
markDirty();
});
}
if (importInput) {
importInput.addEventListener("change", () => {
const [file] = importInput.files || [];
if (file) {
runImport(file);
}
});
}
if (headerActions) {
headerActions.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const action = target.dataset.action;
if (!action) return;
if (action === "new-profile") {
createNewProfile();
setStatus("New profile ready.", "info");
} else if (action === "import-profiles") {
importInput?.click();
} else if (action === "export-profiles") {
runExport();
}
});
}
buildVoiceGrid();
renderProfileList();
createNewProfile();
if (Object.keys(profiles).length) {
const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0];
selectProfile(first);
}
loadSampleText();
app.dataset.state = "ready";
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupVoiceMixer, { once: true });
} else {
setupVoiceMixer();
}
+1
View File
@@ -27,5 +27,6 @@
<span>Need help?</span>
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+71 -56
View File
@@ -3,64 +3,79 @@
{% block title %}abogen · Voice mixer{% endblock %}
{% block content %}
<section class="card">
<h1 class="card__title">Voice mixer</h1>
<p class="tag">Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.</p>
<div class="grid grid--two">
<div class="grid">
<h2>Saved profiles</h2>
{% if profiles %}
<ul class="list">
{% for profile in profiles %}
<li class="list__item">
<div>
<strong>{{ profile.name }}</strong>
<p class="tag">Language: {{ languages.get(profile.language, profile.language|upper) }}</p>
<p class="tag">Formula: {{ profile.formula }}</p>
</div>
<form method="post" action="{{ url_for('web.delete_voice_profile_route', name=profile.name) }}">
<button type="submit" class="button button--danger">Delete</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>No saved profiles yet. Use the form to create one.</p>
{% endif %}
<section class="card voice-mixer">
<div class="voice-mixer__header">
<div>
<h1 class="card__title">Voice mixer</h1>
<p class="tag">Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.</p>
</div>
<div class="grid">
<h2>Create or update</h2>
<form method="post" action="{{ url_for('web.save_voice_profile_route') }}" class="grid">
<div class="field">
<label for="name">Profile name</label>
<input type="text" id="name" name="name" required placeholder="Narrator Mix">
</div>
<div class="field">
<label for="language">Language</label>
<select id="language" name="language">
{% for key, label in languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="formula">Voice formula</label>
<input type="text" id="formula" name="formula" required placeholder="af_nova*0.4+am_liam*0.6">
<p class="tag">Use <code>voice_name*weight</code> segments joined with <code>+</code>. Weights will be normalised automatically.</p>
</div>
<div class="field">
<label for="voices">Available voices</label>
<div class="pill-grid">
{% for voice in voices %}
<span class="pill">{{ voice }}</span>
{% endfor %}
</div>
</div>
<div class="field">
<button type="submit" class="button">Save profile</button>
</div>
</form>
<div class="voice-mixer__header-actions">
<button type="button" class="button button--ghost" data-action="new-profile">New profile</button>
<button type="button" class="button button--ghost" data-action="import-profiles">Import</button>
<button type="button" class="button button--ghost" data-action="export-profiles">Export</button>
</div>
</div>
<div id="voice-mixer-app" class="voice-mixer__layout" data-state="loading">
<aside class="voice-mixer__profiles" data-role="profile-list">
<p class="tag">Loading profiles…</p>
</aside>
<section class="voice-mixer__editor" data-role="editor">
<noscript>
<p class="tag">JavaScript is required for the mixer. Please enable it to edit profiles.</p>
</noscript>
<form id="voice-profile-form" class="voice-editor" autocomplete="off">
<div class="voice-status" data-role="status"></div>
<div class="voice-editor__meta">
<div class="field">
<label for="profile-name">Profile name</label>
<input id="profile-name" name="name" type="text" placeholder="Narrator blend" required>
</div>
<div class="field">
<label for="profile-language">Language</label>
<select id="profile-language" name="language">
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="preview-speed">Preview speed</label>
<input id="preview-speed" type="number" step="0.05" min="0.7" max="1.3" value="1.0">
</div>
</div>
<div class="voice-editor__summary">
<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>
</div>
<div class="voice-editor__grid" data-role="voice-grid"></div>
<div class="voice-editor__actions">
<div class="button-row">
<button type="submit" class="button" data-role="save-profile" disabled>Save profile</button>
<button type="button" class="button button--ghost" data-role="duplicate-profile">Duplicate</button>
<button type="button" class="button button--danger" data-role="delete-profile">Delete</button>
</div>
<div class="voice-preview">
<div class="field">
<label for="preview-text">Preview text</label>
<textarea id="preview-text" rows="3" data-role="preview-text" placeholder="Paste a short line to audition your mix."></textarea>
</div>
<div class="voice-preview__controls">
<button type="button" class="button" data-role="preview-button">Preview mix</button>
<button type="button" class="button button--ghost" data-role="load-sample">Use sample text</button>
</div>
<audio data-role="preview-audio" controls preload="none"></audio>
</div>
</div>
</form>
</section>
</div>
<input id="voice-import-input" type="file" accept="application/json" hidden>
</section>
{% endblock %}
{% block scripts %}
<script>
window.ABOGEN_VOICE_MIXER_DATA = {{ options | tojson | safe }};
</script>
<script type="module" src="{{ url_for('static', filename='voices.js') }}"></script>
{% endblock %}