Archive DLL-free personalized HRTF binaural renderer

This commit is contained in:
2026-09-05 02:43:54 +08:00
parent 329445ed25
commit 22a16ab60d
25 changed files with 4285 additions and 109 deletions
+195 -30
View File
@@ -26,10 +26,16 @@ from metadata import DirectPayloadIndex, PayloadIndex, write_summary
import oamd_tracks
from renderer import JocRenderer
from native_renderer import NativeBackendUnavailable, NativeJocRenderer
from binaural_renderer import (
ROSSELLA_BLOCK_SAMPLES,
ROSSELLA_LATENCY_SAMPLES,
RosellaBinauralRenderer,
resolve_personalized_headphone,
)
from speaker_backend import create_speaker_renderer
from speaker_layouts import (SPEAKER_LAYOUT_CHOICES, get_speaker_layout,
speaker_layout_display_name)
from speaker_wav import SpeakerPcmSpool, write_speaker_wav
from speaker_wav import BinauralPcmSpool, SpeakerPcmSpool, write_pcm_wav
from variant_error import UnsupportedVariantError, write_variant_report
@@ -38,13 +44,15 @@ FRAME_SAMPLES = 1536
DEFAULT_OUTPUT_DIR = PROJECT_DIR / "output"
def resolve_output(source, requested=None, speaker_layout=None):
def resolve_output(source, requested=None, speaker_layout=None, *, binaural=False):
"""解析成品路径;未指定时使用项目内的 ``output`` 目录。"""
source = Path(source)
if requested is not None:
target = Path(requested)
elif speaker_layout is not None:
target = DEFAULT_OUTPUT_DIR / f"{source.stem}.{speaker_layout}.wav"
elif binaural:
target = DEFAULT_OUTPUT_DIR / f"{source.stem}.binaural.wav"
else:
target = DEFAULT_OUTPUT_DIR / (source.stem + ".adm.wav")
return target.expanduser().resolve()
@@ -105,8 +113,8 @@ def sha256(path):
return digest.hexdigest()
def choose_speaker_output_format(requested_format, clip_action, peak, clipped_values,
*, input_func=input, interactive=None):
def choose_pcm_output_format(requested_format, clip_action, peak, clipped_values,
*, input_func=input, interactive=None):
"""Resolve int24 clipping interactively or through an explicit policy."""
if requested_format != "int24" or clipped_values == 0:
return requested_format
@@ -146,6 +154,10 @@ def choose_speaker_output_format(requested_format, clip_action, peak, clipped_va
raise ValueError(f"未知 clip action: {action}")
# Backward-compatible public name used by existing tests and callers.
choose_speaker_output_format = choose_pcm_output_format
def resolve_metadata(args, eac3, temp_dir):
if args.metadata_dir:
directory = Path(args.metadata_dir).resolve()
@@ -198,7 +210,9 @@ def create_renderer(backend, gain, native_library=None, native_threads=None):
def render(index, bed_path, frame_count, raw_path, gain, progress_every,
backend="auto", native_library=None, native_threads=None, frame_sink=None,
speaker_renderer=None, speaker_sink=None, speaker_metadata_offset=1473):
speaker_renderer=None, speaker_sink=None, speaker_metadata_offset=1473,
binaural_renderer=None, binaural_sink=None, binaural_metadata_offset=1473,
raw_scale=1.0):
values = np.memmap(bed_path, dtype=np.float32, mode="r")
frame_width = FRAME_SAMPLES * 6
if values.size % frame_width:
@@ -216,6 +230,9 @@ def render(index, bed_path, frame_count, raw_path, gain, progress_every,
raw_write_seconds = 0.0
speaker_render_seconds = 0.0
speaker_write_seconds = 0.0
binaural_render_seconds = 0.0
binaural_write_seconds = 0.0
elapsed = 0.0
try:
for frame_number, row in enumerate(index.rows[:frame_count]):
bed6 = np.asarray(bed[frame_number], dtype=np.float32)
@@ -226,7 +243,8 @@ def render(index, bed_path, frame_count, raw_path, gain, progress_every,
dsp_seconds += time.perf_counter() - stage
if output is not None:
stage = time.perf_counter()
output[frame_number] = pcm16.T
output[frame_number] = np.multiply(
pcm16.T, np.float32(raw_scale), dtype=np.float32)
raw_write_seconds += time.perf_counter() - stage
if frame_sink is not None:
stage = time.perf_counter()
@@ -240,6 +258,22 @@ def render(index, bed_path, frame_count, raw_path, gain, progress_every,
stage = time.perf_counter()
speaker_sink.write_frame(speaker_pcm)
speaker_write_seconds += time.perf_counter() - stage
if binaural_renderer is not None:
payload = subs.get(11)
outer_offset = (
index.subpayload_sample_offset(row, 11)
if payload is not None and hasattr(index, "subpayload_sample_offset")
else 0
)
stage = time.perf_counter()
binaural_pcm = binaural_renderer.render_frame(
pcm16.T, payload, binaural_metadata_offset,
outer_sample_offset=outer_offset)
binaural_render_seconds += time.perf_counter() - stage
if len(binaural_pcm):
stage = time.perf_counter()
binaural_sink.write_frame(binaural_pcm)
binaural_write_seconds += time.perf_counter() - stage
done = frame_number + 1
if done % progress_every == 0 or done == frame_count:
elapsed = time.perf_counter() - started
@@ -247,6 +281,14 @@ def render(index, bed_path, frame_count, raw_path, gain, progress_every,
eta = (frame_count - done) / max(speed, 1e-9)
print(f"[JOC:{backend_info['name']}] {done}/{frame_count} "
f"{speed:.1f} frame/s ETA {eta:.1f}s", flush=True)
if binaural_renderer is not None:
stage = time.perf_counter()
binaural_tail = binaural_renderer.finish()
binaural_render_seconds += time.perf_counter() - stage
if len(binaural_tail):
stage = time.perf_counter()
binaural_sink.write_frame(binaural_tail)
binaural_write_seconds += time.perf_counter() - stage
if output is not None:
output.flush()
elapsed = time.perf_counter() - started
@@ -257,6 +299,9 @@ def render(index, bed_path, frame_count, raw_path, gain, progress_every,
close = getattr(speaker_renderer, "close", None)
if close is not None:
close()
close = getattr(binaural_renderer, "close", None)
if close is not None:
close()
breakdown = {
"pipeline_wall_seconds": elapsed,
"dsp_and_joc_parse_seconds": dsp_seconds,
@@ -264,38 +309,57 @@ def render(index, bed_path, frame_count, raw_path, gain, progress_every,
"raw_float_write_seconds": raw_write_seconds,
"speaker_render_seconds": speaker_render_seconds,
"speaker_spool_write_seconds": speaker_write_seconds,
"binaural_render_seconds": binaural_render_seconds,
"binaural_spool_write_seconds": binaural_write_seconds,
}
return dsp_seconds, backend_info, breakdown
def build_parser():
parser = argparse.ArgumentParser(
description="JustOneCacophony (JOC):E-AC-3 JOC → 25ch ADM BWF 或扬声器 WAV")
description=("JustOneCacophony (JOC):E-AC-3 JOC → 25ch ADM BWF、"
"扬声器 WAV 或 DLL-free Rosella 双耳 WAV"))
parser.add_argument("input", type=Path, help="输入 .m4a/.eac3/.ec3")
parser.add_argument("-o", "--output", type=Path, help="输出文件;默认按模式和布局命名")
parser.add_argument("--speaker-output", type=Path,
help="扬声器 WAV 路径;仅与 --speaker-layout 一起使用")
parser.add_argument("--speaker-layout", choices=SPEAKER_LAYOUT_CHOICES,
help="直接扬声器渲染布局,例如 2.0、5.1、7.1.2")
parser.add_argument("--binaural-output", type=Path,
help="双耳 WAV 路径;仅与 --binaural 一起使用")
direct_mode = parser.add_mutually_exclusive_group()
direct_mode.add_argument("--speaker-layout", choices=SPEAKER_LAYOUT_CHOICES,
help="直接扬声器渲染布局,例如 2.0、5.1、7.1.2")
direct_mode.add_argument("--binaural", action="store_true",
help="直接 DLL-free Rosella 双耳渲染;不生成临时 ADM BWF")
parser.add_argument("--speaker-format", choices=("float32", "int24"), default="float32",
help="扬声器 WAV 格式,默认 float32")
parser.add_argument("--binaural-format", choices=("float32", "int24"), default="float32",
help="双耳 WAV 格式,默认 float32")
parser.add_argument("--clip-action", choices=("ask", "continue", "float32", "abort"),
default="ask",
help="int24 削波处理:交互询问、继续截断、改 float32 或中止")
parser.add_argument("--speaker-metadata-offset", type=int, default=1473,
help="扬声器渲染 metadata 相对帧偏移,默认 1473 samples")
parser.add_argument("--binaural-mode", choices=("near", "mid", "far"), default="mid",
help="普通对象 Rosella 距离模式,默认 mid;LFE 始终走 special 低通")
parser.add_argument("--personalized-headphone", "--binaural-hrtf", dest="personalized_headphone",
type=Path, help="覆盖 HRTF/binaural.personalized_headphone")
parser.add_argument("--binaural-tail-seconds", type=float, default=5.0,
help="双耳 room/filterbank flush 上限,默认 5 秒")
parser.add_argument("--binaural-tail-threshold", type=float, default=1.0e-8,
help="双耳尾声裁切阈值,默认 1e-8;主体至少保留原时长")
parser.add_argument("--binaural-chunk-frames", type=int, default=64,
help="双耳内部批处理 E-AC-3 帧数,默认 64")
parser.add_argument("--gain-db", type=float, default=0.0,
help="成品增益 dB,默认 0(float32 系数 1.0)")
help="成品增益 dB,默认 0;双耳路径以 float64 应用")
parser.add_argument("--duration", type=float, help="只处理开头指定秒数")
parser.add_argument("--object-delay-samples", type=int, default=1473,
help="可选的对象 PCM/OAMD 时间补偿,默认 1473 samples")
help="对象 PCM/OAMD 时间补偿;ADM 与双耳默认 1473 samples")
parser.add_argument(
"--joc-binaural-mode", choices=tuple(adm_atmos.JOC_BINAURAL_MODES),
default=adm_atmos.JOC_BINAURAL_MODE_DEFAULT,
help="实验性 ADM DBMD JOC 对象双耳模式:off=0、near=1、far=2、mid=3、"
"unspecified=4(默认);不改变 PCM 或直接扬声器渲染")
help="ADM DBMD JOC 模式:off/near/far/mid/unspecified;仅影响 ADM BWF")
parser.add_argument("--trajectory-mode", choices=("compact", "dense64"), default="compact",
help="对象轨迹表示;compact 用长线性插值压缩 AXML,dense64 保留逐 64-sample 块")
help="ADM 对象轨迹表示;直接双耳路径不序列化 AXML")
parser.add_argument("--ffmpeg", default=os.environ.get("FFMPEG", "ffmpeg"))
parser.add_argument("--backend", choices=("auto", "native", "python"), default="auto",
help="DSP 后端;auto 优先 C++,不可用时回退 Python")
@@ -332,24 +396,45 @@ def main(argv=None):
if not source.is_file():
raise FileNotFoundError(source)
speaker_mode = args.speaker_layout is not None
binaural_mode = bool(args.binaural)
if args.speaker_output is not None and not speaker_mode:
raise ValueError("--speaker-output 必须与 --speaker-layout 一起使用")
if args.output is not None and args.speaker_output is not None:
raise ValueError("-o/--output 与 --speaker-output 不能同时使用")
if args.binaural_output is not None and not binaural_mode:
raise ValueError("--binaural-output 必须与 --binaural 一起使用")
specific_outputs = [value for value in (args.speaker_output, args.binaural_output)
if value is not None]
if args.output is not None and specific_outputs:
raise ValueError("-o/--output 与 --speaker-output/--binaural-output 不能同时使用")
if len(specific_outputs) > 1:
raise ValueError("--speaker-output 与 --binaural-output 不能同时使用")
if args.speaker_metadata_offset < 0:
raise ValueError("speaker-metadata-offset 不能为负数")
if args.personalized_headphone is not None and not binaural_mode:
raise ValueError("--personalized-headphone 仅与 --binaural 一起使用")
if args.binaural_tail_seconds < 0:
raise ValueError("binaural-tail-seconds 不能为负数")
if args.binaural_tail_threshold < 0:
raise ValueError("binaural-tail-threshold 不能为负数")
if args.binaural_chunk_frames <= 0:
raise ValueError("binaural-chunk-frames 必须大于 0")
requested_output = (args.speaker_output if args.speaker_output is not None
else args.binaural_output if args.binaural_output is not None
else args.output)
output = resolve_output(
source, requested_output, args.speaker_layout if speaker_mode else None)
source, requested_output, args.speaker_layout if speaker_mode else None,
binaural=binaural_mode)
output.parent.mkdir(parents=True, exist_ok=True)
if args.duration is not None and args.duration <= 0:
raise ValueError("duration 必须大于 0")
if args.object_delay_samples < 0:
raise ValueError("object-delay-samples 不能为负数")
gain = np.float32(10.0 ** (args.gain_db / 20.0))
if not np.isfinite(gain):
raise ValueError("gain-db 超出 float32 范围")
gain_float64 = 10.0 ** (args.gain_db / 20.0)
gain = np.float32(gain_float64)
if not math.isfinite(gain_float64) or not np.isfinite(gain):
raise ValueError("gain-db 超出支持范围")
binaural_model_path = (
resolve_personalized_headphone(args.personalized_headphone)
if binaural_mode and not args.metadata_only else None)
ffmpeg = executable(args.ffmpeg, "FFmpeg")
total_started = time.perf_counter()
@@ -394,7 +479,12 @@ def main(argv=None):
speaker_wav_info = None
speaker_clip_info = None
speaker_actual_format = None
binaural_backend_info = None
binaural_wav_info = None
binaural_clip_info = None
binaural_actual_format = None
if speaker_mode:
timings["create_binaural_renderer"] = 0.0
layout = get_speaker_layout(args.speaker_layout)
speaker_name = speaker_layout_display_name(layout)
speaker_decoder, speaker_backend_info = create_speaker_renderer(
@@ -416,11 +506,11 @@ def main(argv=None):
args.native_threads, None, speaker_decoder, spool,
args.speaker_metadata_offset)
spool.finalize()
speaker_actual_format = choose_speaker_output_format(
speaker_actual_format = choose_pcm_output_format(
args.speaker_format, args.clip_action, spool.peak,
spool.clipped_values)
speaker_wav_info = timed_call(
timings, "write_speaker_wav", write_speaker_wav,
timings, "write_speaker_wav", write_pcm_wav,
output, spool.values, speaker_actual_format, rate=RATE)
speaker_clip_info = {
"peak": spool.peak,
@@ -436,7 +526,66 @@ def main(argv=None):
timings["validate_adm"] = 0.0
info = (f"speaker layout={speaker_name}, format={speaker_actual_format}, "
f"peak={speaker_clip_info['peak']:.9g}")
elif binaural_mode:
model_path = binaural_model_path
binaural_decoder = timed_call(
timings, "create_binaural_renderer", RosellaBinauralRenderer,
model_path, mode=args.binaural_mode,
object_delay_samples=args.object_delay_samples,
tail_seconds=args.binaural_tail_seconds,
output_gain=gain_float64,
chunk_frames=args.binaural_chunk_frames,
backend=args.backend, native_library=args.native_library)
print(
f"[binaural] mode={args.binaural_mode} "
f"backend={binaural_decoder.dsp_backend} "
f"precision=float64/complex128 model={model_path}", flush=True)
flush_samples = math.ceil(
(args.binaural_tail_seconds * RATE
+ ROSSELLA_LATENCY_SAMPLES + ROSSELLA_BLOCK_SAMPLES)
/ ROSSELLA_BLOCK_SAMPLES) * ROSSELLA_BLOCK_SAMPLES
spool = BinauralPcmSpool(
temp_dir / "binaural_interleaved_f64.raw",
frame_count * FRAME_SAMPLES + flush_samples,
tail_threshold=args.binaural_tail_threshold)
try:
render_seconds, renderer_backend, render_breakdown = timed_call(
timings, "render_and_stream", variant_call,
output, source, render, index, bed_path, frame_count, raw_path,
np.float32(1.0), max(1, args.progress_every),
backend=args.backend, native_library=args.native_library,
native_threads=args.native_threads,
binaural_renderer=binaural_decoder, binaural_sink=spool,
binaural_metadata_offset=args.object_delay_samples, raw_scale=gain)
spool.finalize(minimum_samples=frame_count * FRAME_SAMPLES)
binaural_actual_format = choose_pcm_output_format(
args.binaural_format, args.clip_action, spool.peak,
spool.clipped_values)
binaural_wav_info = timed_call(
timings, "write_binaural_wav", write_pcm_wav,
output, spool.values, binaural_actual_format, rate=RATE)
binaural_clip_info = {
"peak": spool.peak,
"over_unity_values": spool.clipped_values,
"requested_format": args.binaural_format,
"actual_format": binaural_actual_format,
"clip_action": args.clip_action,
"tail_threshold": args.binaural_tail_threshold,
"source_samples": frame_count * FRAME_SAMPLES,
"kept_samples": spool.sample_count,
}
binaural_backend_info = binaural_decoder.backend_info
finally:
spool.close()
timings["build_adm_tracks"] = 0.0
timings["finalize_adm"] = 0.0
timings["validate_adm"] = 0.0
info = (f"binaural mode={args.binaural_mode}, "
f"format={binaural_actual_format}, "
f"peak={binaural_clip_info['peak']:.9g}, "
f"samples={binaural_clip_info['kept_samples']}")
else:
timings["create_binaural_renderer"] = 0.0
master = adm_assemble.StreamingMaster(
output, duration_sec, rate=RATE,
joc_binaural_mode=adm_atmos.JOC_BINAURAL_MODES[args.joc_binaural_mode])
@@ -469,10 +618,11 @@ def main(argv=None):
else:
output_sha = timed_call(timings, "sha256", sha256, output)
total_seconds = time.perf_counter() - total_started
mode_name = "speaker" if speaker_mode else "binaural" if binaural_mode else "adm"
report = {
"input": str(source),
"output": str(output),
"mode": "speaker" if speaker_mode else "adm",
"mode": mode_name,
"metadata": str(metadata_json) if metadata_json is not None else None,
"metadata_backend": metadata_backend,
"metadata_cache": str(metadata_cache_dir) if metadata_cache_dir is not None else None,
@@ -480,11 +630,13 @@ def main(argv=None):
"duration_sec": duration_sec,
"gain_db": args.gain_db,
"gain_float32": float(gain),
"object_delay_samples": None if speaker_mode else args.object_delay_samples,
"trajectory_mode": None if speaker_mode else args.trajectory_mode,
"joc_binaural_mode": None if speaker_mode else args.joc_binaural_mode,
"joc_binaural_mode_value": (None if speaker_mode else
adm_atmos.JOC_BINAURAL_MODES[args.joc_binaural_mode]),
"gain_float64": float(gain_float64),
"object_delay_samples": (None if speaker_mode else args.object_delay_samples),
"trajectory_mode": args.trajectory_mode if mode_name == "adm" else None,
"joc_binaural_mode": args.joc_binaural_mode if mode_name == "adm" else None,
"joc_binaural_mode_value": (
adm_atmos.JOC_BINAURAL_MODES[args.joc_binaural_mode]
if mode_name == "adm" else None),
"render_seconds": render_seconds,
"render_breakdown": render_breakdown,
"renderer_backend": renderer_backend,
@@ -493,11 +645,19 @@ def main(argv=None):
"speaker_metadata_offset": args.speaker_metadata_offset if speaker_mode else None,
"speaker_clip": speaker_clip_info,
"speaker_wav": speaker_wav_info,
"streaming_adm": not speaker_mode,
"binaural_renderer_backend": binaural_backend_info,
"binaural_mode": args.binaural_mode if binaural_mode else None,
"personalized_headphone": (
binaural_backend_info["model"] if binaural_backend_info else None),
"binaural_clip": binaural_clip_info,
"binaural_wav": binaural_wav_info,
"output_clip": speaker_clip_info if speaker_mode else binaural_clip_info,
"output_wav": speaker_wav_info if speaker_mode else binaural_wav_info,
"streaming_adm": mode_name == "adm",
"kept_raw": str(raw_path) if raw_path is not None else None,
"timings": timings,
"total_seconds": total_seconds,
"adm_validation": None if speaker_mode else info,
"adm_validation": info if mode_name == "adm" else None,
"adm_metadata": getattr(master, "metadata_info", None) if master is not None else None,
"sha256": output_sha,
"python": platform.python_version(),
@@ -515,6 +675,11 @@ def main(argv=None):
f"speaker={render_breakdown['speaker_render_seconds']:.2f}s "
f"pipeline={render_breakdown['pipeline_wall_seconds']:.2f}s "
f"total={report['total_seconds']:.2f}s")
elif binaural_mode:
print(f"[time] JOC-DSP={render_seconds:.2f}s ({renderer_backend['name']}) "
f"binaural={render_breakdown['binaural_render_seconds']:.2f}s "
f"pipeline={render_breakdown['pipeline_wall_seconds']:.2f}s "
f"total={report['total_seconds']:.2f}s")
else:
print(f"[time] DSP={render_seconds:.2f}s ({renderer_backend['name']}) "
f"render+ADM-stream={render_breakdown['pipeline_wall_seconds']:.2f}s "