feat: Implement voice mixer UI and functionality

- Added new styles for the voice mixer components in styles.css.
- Updated base.html to include a block for scripts.
- Refactored voices.html to create a structured voice mixer interface with profile management features.
- Introduced voices.js to handle voice mixer logic, including profile creation, editing, and previewing.
- Implemented actions for importing and exporting voice profiles.
- Enhanced user experience with loading states and status messages.
This commit is contained in:
JB
2025-10-06 05:10:32 -07:00
parent 9ba2362528
commit b718dae1b3
6 changed files with 1457 additions and 86 deletions
+1
View File
@@ -27,5 +27,6 @@
<span>Need help?</span>
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+71 -56
View File
@@ -3,64 +3,79 @@
{% block title %}abogen · Voice mixer{% endblock %}
{% block content %}
<section class="card">
<h1 class="card__title">Voice mixer</h1>
<p class="tag">Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.</p>
<div class="grid grid--two">
<div class="grid">
<h2>Saved profiles</h2>
{% if profiles %}
<ul class="list">
{% for profile in profiles %}
<li class="list__item">
<div>
<strong>{{ profile.name }}</strong>
<p class="tag">Language: {{ languages.get(profile.language, profile.language|upper) }}</p>
<p class="tag">Formula: {{ profile.formula }}</p>
</div>
<form method="post" action="{{ url_for('web.delete_voice_profile_route', name=profile.name) }}">
<button type="submit" class="button button--danger">Delete</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>No saved profiles yet. Use the form to create one.</p>
{% endif %}
<section class="card voice-mixer">
<div class="voice-mixer__header">
<div>
<h1 class="card__title">Voice mixer</h1>
<p class="tag">Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.</p>
</div>
<div class="grid">
<h2>Create or update</h2>
<form method="post" action="{{ url_for('web.save_voice_profile_route') }}" class="grid">
<div class="field">
<label for="name">Profile name</label>
<input type="text" id="name" name="name" required placeholder="Narrator Mix">
</div>
<div class="field">
<label for="language">Language</label>
<select id="language" name="language">
{% for key, label in languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="formula">Voice formula</label>
<input type="text" id="formula" name="formula" required placeholder="af_nova*0.4+am_liam*0.6">
<p class="tag">Use <code>voice_name*weight</code> segments joined with <code>+</code>. Weights will be normalised automatically.</p>
</div>
<div class="field">
<label for="voices">Available voices</label>
<div class="pill-grid">
{% for voice in voices %}
<span class="pill">{{ voice }}</span>
{% endfor %}
</div>
</div>
<div class="field">
<button type="submit" class="button">Save profile</button>
</div>
</form>
<div class="voice-mixer__header-actions">
<button type="button" class="button button--ghost" data-action="new-profile">New profile</button>
<button type="button" class="button button--ghost" data-action="import-profiles">Import</button>
<button type="button" class="button button--ghost" data-action="export-profiles">Export</button>
</div>
</div>
<div id="voice-mixer-app" class="voice-mixer__layout" data-state="loading">
<aside class="voice-mixer__profiles" data-role="profile-list">
<p class="tag">Loading profiles…</p>
</aside>
<section class="voice-mixer__editor" data-role="editor">
<noscript>
<p class="tag">JavaScript is required for the mixer. Please enable it to edit profiles.</p>
</noscript>
<form id="voice-profile-form" class="voice-editor" autocomplete="off">
<div class="voice-status" data-role="status"></div>
<div class="voice-editor__meta">
<div class="field">
<label for="profile-name">Profile name</label>
<input id="profile-name" name="name" type="text" placeholder="Narrator blend" required>
</div>
<div class="field">
<label for="profile-language">Language</label>
<select id="profile-language" name="language">
{% for key, label in options.languages.items() %}
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="preview-speed">Preview speed</label>
<input id="preview-speed" type="number" step="0.05" min="0.7" max="1.3" value="1.0">
</div>
</div>
<div class="voice-editor__summary">
<span data-role="profile-summary">Select or create a profile to begin.</span>
<span class="tag" data-role="mix-total">Total weight: 0.00</span>
</div>
<div class="voice-editor__grid" data-role="voice-grid"></div>
<div class="voice-editor__actions">
<div class="button-row">
<button type="submit" class="button" data-role="save-profile" disabled>Save profile</button>
<button type="button" class="button button--ghost" data-role="duplicate-profile">Duplicate</button>
<button type="button" class="button button--danger" data-role="delete-profile">Delete</button>
</div>
<div class="voice-preview">
<div class="field">
<label for="preview-text">Preview text</label>
<textarea id="preview-text" rows="3" data-role="preview-text" placeholder="Paste a short line to audition your mix."></textarea>
</div>
<div class="voice-preview__controls">
<button type="button" class="button" data-role="preview-button">Preview mix</button>
<button type="button" class="button button--ghost" data-role="load-sample">Use sample text</button>
</div>
<audio data-role="preview-audio" controls preload="none"></audio>
</div>
</div>
</form>
</section>
</div>
<input id="voice-import-input" type="file" accept="application/json" hidden>
</section>
{% endblock %}
{% block scripts %}
<script>
window.ABOGEN_VOICE_MIXER_DATA = {{ options | tojson | safe }};
</script>
<script type="module" src="{{ url_for('static', filename='voices.js') }}"></script>
{% endblock %}