add root readme

This commit is contained in:
2026-05-07 13:04:40 -03:00
parent 946234eb9e
commit feb5ecd463
10 changed files with 919 additions and 6 deletions

98
cht/summary/audio.py Normal file
View File

@@ -0,0 +1,98 @@
"""Assemble a single WAV file covering the entire session audio.
Prefers the recording source (fMP4 or raw AAC) over the live-extracted
WAV chunks: a single decode pass gives whisperx contiguous audio with no
chunk-boundary artifacts. Chunks are a fallback when the recording source
is missing.
"""
import logging
import tempfile
from pathlib import Path
import ffmpeg
from cht.stream import ffmpeg as ff
log = logging.getLogger(__name__)
def assemble_session_wav(session_dir: Path, *, force: bool = False) -> Path:
"""Build `summary/full.wav` covering the whole session audio.
Returns the cached path if already present and `force` is False.
Raises FileNotFoundError if no usable audio source exists.
"""
summary_dir = session_dir / "summary"
summary_dir.mkdir(parents=True, exist_ok=True)
out = summary_dir / "full.wav"
if out.exists() and not force:
log.info("assemble_session_wav: cached %s", out)
return out
stream_dir = session_dir / "stream"
# 1. Rust transport: standalone audio.aac.
aac = stream_dir / "audio.aac"
if aac.exists() and aac.stat().st_size > 100:
ff.extract_audio_chunk(aac, out)
log.info("assemble_session_wav: from audio.aac → %s", out)
return out
# 2. fMP4 segments (Python transport). Single segment is the common case.
segments = sorted(stream_dir.glob("recording_*.mp4")) if stream_dir.exists() else []
if len(segments) == 1:
ff.extract_audio_chunk(segments[0], out)
log.info("assemble_session_wav: from %s%s", segments[0].name, out)
return out
if len(segments) > 1:
_concat_segments_audio(segments, out)
log.info("assemble_session_wav: concatenated %d segments → %s", len(segments), out)
return out
# 3. Fallback: concat the live audio chunks. Last resort — chunk seams may
# introduce minor artifacts; whisperx still works but precision can suffer.
audio_dir = session_dir / "audio"
chunks = sorted(audio_dir.glob("chunk_*.wav")) if audio_dir.exists() else []
if chunks:
log.warning("assemble_session_wav: no recording source, falling back to %d chunks", len(chunks))
_concat_chunks(chunks, out)
return out
raise FileNotFoundError(f"No audio source found in {session_dir}")
def _concat_segments_audio(segments: list[Path], out: Path) -> None:
"""Decode + concatenate audio tracks from multiple fMP4 segments into 16kHz mono WAV."""
inputs = [ffmpeg.input(str(p)) for p in segments]
audio_streams = [s.audio for s in inputs]
node = (
ffmpeg.concat(*audio_streams, v=0, a=1)
.output(str(out), acodec="pcm_s16le", ac=1, ar=16000)
.overwrite_output()
.global_args("-hide_banner", "-loglevel", "warning")
)
log.info("concat_segments_audio: %s", " ".join(node.compile()))
node.run(capture_stdout=True, capture_stderr=True)
def _concat_chunks(chunks: list[Path], out: Path) -> None:
"""Concat already-PCM 16kHz mono WAV files via the concat demuxer (no re-decode)."""
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
listfile = Path(f.name)
for c in chunks:
f.write(f"file '{c.resolve()}'\n")
try:
node = (
ffmpeg.input(str(listfile), format="concat", safe=0)
.output(str(out), c="copy")
.overwrite_output()
.global_args("-hide_banner", "-loglevel", "warning")
)
log.info("concat_chunks: %s", " ".join(node.compile()))
node.run(capture_stdout=True, capture_stderr=True)
finally:
try:
listfile.unlink()
except OSError:
pass