feat: Enhance voice mixer functionality with language filtering and improved directory management

This commit is contained in:
JB
2025-10-06 06:00:17 -07:00
parent 0c47067cb8
commit 2e402f6b5b
9 changed files with 214 additions and 54 deletions
+6
View File
@@ -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
+3
View File
@@ -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/
+8
View File
@@ -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
View File
@@ -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 nonWindows, 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
View File
@@ -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)
+73 -10
View File
@@ -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;
}
} }
+36 -6
View File
@@ -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;
+13 -1
View File
@@ -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">
<div class="voice-available__title">
<h2>Available voices</h2> <h2>Available voices</h2>
<p class="tag">Drag into the mixer or click Add.</p> <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 %}
+1
View File
@@ -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",