tooling/db/ (a misnamed directory: connectors, not database work),
trellis-batch.sh and synth_ui_sounds.py become `reach assets`:
audio {health,generate,batch,post {convert,normalize,trim,pipeline}},
image {health,generate}, trellis {health,generate,batch}, and synth-ui.
The four audio bash wrappers are retired, and tooling/db/ is gone.
Parity, from baselines taken before anything moved:
- the four UI-sound WAVs and the harmonic-synth WAVs (exponential and linear
decay) are byte-identical
- the ffmpeg pipeline's decoded PCM is identical. Its .ogg bytes are not,
even between two runs of the OLD code: Ogg picks a random stream serial,
so the encoded file was never the right thing to compare
- the network success paths can't be run in a gate (Stable Audio and Trellis
are kept off, Gemini costs money), so tooling/test_assets.py stands up a
fake Gradio and pins every payload: the audio submit, Trellis's six-call
session sequence with its 9-input image_to_3d, and the Gemini body. It
failed when one Trellis value was mutated (7.5 → 7.0)
Failure classification, in endpoints.py, is the point of the port. The
services are OFF by design (VRAM on tower-of-joy, D-17), and the topology doc
warns against "fixing" one by restarting it. So a refused connection says OFF
and asks for the service to be turned on rather than restarted; a 4xx/5xx says
the request was rejected; 401/403 says credentials; 429 says quota; and an
unreachable Gemini blames the network, not VRAM.
Behaviour changes, each a failure that used to read as success or crash:
- audio batch and trellis batch exited 0 with failures in their summaries;
they now print the summary and exit 1
- trellis generate on a missing image crashed with a TypeError
(print(..., indent=2)); it now names the file, and checks it before the
service so a typo is not reported as an outage
- the ffmpeg pipeline left its intermediates behind when a step failed
Structure: the connectors called each other as subprocesses (batch spawned
the connector, which spawned audio_post) and parsed each other's stdout. They
are now function calls, and ffmpeg is the only exec, through core/process.
ensure_venv() is removed: it os.execv'd into .venv, which D-263's exec rule
forbids, and reach declares the dependencies itself. config.json moved into
the domain deliberately, and the local-services rule follows it.
Output contract: results are still JSON on stdout with the same keys, so skill
readers keep working. Failures are an exit status with a Fix line, never
{"ok": false}. The audio-gen, glb-gen and image-gen skills, Araminta's agent
file and the allow-list are updated to match. glb-gen's "trellis-batch.sh is
hardcoded to one category" caveat is gone: batch takes --input-dir or --names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.1 KiB
Python
114 lines
4.1 KiB
Python
"""Audio post-processing around ffmpeg.
|
|
|
|
convert — WAV to OGG (libvorbis, quality 6)
|
|
normalize — LUFS normalize to -16 LUFS (broadcast standard)
|
|
trim — remove leading/trailing silence
|
|
pipeline — trim + normalize + convert (the full post-processing chain)
|
|
|
|
Every operation writes a new file and never overwrites its input. Each returns
|
|
a result dict — the same keys the old script printed as JSON — so callers read
|
|
data rather than parse output. ffmpeg runs through `core/process.run`, the one
|
|
guarded exec (D-263): a non-zero exit becomes a ReachError naming the command,
|
|
and a missing ffmpeg says what to install.
|
|
|
|
The ffmpeg argument lists are unchanged from tooling/db/audio_post.py (T-1290);
|
|
the decoded audio of a pipeline run is identical before and after the port.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from tooling.core import console, process
|
|
|
|
FFMPEG_MISSING = "install ffmpeg (brew install ffmpeg), or check PATH in a non-interactive shell"
|
|
|
|
|
|
def trim_filter(threshold: int) -> str:
|
|
"""The silence-removal filter chain: trim the start, reverse, trim, reverse."""
|
|
return (
|
|
"silenceremove=start_periods=1:start_silence=0.05"
|
|
f":start_threshold={threshold}dB,"
|
|
"areverse,"
|
|
"silenceremove=start_periods=1:start_silence=0.05"
|
|
f":start_threshold={threshold}dB,"
|
|
"areverse"
|
|
)
|
|
|
|
|
|
def ffmpeg_argv(args: list[str]) -> list[str]:
|
|
"""The full argv for one ffmpeg call — pure, so it can be tested unrun."""
|
|
return ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *args]
|
|
|
|
|
|
def run_ffmpeg(args: list[str], description: str) -> None:
|
|
console.event(description)
|
|
process.run(ffmpeg_argv(args), missing_fix=FFMPEG_MISSING)
|
|
|
|
|
|
def convert(input_path: str, output: str | None = None, quality: int = 6) -> dict:
|
|
"""WAV → OGG (libvorbis)."""
|
|
output = output or input_path.rsplit(".", 1)[0] + ".ogg"
|
|
run_ffmpeg(
|
|
["-i", input_path, "-c:a", "libvorbis", "-q:a", str(quality), output],
|
|
f"converting {os.path.basename(input_path)} → {os.path.basename(output)}",
|
|
)
|
|
return {"ok": True, "output": output, "size_bytes": os.path.getsize(output)}
|
|
|
|
|
|
def normalize(input_path: str, output: str | None = None, lufs: float = -16) -> dict:
|
|
"""LUFS-normalize an audio file."""
|
|
output = output or _suffixed(input_path, "_norm")
|
|
run_ffmpeg(
|
|
["-i", input_path, "-af", f"loudnorm=I={lufs}:LRA=11:TP=-1", output],
|
|
f"normalizing to {lufs} LUFS",
|
|
)
|
|
return {"ok": True, "output": output}
|
|
|
|
|
|
def trim(input_path: str, output: str | None = None, threshold: int = -50) -> dict:
|
|
"""Trim leading and trailing silence."""
|
|
output = output or _suffixed(input_path, "_trimmed")
|
|
run_ffmpeg(
|
|
["-i", input_path, "-af", trim_filter(threshold), output],
|
|
f"trimming silence (threshold: {threshold}dB)",
|
|
)
|
|
return {"ok": True, "output": output}
|
|
|
|
|
|
def pipeline(
|
|
input_path: str,
|
|
output: str | None = None,
|
|
quality: int = 6,
|
|
lufs: float = -16,
|
|
threshold: int = -50,
|
|
) -> dict:
|
|
"""Full post-processing: trim → normalize → convert to OGG."""
|
|
base = input_path.rsplit(".", 1)[0]
|
|
trimmed = base + "_trimmed.wav"
|
|
normalized = base + "_norm.wav"
|
|
output = output or base + ".ogg"
|
|
|
|
try:
|
|
run_ffmpeg(["-i", input_path, "-af", trim_filter(threshold), trimmed], "step 1/3: trimming silence")
|
|
run_ffmpeg(
|
|
["-i", trimmed, "-af", f"loudnorm=I={lufs}:LRA=11:TP=-1", normalized],
|
|
f"step 2/3: normalizing to {lufs} LUFS",
|
|
)
|
|
run_ffmpeg(
|
|
["-i", normalized, "-c:a", "libvorbis", "-q:a", str(quality), output],
|
|
"step 3/3: converting to OGG",
|
|
)
|
|
finally:
|
|
# The old script left the intermediates behind when a step failed.
|
|
for leftover in (trimmed, normalized):
|
|
if os.path.exists(leftover):
|
|
os.remove(leftover)
|
|
|
|
return {"ok": True, "output": output, "size_bytes": os.path.getsize(output)}
|
|
|
|
|
|
def _suffixed(path: str, suffix: str) -> str:
|
|
base, ext = os.path.splitext(path)
|
|
return base + suffix + ext
|