Add pipeline code
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""Assembly with ffmpeg: wav pieces + chimes + pauses -> one MP3 per script file, to ACX levels.
|
||||
1. Speech track: head, pieces, chimes, pauses (digital silence), tail.
|
||||
2. Room tone: quiet noise (fixed seed) as long as the speech track, mixed underneath everywhere, so there is no digital silence.
|
||||
3. Gain on the speech track so the finished mix lands on the RMS target; a limiter holds the peak ceiling.
|
||||
4. MP3, CBR, 44.1 kHz, mono."""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import audiomeasure as am
|
||||
import settings
|
||||
import voice
|
||||
|
||||
FMT = ["-ar", "{sr}", "-ac", "{ch}", "-c:a", "pcm_f32le"]
|
||||
|
||||
|
||||
def fmt(a):
|
||||
return [x.format(sr=a["sample_rate"], ch=a["channels"]) for x in FMT]
|
||||
|
||||
|
||||
def make_silence(a, seconds, path):
|
||||
am.ffmpeg(["-f", "lavfi", "-i", "anullsrc=r=%d:cl=%s" % (a["sample_rate"], "mono" if a["channels"] == 1 else "stereo"),
|
||||
"-t", "%.3f" % seconds] + fmt(a) + [path])
|
||||
|
||||
|
||||
def make_room_tone(a, seconds, path):
|
||||
"""Very quiet noise (ACX wants room tone, not digital silence). Fixed seed, so output is repeatable."""
|
||||
amp = 10 ** (a["room_tone_db"] / 20) * math.sqrt(3) # uniform white noise: RMS = amplitude / sqrt(3)
|
||||
am.ffmpeg(["-f", "lavfi", "-i", "anoisesrc=d=%.3f:c=white:r=%d:a=%.6f:seed=1" % (seconds, a["sample_rate"], amp)] + fmt(a) + [path])
|
||||
|
||||
|
||||
def make_chime(a, level_db, path):
|
||||
"""A soft sine tone with a smooth rise and fall (sin^2 envelope), peak at level_db."""
|
||||
amp = 10 ** (level_db / 20)
|
||||
dur = a["chime_s"]
|
||||
expr = "%.6f*sin(2*PI*%g*t)*pow(sin(PI*t/%g),2)" % (amp, a["chime_hz"], dur)
|
||||
am.ffmpeg(["-f", "lavfi", "-i", "aevalsrc='%s':s=%d:d=%g" % (expr, a["sample_rate"], dur)] + fmt(a) + [path])
|
||||
|
||||
|
||||
def concat(paths, out, tmp):
|
||||
lst = os.path.join(tmp, "list_%s.txt" % os.path.basename(out))
|
||||
with open(lst, "w") as f:
|
||||
for p in paths:
|
||||
f.write("file '%s'\n" % p.replace("'", "'\\''"))
|
||||
am.ffmpeg(["-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", out])
|
||||
|
||||
|
||||
def build_speech(a, items, chime_db, tmp, tag):
|
||||
"""Speech track. chime_db None leaves the cue slots silent (used to find the gain before the chime is made)."""
|
||||
cache = {}
|
||||
|
||||
def silence(seconds):
|
||||
key = "sil%.3f" % seconds
|
||||
if key not in cache:
|
||||
cache[key] = os.path.join(tmp, key + ".wav")
|
||||
make_silence(a, seconds, cache[key])
|
||||
return cache[key]
|
||||
|
||||
parts = [silence(a["head_room_tone_s"])]
|
||||
for n, it in enumerate(items):
|
||||
if "cue" in it:
|
||||
if chime_db is None:
|
||||
parts.append(silence(a["chime_s"]))
|
||||
else:
|
||||
if "chime" not in cache:
|
||||
cache["chime"] = os.path.join(tmp, "chime_%s.wav" % tag)
|
||||
make_chime(a, chime_db, cache["chime"])
|
||||
parts.append(cache["chime"])
|
||||
else:
|
||||
p = os.path.join(tmp, "p%05d.wav" % n)
|
||||
if not os.path.exists(p):
|
||||
am.ffmpeg(["-i", it["wav"]] + fmt(a) + [p])
|
||||
parts.append(p)
|
||||
if n < len(items) - 1:
|
||||
parts.append(silence(it["gap_after_s"]))
|
||||
parts.append(silence(a["tail_room_tone_s"]))
|
||||
out = os.path.join(tmp, "speech_%s.wav" % tag)
|
||||
concat(parts, out, tmp)
|
||||
return out
|
||||
|
||||
|
||||
def mix(a, speech, gain, noise, out):
|
||||
limit = 10 ** (a["peak_ceiling_db"] / 20)
|
||||
am.ffmpeg(["-i", speech, "-i", noise, "-filter_complex",
|
||||
"[0:a]volume=%.3fdB,alimiter=limit=%.5f:level=0[s];[s][1:a]amix=inputs=2:normalize=0:duration=first" % (gain, limit)]
|
||||
+ fmt(a) + [out])
|
||||
|
||||
|
||||
def set_level(a, speech, tmp):
|
||||
"""Find the gain that puts the finished mix on the RMS target. Returns (mix path, gain dB)."""
|
||||
noise = os.path.join(tmp, "noise.wav")
|
||||
make_room_tone(a, float(am.probe(speech)["duration"]), noise)
|
||||
rms, _ = am.overall(speech)
|
||||
gain = a["target_rms_db"] - rms
|
||||
for i in range(3):
|
||||
out = os.path.join(tmp, "mix_%d.wav" % i)
|
||||
mix(a, speech, gain, noise, out)
|
||||
got, _ = am.overall(out)
|
||||
if abs(got - a["target_rms_db"]) <= 0.3:
|
||||
break
|
||||
gain += a["target_rms_db"] - got
|
||||
return out, gain, noise
|
||||
|
||||
|
||||
def assemble_items(cfg, items, out_mp3):
|
||||
"""items as written by voice.py. Writes out_mp3. Returns the gain applied to speech in dB."""
|
||||
a = dict(cfg["audio"])
|
||||
work = settings.resolve(cfg, "work_dir")
|
||||
os.makedirs(work, exist_ok=True)
|
||||
tmp = tempfile.mkdtemp(prefix="asm_", dir=work)
|
||||
try:
|
||||
speech = build_speech(a, items, None, tmp, "measure")
|
||||
final, gain, noise = set_level(a, speech, tmp)
|
||||
if any("cue" in it for it in items):
|
||||
# build again with the chime in; it is made so it lands at chime_db after the gain
|
||||
speech = build_speech(a, items, a["chime_db"] - gain, tmp, "final")
|
||||
final = os.path.join(tmp, "final.wav")
|
||||
mix(a, speech, gain, noise, final)
|
||||
os.makedirs(os.path.dirname(out_mp3), exist_ok=True)
|
||||
am.ffmpeg(["-i", final, "-c:a", "libmp3lame", "-b:a", a["mp3_bitrate"], "-ar", str(a["sample_rate"]), "-ac", str(a["channels"]), out_mp3])
|
||||
return gain
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def run(cfg, book_dir, only=None):
|
||||
import bookio
|
||||
book = bookio.read_book(book_dir)
|
||||
out_dir = settings.audio_dir(cfg, book_dir)
|
||||
for name in book["audio_files"]:
|
||||
if only and name != only:
|
||||
continue
|
||||
with open(os.path.join(voice.work_dir_for(cfg, book_dir, name), "manifest.json"), encoding="utf-8") as f:
|
||||
items = json.load(f)
|
||||
gain = assemble_items(cfg, items, os.path.join(out_dir, name + ".mp3"))
|
||||
print("%s.mp3 written (speech gain %.1f dB)" % (name, gain))
|
||||
Reference in New Issue
Block a user