Russian narration for the Enshrouded notes: mod 0.4.0, installer, generation pipeline
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Step 7: join every note from its picked takes and encode it for the game (work/qoa, work/wav48).
|
||||
|
||||
- each take trimmed to its speech (top_db 40), 60 ms kept at both ends, 8 ms fades;
|
||||
- pauses as the EN narrator makes them: PAUSE["s"] inside a paragraph, PAUSE["l"] after a line break,
|
||||
PAUSE["p"] between paragraphs (EN medians: phrases ~0.6 s, paragraphs ~0.9 s);
|
||||
- 24 -> 48 kHz with soxr VHQ; loudness: active-speech RMS matched to the EN original, soft limiter
|
||||
at -1 dBFS; 100 ms of silence at both ends; frameless QOA.
|
||||
The QOA encoder is pure Python: this step is CPU-bound (assemble.workers processes).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from multiprocessing import Pool
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from .config import load
|
||||
from .qoa import encode
|
||||
|
||||
PAUSE = {"s": 0.45, "l": 0.70, "p": 0.90}
|
||||
CFG = None
|
||||
|
||||
|
||||
def active_rms(x):
|
||||
v = x[::5]
|
||||
v = v[np.abs(v) > 200 / 32768]
|
||||
return float(np.sqrt(np.mean(v * v))) if len(v) else 1e-3
|
||||
|
||||
|
||||
def trim(y, sr):
|
||||
iv = librosa.effects.split(y, top_db=40, frame_length=1024, hop_length=240)
|
||||
if not len(iv):
|
||||
return y
|
||||
y = y[max(0, iv[0][0] - int(0.06 * sr)): min(len(y), iv[-1][1] + int(0.06 * sr))].copy()
|
||||
f = int(0.008 * sr)
|
||||
y[:f] *= np.linspace(0, 1, f)
|
||||
y[-f:] *= np.linspace(1, 0, f)
|
||||
return y
|
||||
|
||||
|
||||
def job(args):
|
||||
n, segs, pick = args
|
||||
cfg = CFG or load()
|
||||
meta = cfg.w("qoa", f"{n['k']}.json")
|
||||
if meta.exists():
|
||||
return
|
||||
parts = []
|
||||
for s in segs:
|
||||
y, sr = sf.read(cfg.w("takes", f"{s['take']}_s{pick[s['id']]}.wav"), dtype="float32")
|
||||
if parts:
|
||||
parts.append(np.zeros(int(PAUSE.get(s["join"], 0.45) * sr), np.float32))
|
||||
parts.append(trim(y, sr))
|
||||
y = librosa.resample(np.concatenate(parts), orig_sr=24000, target_sr=48000, res_type="soxr_vhq")
|
||||
en, _ = sf.read(cfg.w("en24", f"{n['k']}.wav"), dtype="float32")
|
||||
gain = active_rms(en) / active_rms(y)
|
||||
y = y * gain
|
||||
lim = 10 ** (-1 / 20)
|
||||
knee = 0.7 * lim
|
||||
over = np.abs(y) > knee
|
||||
y[over] = np.sign(y[over]) * (knee + (lim - knee) * np.tanh((np.abs(y[over]) - knee) / (lim - knee)))
|
||||
pad = np.zeros(4800, np.float32)
|
||||
y = np.concatenate([pad, y, pad])
|
||||
sf.write(cfg.w("wav48", f"{n['k']}.wav"), y, 48000, subtype="PCM_16")
|
||||
pcm = np.clip(np.round(y * 32767), -32768, 32767).astype(np.int32).tolist()
|
||||
cfg.w("qoa", f"{n['sound']}.qoa").write_bytes(encode(pcm))
|
||||
json.dump(dict(k=n["k"], name=n["name"], vo=n["vo"], sound=n["sound"], frames=len(pcm),
|
||||
seconds=len(pcm) / 48000, gain=round(gain, 3), segments=len(segs)), open(meta, "w"))
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load()
|
||||
notes = json.load(open(cfg.w("notes.json"), encoding="utf-8"))
|
||||
segs = json.load(open(cfg.w("segs.json"), encoding="utf-8"))
|
||||
pick = json.load(open(cfg.w("pick.json")))
|
||||
by = {}
|
||||
for s in segs:
|
||||
by.setdefault(s["k"], []).append(s)
|
||||
notes = [n for n in notes if n["k"] in by] # segments may cover a subset (ENSVOICE_NOTES)
|
||||
if "--force" in sys.argv: # re-encode every note (after a new pick)
|
||||
for f in cfg.w("qoa", "x").parent.glob("n*.json"):
|
||||
f.unlink()
|
||||
else: # re-encode the notes whose picked takes changed
|
||||
for n in notes:
|
||||
m = cfg.w("qoa", f"{n['k']}.json")
|
||||
if m.exists() and json.load(open(m)).get("takes") != [f"{s['take']}_s{pick.get(s['id'])}" for s in by[n["k"]]]:
|
||||
m.unlink()
|
||||
jobs = [(n, by[n["k"]], pick) for n in notes if not cfg.w("qoa", f"{n['k']}.json").exists()]
|
||||
print("notes to encode", len(jobs), flush=True)
|
||||
with Pool(int(cfg.get("assemble", "workers", max(1, (os.cpu_count() or 2) - 1)))) as p:
|
||||
for i, _ in enumerate(p.imap_unordered(job, jobs, chunksize=1)):
|
||||
if i % 25 == 0:
|
||||
print(f"{i + 1}/{len(jobs)}", flush=True)
|
||||
for n in notes: # remember which takes each note was built from
|
||||
m = cfg.w("qoa", f"{n['k']}.json")
|
||||
d = json.load(open(m))
|
||||
d["takes"] = [f"{s['take']}_s{pick[s['id']]}" for s in by[n["k"]]]
|
||||
json.dump(d, open(m, "w"))
|
||||
total = sum(json.load(open(cfg.w("qoa", f"{n['k']}.json")))["seconds"] for n in notes)
|
||||
print("ASSEMBLED", len(notes), "notes,", round(total / 60, 1), "minutes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user