diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 9003a52..793954f 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -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 diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 38e7446..ea90b7e 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -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)) diff --git a/abogen/web/service.py b/abogen/web/service.py index 33da8d8..936250a 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -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") diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index b23083f..7ae11c9 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -23,6 +23,7 @@
  • Merge at end: {{ 'Yes' if job.merge_chapters_at_end else 'No' }}
  • Separate chapter format: {{ job.separate_chapters_format|upper }}
  • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
  • +
  • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
  • diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 40d261a..7d57da6 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -29,6 +29,10 @@
    Characters
    {{ pending.total_characters|default(0) }}
    +
    +
    Chapter intro delay
    +
    {{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
    +
    {% if pending.metadata_tags %}
    @@ -105,6 +109,13 @@ {% endfor %}
    +
    +
    + + +

    Set to 0 to disable the pause after speaking each chapter title.

    +
    +
    diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 4708a34..f792090 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -86,6 +86,11 @@
    +
    + + +

    Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

    +