mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-19 22:00:28 +02:00
feat: Implement conversion service with job management and logging
- Added `ConversionService` class to handle job queuing, processing, and cancellation. - Introduced `Job`, `JobLog`, and `JobResult` data classes to manage job details and results. - Implemented job status tracking with enums for better state management. - Created a web interface with HTML templates for job submission and monitoring. - Developed CSS styles for a modern UI layout and responsive design. - Added functionality for voice profile management in the voice mixer. - Implemented a Docker Compose configuration for GPU support. - Wrote unit tests for the conversion service to ensure job processing works as expected.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}abogen{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('web.static', filename='styles.css') }}">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-bar">
|
||||
<div class="brand">
|
||||
<span class="brand__mark">🔊</span>
|
||||
<span class="brand__name">abogen</span>
|
||||
<span class="brand__tagline">Audiobooks for humans, not desktops</span>
|
||||
</div>
|
||||
<nav class="top-actions">
|
||||
<a href="{{ url_for('web.index') }}" class="btn">Dashboard</a>
|
||||
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn">Voice mixer</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<span>Need help?</span>
|
||||
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1 class="card__title">Create a new audiobook</h1>
|
||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="source_file">Source file</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
||||
<p class="tag">You can also paste text below.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice">Voice</label>
|
||||
<select id="voice" name="voice">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}">{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice_profile">Voice profile</label>
|
||||
<select id="voice_profile" name="voice_profile">
|
||||
<option value="">Use selected voice</option>
|
||||
{% for name, data in options.voice_profiles %}
|
||||
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles') }}">voice mixer</a>.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice_formula">Custom voice formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6">
|
||||
<p class="tag">Overrides the dropdown/profile when provided.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_mode">Subtitle mode</label>
|
||||
<select id="subtitle_mode" name="subtitle_mode">
|
||||
<option value="Disabled">Disabled</option>
|
||||
<option value="Sentence">Sentence</option>
|
||||
<option value="Sentence + Comma">Sentence + Comma</option>
|
||||
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="output_format">Audio format</label>
|
||||
<select id="output_format" name="output_format">
|
||||
{% for fmt in options.output_formats %}
|
||||
<option value="{{ fmt }}">{{ fmt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_format">Subtitle file format</label>
|
||||
<select id="subtitle_format" name="subtitle_format">
|
||||
{% for value, text in options.subtitle_formats %}
|
||||
<option value="{{ value }}">{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="save_mode">Save location</label>
|
||||
<select id="save_mode" name="save_mode">
|
||||
<option>Save next to input file</option>
|
||||
<option>Save to Desktop</option>
|
||||
<option>Choose output folder</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true"> Replace single newlines</label>
|
||||
<label class="tag"><input type="checkbox" name="use_gpu" value="true" checked> Use GPU (when available)</label>
|
||||
</div>
|
||||
<details class="field">
|
||||
<summary>Advanced chapter & project options</summary>
|
||||
<div class="field inline">
|
||||
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true"> Save each chapter separately</label>
|
||||
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" checked> Also create merged audiobook</label>
|
||||
<label class="tag"><input type="checkbox" name="save_as_project" value="true"> Save in project folder with metadata</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="separate_chapters_format">Separate chapter format</label>
|
||||
<select id="separate_chapters_format" name="separate_chapters_format">
|
||||
{% for fmt in options.separate_formats %}
|
||||
<option value="{{ fmt }}">{{ fmt|upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
|
||||
<input type="number" step="0.5" min="0" value="2.0" id="silence_between_chapters" name="silence_between_chapters">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max_subtitle_words">Max words per subtitle entry</label>
|
||||
<input type="number" min="1" max="100" value="50" id="max_subtitle_words" name="max_subtitle_words">
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="source_text">Or paste text directly</label>
|
||||
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..."></textarea>
|
||||
</div>
|
||||
<div class="field" style="justify-content: flex-end;">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
|
||||
{% include "partials/jobs.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job · {{ job.original_filename }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__title">{{ job.original_filename }}</div>
|
||||
<div class="grid grid--two">
|
||||
<article>
|
||||
<h2>Settings</h2>
|
||||
<ul>
|
||||
<li><strong>Voice:</strong> {{ job.voice }}</li>
|
||||
<li><strong>Language:</strong> {{ options.languages.get(job.language, job.language) }}</li>
|
||||
{% if job.voice_profile %}
|
||||
<li><strong>Voice profile:</strong> {{ job.voice_profile }}</li>
|
||||
{% endif %}
|
||||
<li><strong>Speed:</strong> {{ '%.2f' | format(job.speed) }}×</li>
|
||||
<li><strong>Subtitle mode:</strong> {{ job.subtitle_mode }}</li>
|
||||
<li><strong>Audio format:</strong> {{ job.output_format }}</li>
|
||||
<li><strong>Subtitle format:</strong> {{ job.subtitle_format }}</li>
|
||||
<li><strong>GPU:</strong> {{ 'Yes' if job.use_gpu else 'No' }}</li>
|
||||
<li><strong>Save chapters separately:</strong> {{ 'Yes' if job.save_chapters_separately else 'No' }}</li>
|
||||
<li><strong>Merge at end:</strong> {{ 'Yes' if job.merge_chapters_at_end else 'No' }}</li>
|
||||
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
|
||||
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
|
||||
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
|
||||
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Status</h2>
|
||||
<p><span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span></p>
|
||||
<p>Created: {{ job.created_at|datetimeformat }}</p>
|
||||
<p>Started: {{ job.started_at|datetimeformat }}</p>
|
||||
<p>Finished: {{ job.finished_at|datetimeformat }}</p>
|
||||
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
|
||||
{% if job.result.audio_path %}
|
||||
<p><a class="button" href="{{ url_for('web.download_job', job_id=job.id) }}">Download audio</a></p>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post">
|
||||
<button type="submit" class="button button--ghost">Cancel job</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="logs" hx-get="{{ url_for('web.job_logs_partial', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML">
|
||||
{% include "partials/logs.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="card__title">Queue</div>
|
||||
{% if jobs %}
|
||||
<table class="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ job.original_filename }}</strong>
|
||||
<div class="tag">
|
||||
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<progress value="{{ job.processed_characters or 0 }}" max="{{ job.total_characters or 1 }}" class="progress"></progress>
|
||||
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
</td>
|
||||
<td><small>{{ job.created_at|datetimeformat }}</small></td>
|
||||
<td><a class="btn" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No jobs yet. Drop a file or paste text to get started.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card__title">Live log</div>
|
||||
{% if job.logs %}
|
||||
<ul class="log-list">
|
||||
{% for entry in job.logs|reverse %}
|
||||
<li class="log-item log-item--{{ entry.level }}">
|
||||
<small>{{ entry.timestamp|datetimeformat('%H:%M:%S') }}</small>
|
||||
<div>{{ entry.message }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No log entries yet. Check back soon!</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 %}
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user