mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Enhance voice mixer functionality with language filtering and improved directory management
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Copy this file to `.env` and customize the paths to match your environment.
|
||||||
|
# Relative paths are resolved from the repository root when running locally.
|
||||||
|
|
||||||
|
ABOGEN_SETTINGS_DIR=./config
|
||||||
|
ABOGEN_TEMP_DIR=./storage/tmp
|
||||||
|
ABOGEN_OUTPUT_DIR=./storage/output
|
||||||
@@ -19,6 +19,7 @@ __pycache__/
|
|||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
.env/
|
.env/
|
||||||
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
test/
|
test/
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ python_embedded/
|
|||||||
|
|
||||||
# abogen
|
# abogen
|
||||||
*config.json
|
*config.json
|
||||||
|
config/
|
||||||
|
storage/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
.old/
|
.old/
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo
|
|||||||
| `ABOGEN_DEBUG` | `false` | Enable Flask debug mode |
|
| `ABOGEN_DEBUG` | `false` | Enable Flask debug mode |
|
||||||
| `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored |
|
| `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored |
|
||||||
| `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles |
|
| `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles |
|
||||||
|
| `ABOGEN_TEMP_DIR` | Platform cache dir (e.g. `~/.cache/abogen`) | Override the cache/temp directory |
|
||||||
|
| `ABOGEN_OUTPUT_DIR` | Same as `ABOGEN_OUTPUT_ROOT` | Override the rendered output directory |
|
||||||
|
| `ABOGEN_SETTINGS_DIR` | Platform config dir (e.g. `~/.config/abogen`) | Override where JSON settings (profiles, config) are stored |
|
||||||
|
|
||||||
Set any of these with `-e VAR=value` when starting the container.
|
Set any of these with `-e VAR=value` when starting the container.
|
||||||
|
|
||||||
@@ -111,9 +114,14 @@ More automation hooks are planned; contributions are very welcome if you need ad
|
|||||||
Most behaviour is controlled through the UI, but a few environment variables are helpful for automation:
|
Most behaviour is controlled through the UI, but a few environment variables are helpful for automation:
|
||||||
- `ABOGEN_SECRET_KEY` – provide your own random secret when deploying across multiple replicas.
|
- `ABOGEN_SECRET_KEY` – provide your own random secret when deploying across multiple replicas.
|
||||||
- `ABOGEN_DEBUG` – set to `true` for verbose Flask error output.
|
- `ABOGEN_DEBUG` – set to `true` for verbose Flask error output.
|
||||||
|
- `ABOGEN_SETTINGS_DIR` – change where Abogen stores its JSON settings/configuration files.
|
||||||
|
- `ABOGEN_TEMP_DIR` – change where temporary uploads and cache files are stored.
|
||||||
|
- `ABOGEN_OUTPUT_DIR` – change where rendered audio/subtitles are written.
|
||||||
|
|
||||||
If unset, Abogen picks sensible defaults suitable for local usage.
|
If unset, Abogen picks sensible defaults suitable for local usage.
|
||||||
|
|
||||||
|
You can also create a `.env` file in the project root (see `.env.example`) to configure these paths when running locally. The application loads `.env` automatically on startup.
|
||||||
|
|
||||||
## Development workflow
|
## Development workflow
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/denizsafak/abogen.git
|
git clone https://github.com/denizsafak/abogen.git
|
||||||
|
|||||||
+63
-28
@@ -1,13 +1,19 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import warnings
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import sys
|
||||||
|
import warnings
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
|
||||||
@@ -82,40 +88,69 @@ def get_version():
|
|||||||
|
|
||||||
|
|
||||||
# Define config path
|
# Define config path
|
||||||
def get_user_config_path():
|
def ensure_directory(path):
|
||||||
|
resolved = os.path.abspath(os.path.expanduser(str(path)))
|
||||||
|
os.makedirs(resolved, exist_ok=True)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_user_settings_dir():
|
||||||
|
override = os.environ.get("ABOGEN_SETTINGS_DIR")
|
||||||
|
if override:
|
||||||
|
return ensure_directory(override)
|
||||||
|
|
||||||
from platformdirs import user_config_dir
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
# TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used.
|
|
||||||
# On non‑Windows, prefer ~/.config/abogen if it already exists
|
|
||||||
if platform.system() != "Windows":
|
if platform.system() != "Windows":
|
||||||
custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||||
if os.path.exists(custom_dir):
|
if os.path.exists(legacy_dir):
|
||||||
config_dir = custom_dir
|
return ensure_directory(legacy_dir)
|
||||||
else:
|
|
||||||
config_dir = user_config_dir(
|
|
||||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Windows and fallback case
|
|
||||||
config_dir = user_config_dir(
|
|
||||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return os.path.join(config_dir, "config.json")
|
config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True)
|
||||||
|
return ensure_directory(config_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_config_path():
|
||||||
|
return os.path.join(get_user_settings_dir(), "config.json")
|
||||||
|
|
||||||
|
|
||||||
# Define cache path
|
# Define cache path
|
||||||
def get_user_cache_path(folder=None):
|
@lru_cache(maxsize=1)
|
||||||
|
def get_user_cache_root():
|
||||||
|
override = os.environ.get("ABOGEN_TEMP_DIR")
|
||||||
|
if override:
|
||||||
|
return ensure_directory(override)
|
||||||
|
|
||||||
from platformdirs import user_cache_dir
|
from platformdirs import user_cache_dir
|
||||||
|
|
||||||
cache_dir = user_cache_dir(
|
cache_dir = user_cache_dir("abogen", appauthor=False, opinion=True, ensure_exists=True)
|
||||||
"abogen", appauthor=False, opinion=True, ensure_exists=True
|
return ensure_directory(cache_dir)
|
||||||
)
|
|
||||||
|
|
||||||
|
def get_user_cache_path(folder=None):
|
||||||
|
base = get_user_cache_root()
|
||||||
if folder:
|
if folder:
|
||||||
cache_dir = os.path.join(cache_dir, folder)
|
return ensure_directory(os.path.join(base, folder))
|
||||||
# Ensure the directory exists
|
return base
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
|
||||||
return cache_dir
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_user_output_root():
|
||||||
|
override = (
|
||||||
|
os.environ.get("ABOGEN_OUTPUT_DIR")
|
||||||
|
or os.environ.get("ABOGEN_OUTPUT_ROOT")
|
||||||
|
)
|
||||||
|
if override:
|
||||||
|
return ensure_directory(override)
|
||||||
|
return ensure_directory(os.path.join(get_user_cache_root(), "outputs"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_output_path(folder=None):
|
||||||
|
base = get_user_output_root()
|
||||||
|
if folder:
|
||||||
|
return ensure_directory(os.path.join(base, folder))
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||||
|
|||||||
+9
-7
@@ -7,7 +7,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from abogen.utils import get_user_cache_path
|
from abogen.utils import get_user_cache_path, get_user_output_path
|
||||||
|
|
||||||
from .conversion_runner import run_conversion_job
|
from .conversion_runner import run_conversion_job
|
||||||
from .service import build_service
|
from .service import build_service
|
||||||
@@ -17,13 +17,15 @@ def _default_dirs() -> tuple[Path, Path]:
|
|||||||
uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT")
|
uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT")
|
||||||
outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT")
|
outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT")
|
||||||
|
|
||||||
if uploads_override and outputs_override:
|
if uploads_override:
|
||||||
uploads = Path(uploads_override)
|
uploads = Path(os.path.expanduser(uploads_override)).resolve()
|
||||||
outputs = Path(outputs_override)
|
|
||||||
else:
|
else:
|
||||||
base = Path(get_user_cache_path("web"))
|
uploads = Path(get_user_cache_path("web/uploads"))
|
||||||
uploads = base / "uploads"
|
|
||||||
outputs = base / "outputs"
|
if outputs_override:
|
||||||
|
outputs = Path(os.path.expanduser(outputs_override)).resolve()
|
||||||
|
else:
|
||||||
|
outputs = Path(get_user_output_path("web"))
|
||||||
|
|
||||||
uploads.mkdir(parents=True, exist_ok=True)
|
uploads.mkdir(parents=True, exist_ok=True)
|
||||||
outputs.mkdir(parents=True, exist_ok=True)
|
outputs.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -397,8 +400,8 @@ progress.progress::-moz-progress-bar {
|
|||||||
|
|
||||||
.voice-mixer__layout {
|
.voice-mixer__layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 280px) 1fr;
|
grid-template-columns: minmax(260px, 300px) 1fr;
|
||||||
gap: 2rem;
|
gap: 2.25rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,8 +514,8 @@ progress.progress::-moz-progress-bar {
|
|||||||
|
|
||||||
.voice-editor__canvas {
|
.voice-editor__canvas {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr);
|
grid-template-columns: minmax(280px, 340px) minmax(320px, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 2rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,20 +529,67 @@ progress.progress::-moz-progress-bar {
|
|||||||
.voice-available__header,
|
.voice-available__header,
|
||||||
.voice-mix__header {
|
.voice-mix__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-available__title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-filter label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-filter select {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-filter select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-available__list {
|
.voice-available__list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.85rem;
|
||||||
max-height: 55vh;
|
max-height: 55vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-available__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.voice-available__card {
|
.voice-available__card {
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -609,8 +659,8 @@ progress.progress::-moz-progress-bar {
|
|||||||
.voice-mix__dropzone {
|
.voice-mix__dropzone {
|
||||||
border: 2px dashed rgba(148, 163, 184, 0.25);
|
border: 2px dashed rgba(148, 163, 184, 0.25);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 1.25rem;
|
padding: 1.5rem;
|
||||||
min-height: 260px;
|
min-height: 320px;
|
||||||
background: rgba(15, 23, 42, 0.25);
|
background: rgba(15, 23, 42, 0.25);
|
||||||
transition: border 0.2s ease, background 0.2s ease;
|
transition: border 0.2s ease, background 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -640,7 +690,7 @@ progress.progress::-moz-progress-bar {
|
|||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(15, 23, 42, 0.4);
|
background: rgba(15, 23, 42, 0.4);
|
||||||
padding: 1rem 1.1rem;
|
padding: 1.1rem 1.2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
@@ -803,4 +853,17 @@ progress.progress::-moz-progress-bar {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-available__header,
|
||||||
|
.voice-mix__header {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-mix__dropzone {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const setupVoiceMixer = () => {
|
|||||||
const selectedListEl = app.querySelector('[data-role="selected-voices"]');
|
const selectedListEl = app.querySelector('[data-role="selected-voices"]');
|
||||||
const dropzoneEl = app.querySelector('[data-role="dropzone"]');
|
const dropzoneEl = app.querySelector('[data-role="dropzone"]');
|
||||||
const emptyStateEl = app.querySelector('[data-role="mix-empty"]');
|
const emptyStateEl = app.querySelector('[data-role="mix-empty"]');
|
||||||
|
const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]');
|
||||||
|
|
||||||
if (!profileListEl || !availableListEl || !selectedListEl) {
|
if (!profileListEl || !availableListEl || !selectedListEl) {
|
||||||
return;
|
return;
|
||||||
@@ -55,6 +56,7 @@ const setupVoiceMixer = () => {
|
|||||||
language: "a",
|
language: "a",
|
||||||
voices: new Map(),
|
voices: new Map(),
|
||||||
},
|
},
|
||||||
|
languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "",
|
||||||
};
|
};
|
||||||
|
|
||||||
let statusTimeout = null;
|
let statusTimeout = null;
|
||||||
@@ -211,14 +213,16 @@ const setupVoiceMixer = () => {
|
|||||||
|
|
||||||
const slider = document.createElement("input");
|
const slider = document.createElement("input");
|
||||||
slider.type = "range";
|
slider.type = "range";
|
||||||
slider.min = "0";
|
slider.min = "5";
|
||||||
slider.max = "100";
|
slider.max = "100";
|
||||||
slider.step = "1";
|
slider.step = "1";
|
||||||
slider.className = "mix-slider";
|
slider.className = "mix-slider";
|
||||||
slider.value = String(Math.round(clamp(weight, 0, 1) * 100));
|
const normalizedWeight = clamp(weight, 0.05, 1);
|
||||||
setSliderFill(slider, weight);
|
slider.value = String(Math.round(normalizedWeight * 100));
|
||||||
|
setSliderFill(slider, normalizedWeight);
|
||||||
slider.addEventListener("input", () => {
|
slider.addEventListener("input", () => {
|
||||||
const value = clamp(Number(slider.value) / 100, 0, 1);
|
const value = clamp(Number(slider.value) / 100, 0.05, 1);
|
||||||
|
slider.value = String(Math.round(value * 100));
|
||||||
state.draft.voices.set(voiceId, value);
|
state.draft.voices.set(voiceId, value);
|
||||||
weightLabel.textContent = formatWeight(value);
|
weightLabel.textContent = formatWeight(value);
|
||||||
setSliderFill(slider, value);
|
setSliderFill(slider, value);
|
||||||
@@ -244,7 +248,24 @@ const setupVoiceMixer = () => {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id));
|
.sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id));
|
||||||
|
|
||||||
sortedVoices.forEach((voice) => {
|
const filteredVoices = sortedVoices.filter((voice) => {
|
||||||
|
const languageCode = voice.language || voice.id?.charAt(0) || "a";
|
||||||
|
return !state.languageFilter || state.languageFilter === languageCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filteredVoices.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "voice-available__empty";
|
||||||
|
const label = state.languageFilter
|
||||||
|
? voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase()
|
||||||
|
: "All voices";
|
||||||
|
empty.innerHTML = `No voices for <strong>${label}</strong> yet.`;
|
||||||
|
availableListEl.appendChild(empty);
|
||||||
|
updateAvailableState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredVoices.forEach((voice) => {
|
||||||
if (!voice?.id) {
|
if (!voice?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -309,6 +330,7 @@ const setupVoiceMixer = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateAvailableState();
|
updateAvailableState();
|
||||||
|
availableListEl.scrollTop = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addVoiceToDraft = (voiceId, weight = 0.6) => {
|
const addVoiceToDraft = (voiceId, weight = 0.6) => {
|
||||||
@@ -445,7 +467,8 @@ const setupVoiceMixer = () => {
|
|||||||
const [voiceId, weight] = entry;
|
const [voiceId, weight] = entry;
|
||||||
const value = clamp(parseFloat(weight), 0, 1);
|
const value = clamp(parseFloat(weight), 0, 1);
|
||||||
if (!Number.isNaN(value) && value > 0) {
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
state.draft.voices.set(String(voiceId), value);
|
const normalized = clamp(value, 0.05, 1);
|
||||||
|
state.draft.voices.set(String(voiceId), normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -697,6 +720,13 @@ const setupVoiceMixer = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (voiceFilterSelect) {
|
||||||
|
voiceFilterSelect.addEventListener("change", () => {
|
||||||
|
state.languageFilter = voiceFilterSelect.value;
|
||||||
|
renderAvailableVoices();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (nameInput) {
|
if (nameInput) {
|
||||||
nameInput.addEventListener("input", () => {
|
nameInput.addEventListener("input", () => {
|
||||||
state.draft.name = nameInput.value;
|
state.draft.name = nameInput.value;
|
||||||
|
|||||||
@@ -50,8 +50,19 @@
|
|||||||
<div class="voice-editor__canvas">
|
<div class="voice-editor__canvas">
|
||||||
<section class="voice-available">
|
<section class="voice-available">
|
||||||
<header class="voice-available__header">
|
<header class="voice-available__header">
|
||||||
<h2>Available voices</h2>
|
<div class="voice-available__title">
|
||||||
<p class="tag">Drag into the mixer or click Add.</p>
|
<h2>Available voices</h2>
|
||||||
|
<p class="tag">Drag into the mixer or click Add.</p>
|
||||||
|
</div>
|
||||||
|
<div class="voice-filter">
|
||||||
|
<label for="voice-language-filter">Language</label>
|
||||||
|
<select id="voice-language-filter" data-role="voice-filter">
|
||||||
|
<option value="">All languages</option>
|
||||||
|
{% for key, label in options.languages.items() %}
|
||||||
|
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="voice-available__list" data-role="available-voices"></div>
|
<div class="voice-available__list" data-role="available-voices"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -92,8 +103,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script id="voice-mixer-options" type="application/json">{{ options | tojson | safe }}</script>
|
||||||
<script>
|
<script>
|
||||||
window.ABOGEN_VOICE_MIXER_DATA = {{ options | tojson | safe }};
|
window.ABOGEN_VOICE_MIXER_DATA = JSON.parse(document.getElementById('voice-mixer-options').textContent);
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="{{ url_for('static', filename='voices.js') }}"></script>
|
<script type="module" src="{{ url_for('static', filename='voices.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"pygame>=2.6.1",
|
"pygame>=2.6.1",
|
||||||
"charset_normalizer>=3.4.1",
|
"charset_normalizer>=3.4.1",
|
||||||
"chardet>=5.2.0",
|
"chardet>=5.2.0",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
"static_ffmpeg>=2.13",
|
"static_ffmpeg>=2.13",
|
||||||
"Markdown>=3.9",
|
"Markdown>=3.9",
|
||||||
"Flask>=3.0.3",
|
"Flask>=3.0.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user