Initial commit
This commit is contained in:
155
ackit/encoder.py
Normal file
155
ackit/encoder.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Encode audio into game-compatible WEMs (portable: libvorbis via libsndfile, no Wwise, no ffmpeg).
|
||||
|
||||
Pipeline: float32 PCM -> libvorbis (Ogg) -> `builder.ogg_to_wem` (Wwise 2011.1 layout, game codebook
|
||||
IDs, FNV-1 hash, decode-alloc size). The quality is chosen so that every codebook libvorbis uses exists
|
||||
in the game's built-in library (docs/formats/wem.md); setups the game itself ships are preferred.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from . import audio
|
||||
from .builder import ogg_to_wem
|
||||
from .convert import library
|
||||
from .known_setups import lookup_setup
|
||||
from .ogg import read_packets
|
||||
from .paths import user_dir
|
||||
from .wwvorbis import V2W, transcode_setup
|
||||
|
||||
QUALITIES = [q / 2 for q in range(20, -1, -1)] # 10.0 .. 0.0 (libvorbis q; libsndfile cannot do q<0)
|
||||
AUTO_MAX_Q_KNOWN = 6.0 # highest q considered when looking for a setup the game ships
|
||||
AUTO_MAX_Q = 4.0 # otherwise, highest compatible q (game files sit around q3-5)
|
||||
FALLBACK_RATES = (32000, 28000, 24000) # e.g. 6 channels at 44.1/48 kHz
|
||||
FIT_RATES = (24000, 22050, 16000, 12000, 11025, 8000)
|
||||
|
||||
|
||||
class EncodeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def vorbis_ogg(x, rate, quality):
|
||||
"""Encode float32 PCM to Ogg Vorbis bytes at libvorbis quality `quality` (0..10)."""
|
||||
b = io.BytesIO()
|
||||
level = min(1.0, max(0.0, 1.0 - quality / 10.0))
|
||||
x = np.ascontiguousarray(audio._as2d(x))
|
||||
# write in blocks: libsndfile's Vorbis writer overflows the C stack on very large single writes
|
||||
with sf.SoundFile(b, "w", rate, x.shape[1], format="OGG", subtype="VORBIS", compression_level=level) as f:
|
||||
for i in range(0, len(x), 4096):
|
||||
f.write(x[i:i + 4096])
|
||||
return b.getvalue()
|
||||
|
||||
|
||||
# --- setup probing (which q values are usable at a rate/channel count) ------------------------
|
||||
_cache = None
|
||||
|
||||
|
||||
def _cache_path():
|
||||
return os.path.join(user_dir("cache"), "vorbis_probe.json")
|
||||
|
||||
|
||||
def _probe_cache():
|
||||
global _cache
|
||||
if _cache is None:
|
||||
try:
|
||||
c = json.load(open(_cache_path()))
|
||||
_cache = c if c.get("_lib") == sf.__libsndfile_version__ else None
|
||||
except (OSError, ValueError):
|
||||
_cache = None
|
||||
_cache = _cache or {"_lib": sf.__libsndfile_version__}
|
||||
return _cache
|
||||
|
||||
|
||||
def probe_setup(rate, channels, quality):
|
||||
"""(stripped Wwise setup or None, error) for libvorbis at these settings (cached)."""
|
||||
key = "%d,%d,%.1f" % (rate, channels, quality)
|
||||
c = _probe_cache()
|
||||
if key not in c:
|
||||
noise = (np.random.default_rng(7).standard_normal((rate, channels)) * 0.1).astype(np.float32)
|
||||
pk = read_packets(vorbis_ogg(noise, rate, quality))
|
||||
try:
|
||||
s, _ = transcode_setup(pk[2][0], V2W, channels, library())
|
||||
c[key] = [s.hex(), None]
|
||||
except ValueError as e:
|
||||
c[key] = [None, str(e)]
|
||||
try:
|
||||
json.dump(c, open(_cache_path(), "w"))
|
||||
except OSError:
|
||||
pass
|
||||
s, err = c[key]
|
||||
return (bytes.fromhex(s) if s else None), err
|
||||
|
||||
|
||||
def choose_quality(rate, channels, quality="auto"):
|
||||
"""-> (quality, known) for a rate/channel count, or raise EncodeError."""
|
||||
if quality != "auto":
|
||||
q = float(quality)
|
||||
s, err = probe_setup(rate, channels, q)
|
||||
if s is None:
|
||||
raise EncodeError("quality %s cannot be used at %d Hz / %d ch: %s" % (quality, rate, channels, err))
|
||||
return q, bool(lookup_setup(s))
|
||||
compatible = None
|
||||
for q in [x for x in QUALITIES if x <= AUTO_MAX_Q_KNOWN]:
|
||||
s, _ = probe_setup(rate, channels, q)
|
||||
if s is None:
|
||||
continue
|
||||
if lookup_setup(s):
|
||||
return q, True
|
||||
if compatible is None and q <= AUTO_MAX_Q:
|
||||
compatible = q
|
||||
if compatible is None:
|
||||
raise EncodeError("no libvorbis quality yields game-compatible codebooks at %d Hz / %d ch" % (rate, channels))
|
||||
return compatible, False
|
||||
|
||||
|
||||
def pick_format(rate, channels, quality="auto"):
|
||||
"""Find a usable (rate, quality). Falls back to lower rates when needed (Wwise resamples every
|
||||
source, so playback speed is unaffected)."""
|
||||
try:
|
||||
return (rate,) + choose_quality(rate, channels, quality)
|
||||
except EncodeError as first:
|
||||
for r in FALLBACK_RATES:
|
||||
if r < rate:
|
||||
try:
|
||||
return (r,) + choose_quality(r, channels, quality)
|
||||
except EncodeError:
|
||||
pass
|
||||
raise first
|
||||
|
||||
|
||||
def encode(x, src_rate, rate, channels, quality="auto"):
|
||||
"""PCM -> WEM bytes in the requested format. Returns (wem, info)."""
|
||||
r, q, known = pick_format(rate, channels, quality)
|
||||
pcm = audio.conform(x, src_rate, r, channels)
|
||||
if len(pcm) < 64:
|
||||
raise EncodeError("the audio is empty")
|
||||
wem = ogg_to_wem(vorbis_ogg(pcm, r, q))
|
||||
return wem, dict(rate=r, quality=q, known_setup=known, size=len(wem), channels=channels,
|
||||
seconds=len(pcm) / float(r), requested_rate=rate)
|
||||
|
||||
|
||||
def encode_fit(x, src_rate, rate, channels, limit=None, quality="auto", progress=None):
|
||||
"""Like `encode`, but if the result is larger than `limit` bytes lower the quality step by step,
|
||||
then the sample rate, until it fits. Raises EncodeError if it cannot fit."""
|
||||
wem, info = encode(x, src_rate, rate, channels, quality)
|
||||
if limit is None or len(wem) <= limit:
|
||||
return wem, info
|
||||
first = len(wem)
|
||||
for r in [info["rate"]] + [f for f in FIT_RATES if f < info["rate"]]:
|
||||
pcm = audio.conform(x, src_rate, r, channels)
|
||||
for q in QUALITIES:
|
||||
if r == info["rate"] and q >= info["quality"]:
|
||||
continue
|
||||
if probe_setup(r, channels, q)[0] is None:
|
||||
continue
|
||||
if progress:
|
||||
progress("shrinking: %d Hz, quality %.1f" % (r, q))
|
||||
w = ogg_to_wem(vorbis_ogg(pcm, r, q))
|
||||
if len(w) <= limit:
|
||||
info.update(rate=r, quality=q, size=len(w), shrunk_from=first,
|
||||
known_setup=bool(lookup_setup(probe_setup(r, channels, q)[0])))
|
||||
return w, info
|
||||
raise EncodeError("the audio is too long for this sound even at the lowest quality "
|
||||
"(%d bytes, maximum %d). Shorten it." % (first, limit))
|
||||
Reference in New Issue
Block a user