feat: Add chapter intro delay setting and implement in conversion process

This commit is contained in:
JB
2025-10-07 07:07:08 -07:00
parent ea1c7bd93e
commit 0dd74412d1
6 changed files with 191 additions and 71 deletions
+132 -56
View File
@@ -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,9 +520,11 @@ 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
segments_emitted = 0
with ExitStack() as chapter_sink_stack:
chapter_sink: Optional[AudioSink] = None
if chapter_dir is not None:
chapter_audio_path = _build_output_path(
@@ -442,54 +539,35 @@ def run_conversion_job(job: Job) -> None:
fmt=job.separate_chapters_format,
)
segments_emitted = 0
tts_input = _normalize_for_pipeline(chapter.text)
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
for segment in pipeline(
tts_input,
voice=voice_choice,
speed=job.speed,
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,
):
canceller()
graphemes_raw = getattr(segment, "graphemes", "") or ""
graphemes = graphemes_raw.strip()
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,
)
subtitle_index += 1
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,
)
if audio_sink:
current_time += duration
if chapter_sink:
chapter_sink_stack.close()
segments_emitted += emit_text(
chapter.text,
voice_choice=voice_choice,
chapter_sink=chapter_sink,
)
chapter_end_time = current_time
@@ -511,11 +589,11 @@ 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
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
View File
@@ -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))
+8 -1
View File
@@ -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")
+1
View File
@@ -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>
+11
View File
@@ -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>
+5
View File
@@ -87,6 +87,11 @@
<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 }}">
</div>