diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index 77a07d0..4f87820 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -1,5 +1,8 @@ -import os import json +import os +from typing import Dict, Iterable, List, Tuple + +from abogen.constants import VOICES_INTERNAL from abogen.utils import get_user_config_path @@ -57,3 +60,107 @@ def export_profiles(export_path): profiles = load_profiles() with open(export_path, "w", encoding="utf-8") as f: json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: + """Return profiles in canonical dictionary form.""" + return load_profiles() + + +def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + normalized: List[Tuple[str, float]] = [] + for item in entries or []: + if isinstance(item, dict): + voice = item.get("id") or item.get("voice") + weight = item.get("weight") + elif isinstance(item, (list, tuple)) and len(item) >= 2: + voice, weight = item[0], item[1] + else: + continue + if voice not in VOICES_INTERNAL: + continue + if weight is None: + continue + try: + weight_val = float(weight) + except (TypeError, ValueError): + continue + if weight_val <= 0: + continue + normalized.append((voice, weight_val)) + return normalized + + +def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + """Public helper to normalize voice-weight pairs from arbitrary payloads.""" + + return _normalize_voice_entries(entries) + + +def save_profile(name: str, *, language: str, voices: Iterable) -> None: + """Persist a single profile after validating its data.""" + + name = (name or "").strip() + if not name: + raise ValueError("Profile name is required") + + normalized = _normalize_voice_entries(voices) + if not normalized: + raise ValueError("At least one voice with a weight above zero is required") + + if not language: + language = "a" + + profiles = load_profiles() + profiles[name] = {"language": language, "voices": normalized} + save_profiles(profiles) + + +def remove_profile(name: str) -> None: + delete_profile(name) + + +def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: + """Merge profiles from a dictionary structure and persist them. + + Returns the list of profile names that were added or updated. + """ + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + if "abogen_voice_profiles" in data: + data = data["abogen_voice_profiles"] + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + current = load_profiles() + updated: List[str] = [] + for name, entry in data.items(): + if not isinstance(entry, dict): + continue + voices = _normalize_voice_entries(entry.get("voices", [])) + if not voices: + continue + language = entry.get("language", "a") + if name in current and not replace_existing: + # skip duplicates unless explicit replacement is requested + continue + current[name] = {"language": language, "voices": voices} + updated.append(name) + + if updated: + save_profiles(current) + return updated + + +def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: + """Return profiles limited to the provided names for download/export.""" + + profiles = load_profiles() + if names is None: + subset = profiles + else: + subset = {name: profiles[name] for name in names if name in profiles} + return {"abogen_voice_profiles": subset} diff --git a/abogen/web/routes.py b/abogen/web/routes.py index d4da093..c48c412 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -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/") +def api_delete_voice_profile(name: str) -> Response: + remove_profile(name) + return jsonify({"ok": True, **_profiles_payload()}) + + +@api_bp.post("/voice-profiles//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//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/") 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) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index c3a6687..8f8b244 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -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; + } +} diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js new file mode 100644 index 0000000..b8b18e0 --- /dev/null +++ b/abogen/web/static/voices.js @@ -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 = "

No voices available.

"; + } + 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 = ` + ${name} + ${voiceLanguageLabel(profile.language || "a")} + `; + 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(); +} diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index c03b9dd..c4fbfe7 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -27,5 +27,6 @@ Need help? Read the docs + {% block scripts %}{% endblock %} diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index 90deb9c..f3aa57b 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -3,64 +3,79 @@ {% block title %}abogen · Voice mixer{% endblock %} {% block content %} -
-

Voice mixer

-

Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.

-
-
-

Saved profiles

- {% if profiles %} -
    - {% for profile in profiles %} -
  • -
    - {{ profile.name }} -

    Language: {{ languages.get(profile.language, profile.language|upper) }}

    -

    Formula: {{ profile.formula }}

    -
    -
    - -
    -
  • - {% endfor %} -
- {% else %} -

No saved profiles yet. Use the form to create one.

- {% endif %} +
+
+
+

Voice mixer

+

Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.

-
-

Create or update

-
-
- - -
-
- - -
-
- - -

Use voice_name*weight segments joined with +. Weights will be normalised automatically.

-
-
- -
- {% for voice in voices %} - {{ voice }} - {% endfor %} -
-
-
- -
-
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Select or create a profile to begin. + Total weight: 0.00 +
+
+
+
+ + + +
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+
{% endblock %} + +{% block scripts %} + + +{% endblock %}