添加双耳渲染功能
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
"""JOC frame adapter for the public SOFA binaural backend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from binaural_metadata import OamdPositionTimeline
|
||||
from public_filterbank import ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
|
||||
from sofa_binaural_backend import SofaBinauralBackend
|
||||
from sofa_hrtf_field import (
|
||||
DEFAULT_HRTF_CACHE_DIR,
|
||||
DEFAULT_PROJECTION_RIDGE,
|
||||
DEFAULT_SH_RIDGE,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
FRAME_SAMPLES = 1536
|
||||
BINAURAL_BLOCK_SAMPLES = 512
|
||||
QMF_HOP_SAMPLES = 64
|
||||
BINAURAL_LATENCY_SAMPLES = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES
|
||||
SOURCE_CHANNELS = 16
|
||||
OUTPUT_CHANNELS = 2
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_HRTF_DIR = PROJECT_DIR / "HRTF"
|
||||
DEFAULT_SOFA_HRTF = DEFAULT_HRTF_DIR / "binaural.sofa"
|
||||
|
||||
|
||||
def _resolve_hrtf_file(path: str | Path, suffix: str, label: str) -> Path:
|
||||
target = Path(path).expanduser().resolve()
|
||||
if target.suffix.lower() != suffix:
|
||||
raise ValueError(f"{label} must use the {suffix} extension: {target}")
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(f"{label} not found: {target}")
|
||||
return target
|
||||
|
||||
|
||||
def resolve_sofa_hrtf(path: str | Path) -> Path:
|
||||
"""Resolve an explicitly selected public SOFA source."""
|
||||
return _resolve_hrtf_file(path, ".sofa", "SOFA HRTF")
|
||||
|
||||
|
||||
def resolve_compiled_hrtf_cache(path: str | Path) -> Path:
|
||||
"""Resolve an explicitly selected JOC compiled HRTF cache."""
|
||||
return _resolve_hrtf_file(path, ".jochrtf", "compiled HRTF cache")
|
||||
|
||||
|
||||
class SofaBinauralRenderer:
|
||||
"""Render interleaved LFE plus fifteen JOC objects to stereo.
|
||||
|
||||
The adapter owns frame buffering and sample-timed OAMD updates. The
|
||||
backend owns the 64-QMF/77-hybrid state, the 961-sample latency policy,
|
||||
per-object direct/early state, and the shared late room.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, backend, *,
|
||||
mode: str = "mid",
|
||||
object_delay_samples: int = 1473,
|
||||
tail_seconds: float = 5.0,
|
||||
chunk_frames: int = 64):
|
||||
required_interface = (
|
||||
"source_count", "default_profile", "set_source", "process",
|
||||
"finish", "finish_output_capacity", "info")
|
||||
missing = [name for name in required_interface if not hasattr(backend, name)]
|
||||
if missing:
|
||||
raise TypeError(
|
||||
f"backend must implement the binaural backend interface; "
|
||||
f"missing: {', '.join(missing)}")
|
||||
if backend.source_count != SOURCE_CHANNELS:
|
||||
raise ValueError(f"JOC binaural backend must have {SOURCE_CHANNELS} sources")
|
||||
if backend.default_profile != str(mode).lower():
|
||||
raise ValueError("backend default profile does not match renderer mode")
|
||||
if int(object_delay_samples) < 0:
|
||||
raise ValueError("object_delay_samples must be non-negative")
|
||||
if not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0:
|
||||
raise ValueError("tail_seconds must be finite and non-negative")
|
||||
if int(chunk_frames) <= 0:
|
||||
raise ValueError("chunk_frames must be positive")
|
||||
|
||||
self.backend = backend
|
||||
self.mode = str(mode).lower()
|
||||
self.object_delay_samples = int(object_delay_samples)
|
||||
self.tail_seconds = float(tail_seconds)
|
||||
self.chunk_frames = int(chunk_frames)
|
||||
self.chunk_samples = self.chunk_frames * FRAME_SAMPLES
|
||||
self.dsp_backend = getattr(backend, "dsp_backend", "python-sofa")
|
||||
self.timeline = OamdPositionTimeline(15)
|
||||
|
||||
self._input_buffer = np.empty(
|
||||
(self.chunk_samples, SOURCE_CHANNELS), dtype=np.float64)
|
||||
self._buffer_used = 0
|
||||
self.input_samples = 0
|
||||
self.processed_input_samples = 0
|
||||
self.output_samples = 0
|
||||
self.finished = False
|
||||
self.metadata_block_updates = 0
|
||||
|
||||
@classmethod
|
||||
def from_sofa(
|
||||
cls, sofa: str | Path, *,
|
||||
mode: str = "mid",
|
||||
cache_policy: str = "memory",
|
||||
cache_dir: str | Path | None = DEFAULT_HRTF_CACHE_DIR,
|
||||
shell_radius_m: float = 1.0,
|
||||
projection_ridge: float = DEFAULT_PROJECTION_RIDGE,
|
||||
sh_ridge: float = DEFAULT_SH_RIDGE,
|
||||
object_delay_samples: int = 1473,
|
||||
tail_seconds: float = 5.0,
|
||||
output_gain: float = 1.0,
|
||||
chunk_frames: int = 64) -> "SofaBinauralRenderer":
|
||||
source = resolve_sofa_hrtf(sofa)
|
||||
backend = SofaBinauralBackend.from_sofa(
|
||||
source,
|
||||
source_count=SOURCE_CHANNELS,
|
||||
default_profile=mode,
|
||||
output_gain=output_gain,
|
||||
cache_policy=cache_policy,
|
||||
cache_dir=cache_dir,
|
||||
shell_radius_m=shell_radius_m,
|
||||
projection_ridge=projection_ridge,
|
||||
sh_ridge=sh_ridge)
|
||||
return cls(
|
||||
backend,
|
||||
mode=mode,
|
||||
object_delay_samples=object_delay_samples,
|
||||
tail_seconds=tail_seconds,
|
||||
chunk_frames=chunk_frames)
|
||||
|
||||
@classmethod
|
||||
def from_compiled_cache(
|
||||
cls, cache: str | Path, *,
|
||||
mode: str = "mid",
|
||||
object_delay_samples: int = 1473,
|
||||
tail_seconds: float = 5.0,
|
||||
output_gain: float = 1.0,
|
||||
chunk_frames: int = 64) -> "SofaBinauralRenderer":
|
||||
source = resolve_compiled_hrtf_cache(cache)
|
||||
backend = SofaBinauralBackend.from_compiled_cache(
|
||||
source,
|
||||
source_count=SOURCE_CHANNELS,
|
||||
default_profile=mode,
|
||||
output_gain=output_gain)
|
||||
return cls(
|
||||
backend,
|
||||
mode=mode,
|
||||
object_delay_samples=object_delay_samples,
|
||||
tail_seconds=tail_seconds,
|
||||
chunk_frames=chunk_frames)
|
||||
|
||||
@property
|
||||
def finish_capacity_samples(self) -> int:
|
||||
return self.backend.finish_output_capacity(self.tail_seconds)
|
||||
|
||||
def _append_input(self, samples: np.ndarray) -> list[np.ndarray]:
|
||||
outputs = []
|
||||
source = np.asarray(samples, dtype=np.float64)
|
||||
position = 0
|
||||
while position < len(source):
|
||||
count = min(self.chunk_samples - self._buffer_used, len(source) - position)
|
||||
self._input_buffer[self._buffer_used:self._buffer_used + count] = (
|
||||
source[position:position + count])
|
||||
self._buffer_used += count
|
||||
position += count
|
||||
if self._buffer_used == self.chunk_samples:
|
||||
outputs.append(self._process_samples(self._input_buffer))
|
||||
self._buffer_used = 0
|
||||
return outputs
|
||||
|
||||
def render_frame(self, objects16, payload=None, metadata_offset=None,
|
||||
*, outer_sample_offset=0) -> np.ndarray:
|
||||
"""Submit one 1536-sample reconstructed frame and its ID11 payload."""
|
||||
if self.finished:
|
||||
raise RuntimeError("binaural renderer is already finished")
|
||||
source = np.asarray(objects16)
|
||||
if source.shape != (FRAME_SAMPLES, SOURCE_CHANNELS):
|
||||
raise ValueError(
|
||||
f"binaural frame must have shape ({FRAME_SAMPLES},{SOURCE_CHANNELS}), "
|
||||
f"got {source.shape}")
|
||||
frame_start = self.input_samples
|
||||
metadata_delay = (self.object_delay_samples if metadata_offset is None
|
||||
else int(metadata_offset))
|
||||
if metadata_delay < 0:
|
||||
raise ValueError("metadata_offset must be non-negative")
|
||||
if payload is not None:
|
||||
self.timeline.submit_payload(
|
||||
payload,
|
||||
frame_start_sample=frame_start,
|
||||
outer_sample_offset=int(outer_sample_offset),
|
||||
object_delay_samples=metadata_delay,
|
||||
processed_sample=self.processed_input_samples,
|
||||
)
|
||||
self.metadata_block_updates += 1
|
||||
self.input_samples += FRAME_SAMPLES
|
||||
chunks = self._append_input(source)
|
||||
if not chunks:
|
||||
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
|
||||
return np.concatenate(chunks, axis=0) if len(chunks) > 1 else chunks[0]
|
||||
|
||||
def _set_block_parameters(self, sample: int) -> None:
|
||||
positions = self.timeline.positions_at(sample)
|
||||
self.backend.set_source(
|
||||
0, (0.0, 1.0, 0.0), profile=self.mode,
|
||||
special_lfe=True)
|
||||
for object_index in range(15):
|
||||
self.backend.set_source(
|
||||
object_index + 1,
|
||||
positions[object_index],
|
||||
profile=self.mode)
|
||||
|
||||
def _process_samples(self, source: np.ndarray) -> np.ndarray:
|
||||
values = np.asarray(source, dtype=np.float64)
|
||||
if values.ndim != 2 or values.shape[1] != SOURCE_CHANNELS:
|
||||
raise ValueError(f"expected [samples,{SOURCE_CHANNELS}], got {values.shape}")
|
||||
if len(values) % BINAURAL_BLOCK_SAMPLES:
|
||||
raise ValueError("binaural input must be divisible by 512 samples")
|
||||
outputs = []
|
||||
block_base = self.processed_input_samples
|
||||
for start in range(0, len(values), BINAURAL_BLOCK_SAMPLES):
|
||||
sample = block_base + start
|
||||
self._set_block_parameters(sample)
|
||||
outputs.append(self.backend.process(
|
||||
values[start:start + BINAURAL_BLOCK_SAMPLES]))
|
||||
self.processed_input_samples += len(values)
|
||||
nonempty = [value for value in outputs if len(value)]
|
||||
if not nonempty:
|
||||
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
|
||||
output = np.concatenate(nonempty, axis=0)
|
||||
self.output_samples += len(output)
|
||||
return output
|
||||
|
||||
def finish(self) -> np.ndarray:
|
||||
"""Process pending source samples and drain early/late room state once."""
|
||||
if self.finished:
|
||||
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
|
||||
outputs: list[np.ndarray] = []
|
||||
if self._buffer_used:
|
||||
outputs.append(self._process_samples(
|
||||
self._input_buffer[:self._buffer_used]))
|
||||
self._buffer_used = 0
|
||||
outputs.append(self.backend.finish(tail_seconds=self.tail_seconds))
|
||||
self.finished = True
|
||||
nonempty = [value for value in outputs if len(value)]
|
||||
if not nonempty:
|
||||
return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64)
|
||||
output = np.concatenate(nonempty, axis=0)
|
||||
self.output_samples += len(outputs[-1])
|
||||
return output
|
||||
|
||||
def close(self) -> None:
|
||||
self.finished = True
|
||||
|
||||
@property
|
||||
def backend_info(self) -> dict:
|
||||
info = self.backend.info()
|
||||
info.update({
|
||||
"adapter": "JOC 1536-frame / 512-sample metadata",
|
||||
"dsp_backend": self.dsp_backend,
|
||||
"mode": self.mode,
|
||||
"latency_compensated_samples": BINAURAL_LATENCY_SAMPLES,
|
||||
"object_delay_samples": self.object_delay_samples,
|
||||
"tail_seconds": self.tail_seconds,
|
||||
"metadata_payloads": self.timeline.payload_count,
|
||||
"metadata_position_transitions": self.timeline.transition_count,
|
||||
"input_samples": self.input_samples,
|
||||
"source_samples_processed": self.processed_input_samples,
|
||||
"output_samples_before_tail_trim": self.output_samples,
|
||||
"thread_safe": False,
|
||||
})
|
||||
return info
|
||||
Reference in New Issue
Block a user