mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
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:
+333
-29
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user