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
+63 -28
View File
@@ -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 nonWindows, 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
+9 -7
View File
@@ -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)
+73 -10
View File
@@ -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;
}
}
+36 -6
View File
@@ -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 <strong>${label}</strong> 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;
+15 -3
View File
@@ -50,8 +50,19 @@
<div class="voice-editor__canvas">
<section class="voice-available">
<header class="voice-available__header">
<h2>Available voices</h2>
<p class="tag">Drag into the mixer or click Add.</p>
<div class="voice-available__title">
<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>
<div class="voice-available__list" data-role="available-voices"></div>
</section>
@@ -92,8 +103,9 @@
{% endblock %}
{% block scripts %}
<script id="voice-mixer-options" type="application/json">{{ options | tojson | safe }}</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 type="module" src="{{ url_for('static', filename='voices.js') }}"></script>
{% endblock %}