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:
+108
-1
@@ -1,5 +1,8 @@
|
|||||||
import os
|
|
||||||
import json
|
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
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
|
|
||||||
@@ -57,3 +60,107 @@ def export_profiles(export_path):
|
|||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
with open(export_path, "w", encoding="utf-8") as f:
|
with open(export_path, "w", encoding="utf-8") as f:
|
||||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
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
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
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 (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -19,28 +22,67 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
from abogen.constants import (
|
from abogen.constants import (
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
SAMPLE_VOICE_TEXTS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
SUPPORTED_SOUND_FORMATS,
|
SUPPORTED_SOUND_FORMATS,
|
||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
)
|
)
|
||||||
from abogen.utils import calculate_text_length, clean_text
|
from abogen.utils import calculate_text_length, clean_text, load_config, load_numpy_kpipeline
|
||||||
from abogen.voice_profiles import delete_profile, load_profiles, save_profiles
|
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
|
from .service import ConversionService, Job, JobStatus
|
||||||
|
|
||||||
web_bp = Blueprint("web", __name__)
|
web_bp = Blueprint("web", __name__)
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
_preview_pipeline_lock = threading.RLock()
|
||||||
|
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||||
|
|
||||||
|
|
||||||
def _service() -> ConversionService:
|
def _service() -> ConversionService:
|
||||||
return current_app.extensions["conversion_service"]
|
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]:
|
def _template_options() -> Dict[str, Any]:
|
||||||
profiles = load_profiles()
|
profiles = serialize_profiles()
|
||||||
ordered_profiles = sorted(profiles.items())
|
ordered_profiles = sorted(profiles.items())
|
||||||
return {
|
return {
|
||||||
"languages": LANGUAGE_DESCRIPTIONS,
|
"languages": LANGUAGE_DESCRIPTIONS,
|
||||||
@@ -50,6 +92,9 @@ def _template_options() -> Dict[str, Any]:
|
|||||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||||
"voice_profiles": ordered_profiles,
|
"voice_profiles": ordered_profiles,
|
||||||
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
"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
|
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")
|
@web_bp.app_template_filter("datetimeformat")
|
||||||
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||||
if not value:
|
if not value:
|
||||||
@@ -142,22 +235,8 @@ def index() -> str:
|
|||||||
|
|
||||||
@web_bp.get("/voices")
|
@web_bp.get("/voices")
|
||||||
def voice_profiles_page() -> str:
|
def voice_profiles_page() -> str:
|
||||||
profiles = load_profiles()
|
options = _template_options()
|
||||||
rendered = []
|
return render_template("voices.html", options=options)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@web_bp.post("/voices")
|
@web_bp.post("/voices")
|
||||||
@@ -180,6 +259,223 @@ def delete_voice_profile_route(name: str) -> Response:
|
|||||||
return redirect(url_for("web.voice_profiles_page"))
|
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")
|
@web_bp.post("/jobs")
|
||||||
def enqueue_job() -> Response:
|
def enqueue_job() -> Response:
|
||||||
service = _service()
|
service = _service()
|
||||||
@@ -298,19 +594,23 @@ def delete_job(job_id: str) -> Response:
|
|||||||
@web_bp.get("/jobs/<job_id>/download")
|
@web_bp.get("/jobs/<job_id>/download")
|
||||||
def download_job(job_id: str) -> Response:
|
def download_job(job_id: str) -> Response:
|
||||||
job = _service().get_job(job_id)
|
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)
|
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)
|
abort(404)
|
||||||
path = job.result.audio_path
|
if not isinstance(audio_path, Path): # pragma: no cover - sanity guard
|
||||||
if not path.exists():
|
|
||||||
abort(404)
|
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(
|
return send_file(
|
||||||
path,
|
audio_path_path,
|
||||||
mimetype=mime_type or "application/octet-stream",
|
mimetype=mime_type or "application/octet-stream",
|
||||||
as_attachment=True,
|
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>")
|
@api_bp.get("/jobs/<job_id>")
|
||||||
def job_json(job_id: str) -> Response:
|
def job_json(job_id: str) -> Response:
|
||||||
job = _service().get_job(job_id)
|
job = _service().get_job(job_id)
|
||||||
if not job:
|
if job is None:
|
||||||
abort(404)
|
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));
|
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||||
border-radius: 999px;
|
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>
|
<span>Need help?</span>
|
||||||
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
|
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,64 +3,79 @@
|
|||||||
{% block title %}abogen · Voice mixer{% endblock %}
|
{% block title %}abogen · Voice mixer{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card voice-mixer">
|
||||||
<h1 class="card__title">Voice mixer</h1>
|
<div class="voice-mixer__header">
|
||||||
<p class="tag">Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.</p>
|
<div>
|
||||||
<div class="grid grid--two">
|
<h1 class="card__title">Voice mixer</h1>
|
||||||
<div class="grid">
|
<p class="tag">Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.</p>
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="voice-mixer__header-actions">
|
||||||
<h2>Create or update</h2>
|
<button type="button" class="button button--ghost" data-action="new-profile">New profile</button>
|
||||||
<form method="post" action="{{ url_for('web.save_voice_profile_route') }}" class="grid">
|
<button type="button" class="button button--ghost" data-action="import-profiles">Import</button>
|
||||||
<div class="field">
|
<button type="button" class="button button--ghost" data-action="export-profiles">Export</button>
|
||||||
<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>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
{% endblock %}
|
{% 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