diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e21e65c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index c99cf9f..f60d968 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ __pycache__/ env/ venv/ .env/ +.env .venv/ test/ @@ -30,6 +31,8 @@ python_embedded/ # abogen *config.json +config/ +storage/ build/ dist/ .old/ diff --git a/README.md b/README.md index bc3c41a..d288fab 100644 --- a/README.md +++ b/README.md @@ -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_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored | | `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. @@ -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: - `ABOGEN_SECRET_KEY` – provide your own random secret when deploying across multiple replicas. - `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. +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 ```bash git clone https://github.com/denizsafak/abogen.git diff --git a/abogen/utils.py b/abogen/utils.py index 0435d77..94144af 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -1,13 +1,19 @@ -import os -import sys import json -import warnings +import os import platform +import re import shutil import subprocess -import re +import sys +import warnings from threading import Thread +from functools import lru_cache + +from dotenv import load_dotenv + +load_dotenv() + warnings.filterwarnings("ignore") @@ -82,40 +88,69 @@ def get_version(): # 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 - # 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": - custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") - if os.path.exists(custom_dir): - config_dir = custom_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 - ) + legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") + if os.path.exists(legacy_dir): + return ensure_directory(legacy_dir) - 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 -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 - cache_dir = user_cache_dir( - "abogen", appauthor=False, opinion=True, ensure_exists=True - ) + cache_dir = user_cache_dir("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: - cache_dir = os.path.join(cache_dir, folder) - # Ensure the directory exists - os.makedirs(cache_dir, exist_ok=True) - return cache_dir + return ensure_directory(os.path.join(base, folder)) + return base + + +@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 diff --git a/abogen/web/app.py b/abogen/web/app.py index e385fb4..0a4923e 100644 --- a/abogen/web/app.py +++ b/abogen/web/app.py @@ -7,7 +7,7 @@ from typing import Any, Optional 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 .service import build_service @@ -17,13 +17,15 @@ def _default_dirs() -> tuple[Path, Path]: uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT") outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT") - if uploads_override and outputs_override: - uploads = Path(uploads_override) - outputs = Path(outputs_override) + if uploads_override: + uploads = Path(os.path.expanduser(uploads_override)).resolve() else: - base = Path(get_user_cache_path("web")) - uploads = base / "uploads" - outputs = base / "outputs" + uploads = Path(get_user_cache_path("web/uploads")) + + 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) outputs.mkdir(parents=True, exist_ok=True) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 612a769..62b3123 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -33,6 +33,9 @@ body { display: flex; align-items: center; justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; } .brand { @@ -397,8 +400,8 @@ progress.progress::-moz-progress-bar { .voice-mixer__layout { display: grid; - grid-template-columns: minmax(240px, 280px) 1fr; - gap: 2rem; + grid-template-columns: minmax(260px, 300px) 1fr; + gap: 2.25rem; align-items: start; } @@ -511,8 +514,8 @@ progress.progress::-moz-progress-bar { .voice-editor__canvas { display: grid; - grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr); - gap: 1.5rem; + grid-template-columns: minmax(280px, 340px) minmax(320px, 1fr); + gap: 2rem; align-items: start; } @@ -526,20 +529,67 @@ progress.progress::-moz-progress-bar { .voice-available__header, .voice-mix__header { display: flex; - align-items: center; + align-items: flex-end; 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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 0.75rem; + gap: 0.85rem; max-height: 55vh; overflow-y: auto; 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 { border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 18px; @@ -609,8 +659,8 @@ progress.progress::-moz-progress-bar { .voice-mix__dropzone { border: 2px dashed rgba(148, 163, 184, 0.25); border-radius: 20px; - padding: 1.25rem; - min-height: 260px; + padding: 1.5rem; + min-height: 320px; background: rgba(15, 23, 42, 0.25); transition: border 0.2s ease, background 0.2s ease; display: flex; @@ -640,7 +690,7 @@ progress.progress::-moz-progress-bar { border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 18px; background: rgba(15, 23, 42, 0.4); - padding: 1rem 1.1rem; + padding: 1.1rem 1.2rem; display: flex; flex-direction: column; gap: 0.9rem; @@ -803,4 +853,17 @@ progress.progress::-moz-progress-bar { max-height: none; padding-right: 0; } + + .voice-available__header, + .voice-mix__header { + align-items: stretch; + } + + .voice-filter { + width: 100%; + } + + .voice-mix__dropzone { + min-height: 220px; + } } diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 3a9bdba..352a77b 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -30,6 +30,7 @@ const setupVoiceMixer = () => { const selectedListEl = app.querySelector('[data-role="selected-voices"]'); const dropzoneEl = app.querySelector('[data-role="dropzone"]'); const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); + const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); if (!profileListEl || !availableListEl || !selectedListEl) { return; @@ -55,6 +56,7 @@ const setupVoiceMixer = () => { language: "a", voices: new Map(), }, + languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "", }; let statusTimeout = null; @@ -211,14 +213,16 @@ const setupVoiceMixer = () => { const slider = document.createElement("input"); slider.type = "range"; - slider.min = "0"; + slider.min = "5"; slider.max = "100"; slider.step = "1"; slider.className = "mix-slider"; - slider.value = String(Math.round(clamp(weight, 0, 1) * 100)); - setSliderFill(slider, weight); + const normalizedWeight = clamp(weight, 0.05, 1); + slider.value = String(Math.round(normalizedWeight * 100)); + setSliderFill(slider, normalizedWeight); 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); weightLabel.textContent = formatWeight(value); setSliderFill(slider, value); @@ -244,7 +248,24 @@ const setupVoiceMixer = () => { .slice() .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 ${label} yet.`; + availableListEl.appendChild(empty); + updateAvailableState(); + return; + } + + filteredVoices.forEach((voice) => { if (!voice?.id) { return; } @@ -309,6 +330,7 @@ const setupVoiceMixer = () => { }); updateAvailableState(); + availableListEl.scrollTop = 0; }; const addVoiceToDraft = (voiceId, weight = 0.6) => { @@ -445,7 +467,8 @@ const setupVoiceMixer = () => { const [voiceId, weight] = entry; const value = clamp(parseFloat(weight), 0, 1); 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) { nameInput.addEventListener("input", () => { state.draft.name = nameInput.value; diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index e9fb99e..8fb86bc 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -50,8 +50,19 @@
Drag into the mixer or click Add.
+Drag into the mixer or click Add.
+