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
+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 %}