Files
UnheardCity/ackit/audio.py
Cristian Gora 1ccaa8b398 Full correctness check against the docs: safer writes, reverts and texts
Safety
- Writes are serialized (Workspace.lock): two background jobs rebuilding one
  package (a sound and its subtitle applied side by side) lost an edit.
- A store copies a bank file or package as "original" only if it matches the
  shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of
  voice lines, subtitles); the editor refuses to switch stores while changes
  are applied. A second store could otherwise resurrect a reverted sound.
- Mod import in the editor asks before overwriting files changed outside the
  tool (it forced silently).
- Revert all in the editor reverts texts too; the uninstaller counts text
  edits before offering to delete the store.
- ackit restore-backup: the full-backup restore decisions.md described; it
  keeps the sounds of WADs without a full copy recorded.
- build_catalog also stops when a subtitle differs from the catalog.

Languages and texts
- Game.language() returns languages without voices (JPN, RUS, ...) and
  Game.voice_archive() their INT voices; ackit text --lang JPN works.
- Menu lookups parse one cached file (find & replace: minutes -> ms).
- Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line
  Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte;
  SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed).
- Mods exported while another language is loaded keep name and line.

Other fixes
- Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped).
- e2e_editor no longer overwrites the user's settings.json; kill_game waits
  for the game to exit before reverts.
- Recorder handles Int32/UInt8 input and split frames; package block cache
  locked; conflict messages, CLI help examples, stale docstrings and doc
  references fixed; tests no longer read this PC's game language.

Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README,
REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests;
e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
2026-10-13 21:34:13 +02:00

142 lines
4.6 KiB
Python

"""Portable audio helpers (no external programs needed).
Audio is handled as float32 numpy arrays shaped (frames, channels).
Decoding uses libsndfile (WAV, FLAC, OGG/Vorbis/Opus, MP3, AIFF, ...); other formats (M4A, WMA, ...)
fall back to an ffmpeg found on the system, if any. Resampling uses soxr.
"""
import io
import os
import shutil
import subprocess
import numpy as np
import soundfile as sf
class AudioError(Exception):
pass
def _as2d(x):
x = np.asarray(x, dtype=np.float32)
return x[:, None] if x.ndim == 1 else x
def load(path):
"""Read an audio file -> (float32 array (n, ch), rate)."""
try:
x, sr = sf.read(path, dtype="float32", always_2d=True)
return x, sr
except (sf.LibsndfileError, RuntimeError, TypeError) as e:
ff = shutil.which("ffmpeg")
if not ff:
raise AudioError("Cannot read '%s' (%s). Supported: WAV, FLAC, OGG, OPUS, MP3, AIFF. "
"Convert the file to one of these, or install ffmpeg for other formats."
% (os.path.basename(path), e))
raw = subprocess.run([ff, "-v", "error", "-i", path, "-f", "wav", "-"], capture_output=True)
if raw.returncode:
raise AudioError("ffmpeg could not read '%s': %s" % (path, raw.stderr.decode(errors="replace")[-300:]))
x, sr = sf.read(io.BytesIO(raw.stdout), dtype="float32", always_2d=True)
return x, sr
def save_wav(path, x, rate):
sf.write(path, _as2d(x), rate, subtype="PCM_16")
def resample(x, rate, new_rate):
x = _as2d(x)
if rate == new_rate or len(x) == 0:
return x
import soxr
return soxr.resample(x, rate, new_rate, quality="HQ").astype(np.float32)
def to_channels(x, channels):
"""Up/down-mix. Up: mono is copied to every channel, other sources keep their channels and the new ones get
the average. Down: nothing is dropped - every channel goes into the mono sum, or the channels beyond the
kept ones (e.g. the centre and surrounds of 5.1) are added to every kept one at -3 dB (the standard
fold-down); a result louder than the source's peak is scaled back to it, so it does not clip."""
x = _as2d(x)
cur = x.shape[1]
if cur == channels:
return x
if cur == 1:
return np.repeat(x, channels, axis=1)
if channels > cur:
out = np.empty((len(x), channels), dtype=np.float32)
out[:, :cur] = x
out[:, cur:] = x.mean(axis=1, keepdims=True)
return out
if channels == 1:
out = x.sum(axis=1, keepdims=True)
else:
out = x[:, :channels] + 0.7071 * x[:, channels:].sum(axis=1, keepdims=True)
peak_in, peak_out = float(np.max(np.abs(x), initial=0.0)), float(np.max(np.abs(out), initial=0.0))
if peak_out > peak_in > 0:
out = out * (peak_in / peak_out)
return out.astype(np.float32)
def trim(x, rate, start=None, end=None):
x = _as2d(x)
a = int(round((start or 0) * rate))
b = len(x) if end is None else int(round(end * rate))
return x[max(0, a):max(a, min(len(x), b))]
def gain(x, db):
return (_as2d(x) * (10 ** (db / 20.0))).astype(np.float32)
def peak_db(x):
p = float(np.max(np.abs(x))) if len(x) else 0.0
return -120.0 if p <= 0 else 20 * np.log10(p)
def rms_db(x):
r = float(np.sqrt(np.mean(np.square(x)))) if len(x) else 0.0
return -120.0 if r <= 0 else 20 * np.log10(r)
def normalize(x, peak=-1.0):
"""Scale so the highest peak is `peak` dBFS."""
p = peak_db(x)
return _as2d(x) if p <= -119 else gain(x, peak - p)
def match_loudness(x, reference):
"""Scale x to the RMS loudness of `reference` (limited so it does not clip)."""
g = rms_db(reference) - rms_db(x)
g = min(g, -0.3 - peak_db(x))
return gain(x, g)
def trim_silence(x, threshold_db=-50.0, pad=0.02, rate=48000):
x = _as2d(x)
lvl = np.max(np.abs(x), axis=1)
idx = np.nonzero(lvl > 10 ** (threshold_db / 20.0))[0]
if len(idx) == 0:
return x
p = int(pad * rate)
return x[max(0, idx[0] - p):min(len(x), idx[-1] + p)]
def conform(x, rate, new_rate, channels):
"""Resample + channel conversion + clip guard, ready for encoding."""
y = to_channels(resample(x, rate, new_rate), channels)
return np.clip(y, -1.0, 1.0)
def decode_wem(data: bytes):
"""Decode a game WEM -> (float32 array (n, ch), rate), using the game's codebooks."""
from .convert import wem_to_ogg
from .wem import Wem
ogg, _ = wem_to_ogg(Wem.parse(data))
x, sr = sf.read(io.BytesIO(ogg), dtype="float32", always_2d=True)
return x, sr
def duration(x, rate):
return len(x) / float(rate) if rate else 0.0