mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add chapter intro delay setting and implement in conversion process
This commit is contained in:
+145
-69
@@ -204,6 +204,18 @@ def _escape_ffmetadata_value(value: str) -> str:
|
||||
return escaped
|
||||
|
||||
|
||||
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
|
||||
args: List[str] = []
|
||||
for key, value in (metadata or {}).items():
|
||||
if value in (None, ""):
|
||||
continue
|
||||
key_str = str(key).strip()
|
||||
if not key_str:
|
||||
continue
|
||||
args.extend(["-metadata", f"{key_str}={value}"])
|
||||
return args
|
||||
|
||||
|
||||
def _render_ffmetadata(metadata: Dict[str, Any], chapters: List[Dict[str, Any]]) -> str:
|
||||
lines: List[str] = [";FFMETADATA1"]
|
||||
for key, value in (metadata or {}).items():
|
||||
@@ -274,10 +286,12 @@ def _embed_m4b_metadata(
|
||||
if candidate.exists():
|
||||
cover_path = candidate
|
||||
|
||||
if not ffmetadata_path and not cover_path:
|
||||
metadata_args = _metadata_to_ffmpeg_args(metadata_map)
|
||||
|
||||
if not ffmetadata_path and not cover_path and not metadata_args:
|
||||
return
|
||||
|
||||
job.add_log("Embedding chapters and cover metadata into m4b output")
|
||||
job.add_log("Embedding metadata into m4b output")
|
||||
|
||||
command: List[str] = ["ffmpeg", "-y", "-i", str(audio_path)]
|
||||
metadata_index: Optional[int] = None
|
||||
@@ -311,6 +325,9 @@ def _embed_m4b_metadata(
|
||||
else:
|
||||
command += ["-map_metadata", "0"]
|
||||
|
||||
if metadata_args:
|
||||
command.extend(metadata_args)
|
||||
|
||||
command += ["-movflags", "+faststart+use_metadata_tags"]
|
||||
|
||||
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
|
||||
@@ -332,6 +349,7 @@ def _embed_m4b_metadata(
|
||||
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
|
||||
|
||||
temp_output.replace(audio_path)
|
||||
job.add_log("Embedded metadata and chapters into m4b output", level="info")
|
||||
|
||||
|
||||
def run_conversion_job(job: Job) -> None:
|
||||
@@ -408,6 +426,83 @@ def run_conversion_job(job: Job) -> None:
|
||||
total_chapters = len(extraction.chapters)
|
||||
job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}")
|
||||
|
||||
def emit_text(
|
||||
text: str,
|
||||
*,
|
||||
voice_choice: Any,
|
||||
chapter_sink: Optional[AudioSink],
|
||||
preview_prefix: Optional[str] = None,
|
||||
split_pattern: Optional[str] = SPLIT_PATTERN,
|
||||
) -> int:
|
||||
nonlocal processed_chars, subtitle_index, current_time
|
||||
normalized = _normalize_for_pipeline(text)
|
||||
local_segments = 0
|
||||
|
||||
for segment in pipeline(
|
||||
normalized,
|
||||
voice=voice_choice,
|
||||
speed=job.speed,
|
||||
split_pattern=split_pattern,
|
||||
):
|
||||
canceller()
|
||||
graphemes_raw = getattr(segment, "graphemes", "") or ""
|
||||
graphemes = graphemes_raw.strip()
|
||||
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
|
||||
local_segments += 1
|
||||
if chapter_sink:
|
||||
chapter_sink.write(audio)
|
||||
if audio_sink:
|
||||
audio_sink.write(audio)
|
||||
|
||||
duration = len(audio) / SAMPLE_RATE
|
||||
processed_chars += len(graphemes)
|
||||
job.processed_characters = processed_chars
|
||||
if job.total_characters:
|
||||
job.progress = min(processed_chars / job.total_characters, 0.999)
|
||||
else:
|
||||
job.progress = 0.0 if processed_chars == 0 else 0.999
|
||||
|
||||
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
|
||||
prefix = f"{preview_prefix} · " if preview_prefix else ""
|
||||
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}")
|
||||
|
||||
if subtitle_writer and audio_sink and graphemes:
|
||||
subtitle_writer.write_segment(
|
||||
index=subtitle_index,
|
||||
text=graphemes,
|
||||
start=current_time,
|
||||
end=current_time + duration,
|
||||
)
|
||||
subtitle_index += 1
|
||||
|
||||
if audio_sink:
|
||||
current_time += duration
|
||||
|
||||
return local_segments
|
||||
|
||||
def append_silence(
|
||||
duration_seconds: float,
|
||||
*,
|
||||
include_in_chapter: bool,
|
||||
chapter_sink: Optional[AudioSink],
|
||||
) -> None:
|
||||
nonlocal current_time
|
||||
if duration_seconds <= 0:
|
||||
return
|
||||
samples = int(round(duration_seconds * SAMPLE_RATE))
|
||||
if samples <= 0:
|
||||
return
|
||||
silence = np.zeros(samples, dtype="float32")
|
||||
if include_in_chapter and chapter_sink:
|
||||
chapter_sink.write(silence)
|
||||
if audio_sink:
|
||||
audio_sink.write(silence)
|
||||
current_time += duration_seconds
|
||||
|
||||
for idx, chapter in enumerate(extraction.chapters, start=1):
|
||||
canceller()
|
||||
job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}")
|
||||
@@ -425,71 +520,54 @@ def run_conversion_job(job: Job) -> None:
|
||||
voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu)
|
||||
voice_cache[chapter_voice_spec] = voice_choice
|
||||
|
||||
chapter_sink_stack = ExitStack()
|
||||
chapter_sink: Optional[AudioSink] = None
|
||||
chapter_audio_path: Optional[Path] = None
|
||||
|
||||
if chapter_dir is not None:
|
||||
chapter_audio_path = _build_output_path(
|
||||
chapter_dir,
|
||||
f"{Path(job.original_filename).stem}_{_slugify(chapter.title, idx)}",
|
||||
job.separate_chapters_format,
|
||||
)
|
||||
chapter_sink = _open_audio_sink(
|
||||
chapter_audio_path,
|
||||
job,
|
||||
chapter_sink_stack,
|
||||
fmt=job.separate_chapters_format,
|
||||
)
|
||||
|
||||
segments_emitted = 0
|
||||
tts_input = _normalize_for_pipeline(chapter.text)
|
||||
|
||||
for segment in pipeline(
|
||||
tts_input,
|
||||
voice=voice_choice,
|
||||
speed=job.speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
):
|
||||
canceller()
|
||||
graphemes_raw = getattr(segment, "graphemes", "") or ""
|
||||
graphemes = graphemes_raw.strip()
|
||||
with ExitStack() as chapter_sink_stack:
|
||||
chapter_sink: Optional[AudioSink] = None
|
||||
|
||||
audio = _to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
|
||||
segments_emitted += 1
|
||||
if chapter_sink:
|
||||
chapter_sink.write(audio)
|
||||
if audio_sink:
|
||||
audio_sink.write(audio)
|
||||
|
||||
duration = len(audio) / SAMPLE_RATE
|
||||
processed_chars += len(graphemes)
|
||||
job.processed_characters = processed_chars
|
||||
if job.total_characters:
|
||||
job.progress = min(processed_chars / job.total_characters, 0.999)
|
||||
else:
|
||||
job.progress = 0.0 if processed_chars == 0 else 0.999
|
||||
|
||||
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
|
||||
job.add_log(f"{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}")
|
||||
|
||||
if subtitle_writer and audio_sink and graphemes:
|
||||
subtitle_writer.write_segment(
|
||||
index=subtitle_index,
|
||||
text=graphemes,
|
||||
start=current_time,
|
||||
end=current_time + duration,
|
||||
if chapter_dir is not None:
|
||||
chapter_audio_path = _build_output_path(
|
||||
chapter_dir,
|
||||
f"{Path(job.original_filename).stem}_{_slugify(chapter.title, idx)}",
|
||||
job.separate_chapters_format,
|
||||
)
|
||||
chapter_sink = _open_audio_sink(
|
||||
chapter_audio_path,
|
||||
job,
|
||||
chapter_sink_stack,
|
||||
fmt=job.separate_chapters_format,
|
||||
)
|
||||
subtitle_index += 1
|
||||
|
||||
if audio_sink:
|
||||
current_time += duration
|
||||
speak_heading = bool(chapter.title.strip())
|
||||
if speak_heading:
|
||||
stripped_title = chapter.title.strip()
|
||||
if stripped_title:
|
||||
first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "")
|
||||
if first_line and first_line.casefold() == stripped_title.casefold():
|
||||
speak_heading = False
|
||||
|
||||
if chapter_sink:
|
||||
chapter_sink_stack.close()
|
||||
if speak_heading:
|
||||
heading_segments = emit_text(
|
||||
chapter.title,
|
||||
voice_choice=voice_choice,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_prefix=f"Chapter {idx} title",
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
)
|
||||
segments_emitted += heading_segments
|
||||
if heading_segments > 0 and job.chapter_intro_delay > 0:
|
||||
append_silence(
|
||||
job.chapter_intro_delay,
|
||||
include_in_chapter=True,
|
||||
chapter_sink=chapter_sink,
|
||||
)
|
||||
|
||||
segments_emitted += emit_text(
|
||||
chapter.text,
|
||||
voice_choice=voice_choice,
|
||||
chapter_sink=chapter_sink,
|
||||
)
|
||||
|
||||
chapter_end_time = current_time
|
||||
|
||||
@@ -511,12 +589,12 @@ def run_conversion_job(job: Job) -> None:
|
||||
and idx < total_chapters
|
||||
and job.silence_between_chapters > 0
|
||||
):
|
||||
silence_samples = int(job.silence_between_chapters * SAMPLE_RATE)
|
||||
if silence_samples > 0:
|
||||
silence = np.zeros(silence_samples, dtype="float32")
|
||||
audio_sink.write(silence)
|
||||
current_time += job.silence_between_chapters
|
||||
chapter_end_time = current_time
|
||||
append_silence(
|
||||
job.silence_between_chapters,
|
||||
include_in_chapter=False,
|
||||
chapter_sink=None,
|
||||
)
|
||||
chapter_end_time = current_time
|
||||
|
||||
chapter_markers.append(
|
||||
{
|
||||
@@ -730,9 +808,7 @@ def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str
|
||||
base += ["-c:a", "copy"]
|
||||
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value:
|
||||
base += ["-metadata", f"{key}={value}"]
|
||||
base.extend(_metadata_to_ffmpeg_args(metadata))
|
||||
base.append(str(path))
|
||||
return base
|
||||
|
||||
|
||||
+21
-1
@@ -135,7 +135,7 @@ BOOLEAN_SETTINGS = {
|
||||
"save_as_project",
|
||||
}
|
||||
|
||||
FLOAT_SETTINGS = {"silence_between_chapters"}
|
||||
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"}
|
||||
INT_SETTINGS = {"max_subtitle_words"}
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ def _settings_defaults() -> Dict[str, Any]:
|
||||
"save_as_project": False,
|
||||
"separate_chapters_format": "wav",
|
||||
"silence_between_chapters": 2.0,
|
||||
"chapter_intro_delay": 0.5,
|
||||
"max_subtitle_words": 50,
|
||||
}
|
||||
|
||||
@@ -420,6 +421,9 @@ def settings_page() -> Response | str:
|
||||
updated["silence_between_chapters"] = _coerce_float(
|
||||
form.get("silence_between_chapters"), defaults["silence_between_chapters"]
|
||||
)
|
||||
updated["chapter_intro_delay"] = _coerce_float(
|
||||
form.get("chapter_intro_delay"), defaults["chapter_intro_delay"]
|
||||
)
|
||||
updated["max_subtitle_words"] = _coerce_int(
|
||||
form.get("max_subtitle_words"), defaults["max_subtitle_words"]
|
||||
)
|
||||
@@ -801,6 +805,7 @@ def enqueue_job() -> Response:
|
||||
save_as_project = settings["save_as_project"]
|
||||
separate_chapters_format = settings["separate_chapters_format"]
|
||||
silence_between_chapters = settings["silence_between_chapters"]
|
||||
chapter_intro_delay = settings["chapter_intro_delay"]
|
||||
max_subtitle_words = settings["max_subtitle_words"]
|
||||
|
||||
pending = PendingJob(
|
||||
@@ -830,6 +835,7 @@ def enqueue_job() -> Response:
|
||||
created_at=time.time(),
|
||||
cover_image_path=cover_path,
|
||||
cover_image_mime=cover_mime,
|
||||
chapter_intro_delay=chapter_intro_delay,
|
||||
)
|
||||
|
||||
service.store_pending_job(pending)
|
||||
@@ -854,6 +860,19 @@ def finalize_job(pending_id: str) -> Response:
|
||||
pending = cast(PendingJob, pending)
|
||||
|
||||
profiles = serialize_profiles()
|
||||
delay_value = pending.chapter_intro_delay
|
||||
raw_delay = request.form.get("chapter_intro_delay")
|
||||
if raw_delay is not None:
|
||||
raw_normalized = raw_delay.strip()
|
||||
if raw_normalized:
|
||||
try:
|
||||
delay_value = max(0.0, float(raw_normalized))
|
||||
except ValueError:
|
||||
return _render_prepare_page(pending, error="Enter a valid number for the chapter intro delay.")
|
||||
else:
|
||||
delay_value = 0.0
|
||||
pending.chapter_intro_delay = delay_value
|
||||
|
||||
overrides: List[Dict[str, Any]] = []
|
||||
selected_total = 0
|
||||
errors: List[str] = []
|
||||
@@ -941,6 +960,7 @@ def finalize_job(pending_id: str) -> Response:
|
||||
max_subtitle_words=pending.max_subtitle_words,
|
||||
cover_image_path=pending.cover_image_path,
|
||||
cover_image_mime=pending.cover_image_mime,
|
||||
chapter_intro_delay=pending.chapter_intro_delay,
|
||||
)
|
||||
|
||||
return redirect(url_for("web.job_detail", job_id=job.id))
|
||||
|
||||
@@ -19,7 +19,7 @@ def _create_set_event() -> threading.Event:
|
||||
return event
|
||||
|
||||
|
||||
STATE_VERSION = 2
|
||||
STATE_VERSION = 3
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
@@ -69,6 +69,7 @@ class Job:
|
||||
voice_profile: Optional[str] = None
|
||||
metadata_tags: Dict[str, str] = field(default_factory=dict)
|
||||
max_subtitle_words: int = 50
|
||||
chapter_intro_delay: float = 0.5
|
||||
status: JobStatus = JobStatus.PENDING
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
@@ -119,6 +120,7 @@ class Job:
|
||||
"save_as_project": self.save_as_project,
|
||||
"voice_profile": self.voice_profile,
|
||||
"max_subtitle_words": self.max_subtitle_words,
|
||||
"chapter_intro_delay": self.chapter_intro_delay,
|
||||
},
|
||||
"metadata_tags": dict(self.metadata_tags),
|
||||
"chapters": [
|
||||
@@ -167,6 +169,7 @@ class PendingJob:
|
||||
created_at: float
|
||||
cover_image_path: Optional[Path] = None
|
||||
cover_image_mime: Optional[str] = None
|
||||
chapter_intro_delay: float = 0.5
|
||||
|
||||
|
||||
class ConversionService:
|
||||
@@ -230,6 +233,7 @@ class ConversionService:
|
||||
metadata_tags: Optional[Mapping[str, Any]] = None,
|
||||
cover_image_path: Optional[Path] = None,
|
||||
cover_image_mime: Optional[str] = None,
|
||||
chapter_intro_delay: float = 0.5,
|
||||
) -> Job:
|
||||
job_id = uuid.uuid4().hex
|
||||
normalized_metadata = self._normalize_metadata_tags(metadata_tags)
|
||||
@@ -263,6 +267,7 @@ class ConversionService:
|
||||
chapters=normalized_chapters,
|
||||
cover_image_path=cover_image_path,
|
||||
cover_image_mime=cover_image_mime,
|
||||
chapter_intro_delay=chapter_intro_delay,
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job_id] = job
|
||||
@@ -528,6 +533,7 @@ class ConversionService:
|
||||
"resume_token": job.resume_token,
|
||||
"cover_image_path": str(job.cover_image_path) if job.cover_image_path else None,
|
||||
"cover_image_mime": job.cover_image_mime,
|
||||
"chapter_intro_delay": job.chapter_intro_delay,
|
||||
}
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
@@ -573,6 +579,7 @@ class ConversionService:
|
||||
voice_profile=payload.get("voice_profile"),
|
||||
metadata_tags=payload.get("metadata_tags", {}),
|
||||
max_subtitle_words=int(payload.get("max_subtitle_words", 50)),
|
||||
chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)),
|
||||
)
|
||||
job.status = JobStatus(payload.get("status", job.status.value))
|
||||
job.started_at = payload.get("started_at")
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<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>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}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>
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<dt>Characters</dt>
|
||||
<dd>{{ pending.total_characters|default(0) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Chapter intro delay</dt>
|
||||
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% if pending.metadata_tags %}
|
||||
<div class="prepare-metadata">
|
||||
@@ -105,6 +109,13 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="prepare-options">
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prepare-actions">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
|
||||
@@ -86,6 +86,11 @@
|
||||
<label for="silence_between_chapters">Silence Between Chapters (Seconds)</label>
|
||||
<input type="number" step="0.5" min="0" id="silence_between_chapters" name="silence_between_chapters" value="{{ settings.silence_between_chapters }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause After Chapter Titles (Seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
|
||||
<p class="hint">Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max_subtitle_words">Max Words Per Subtitle Entry</label>
|
||||
<input type="number" min="1" max="200" id="max_subtitle_words" name="max_subtitle_words" value="{{ settings.max_subtitle_words }}">
|
||||
|
||||
Reference in New Issue
Block a user