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.
83 lines
2.7 KiB
Python
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
|