Files
UmbraAudio/umbraaudio/generation/scene_break.py
B.D. Murphy bf83bb35cc Merge minieq/main: Apache-2.0 LICENSE, NOTICE and SPDX headers
The remote had one commit this branch did not (03f8a85), which adds the
Apache-2.0 LICENSE, the NOTICE and SPDX headers to every .py and .sh
file. The merge was clean - the headers sit at the top of each file and
my changes are further down.

The new files this branch added (languages.py, translations.py, the
cluster package, the new tests and scripts) were written after the
merge base, so the licence commit never saw them. They now carry the
same header, so the convention holds across the whole tree.

Nothing else changed. 883 tests passed / 8 failed - the same 8
pre-existing failures.
2026-11-06 17:12:24 -08:00

83 lines
2.7 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Scene-break (``<<<>>>``) audio handling.
A scene break is not spoken text; it marks a change of location, time, or
perspective. Historically these rows produced no audio at all, which left
behind 0-byte ``utt_XXXX.wav`` artifacts that broke the chapter stitcher
(``soundfile`` cannot open an empty file) and gave the utterance regen button
nothing to show. Every scene break now gets a fixed-length silence clip so
the final chapter audio carries the intended pause and the stitcher always has
a valid file to read.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
import soundfile as sf
if TYPE_CHECKING:
from pathlib import Path
from umbraaudio.generation.staleness import is_current, write_marker
SCENE_BREAK_TEXT = "<<<>>>"
SCENE_BREAK_SPEAKER = "scene_break"
SCENE_BREAK_SILENCE_MS = 2000
DEFAULT_SAMPLE_RATE = 24000
_WAV_HEADER_BYTES = 44
def is_scene_break(text: str | None, utt_type: str | None = None) -> bool:
"""True when an utterance is a scene break (by type or by its text)."""
if (utt_type or "").strip() == SCENE_BREAK_SPEAKER:
return True
return (text or "").strip() == SCENE_BREAK_TEXT
def _is_valid_wav(path: Path) -> bool:
try:
return path.exists() and path.stat().st_size > _WAV_HEADER_BYTES
except OSError:
return False
def needs_silence(path: Path) -> bool:
"""True when *path* is missing, empty/corrupt, or lacks a matching marker.
A 0-byte file (a historical artifact) must trigger a rewrite, not be
treated as "already generated".
"""
if not _is_valid_wav(path):
return True
return not is_current(SCENE_BREAK_TEXT, path, speaker=SCENE_BREAK_SPEAKER)
def write_scene_break_audio(
output_path: Path,
duration_ms: int = SCENE_BREAK_SILENCE_MS,
sample_rate: int = DEFAULT_SAMPLE_RATE,
) -> Path:
"""Write a silence wav (plus staleness marker) for a scene break.
The marker records no language on purpose. A scene break is silence, not
speech: it is the same clip whatever language the surrounding line is
written in, so tagging it would claim a phonology the audio does not have.
``needs_silence`` therefore does not compare languages either - retagging a
scene-break row must not force a regeneration of an identical silence.
"""
samples = int(sample_rate * duration_ms / 1000)
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_path.exists():
output_path.unlink()
sf.write(str(output_path), np.zeros(samples, dtype=np.float32), sample_rate)
write_marker(
SCENE_BREAK_TEXT,
output_path,
speaker=SCENE_BREAK_SPEAKER,
)
return output_path