Archive DLL-free personalized HRTF binaural renderer
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Direct ID11/OAMD position scheduling for the Rosella binaural path."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from adm_atmos import q_to_adm_xyz
|
||||
from oamd_bits import JocFieldState, frame_update
|
||||
from variant_error import UnsupportedVariantError
|
||||
|
||||
OAMD_UPDATE_QUANTUM_SAMPLES = 64
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PositionTransition:
|
||||
start_sample: int
|
||||
duration_samples: int
|
||||
origin: np.ndarray
|
||||
target: np.ndarray
|
||||
|
||||
@property
|
||||
def end_sample(self) -> int:
|
||||
return self.start_sample + self.duration_samples
|
||||
|
||||
|
||||
class _ObjectPositionTrack:
|
||||
def __init__(self):
|
||||
self.initial = np.zeros(3, dtype=np.float64)
|
||||
self.last_target = self.initial.copy()
|
||||
self.transitions: list[PositionTransition] = []
|
||||
self.cursor = 0
|
||||
self.last_query_sample = -1
|
||||
|
||||
def set_initial(self, position):
|
||||
target = np.asarray(position, dtype=np.float64)
|
||||
self.initial = target.copy()
|
||||
self.last_target = target.copy()
|
||||
|
||||
def append(self, start_sample: int, duration_samples: int, target,
|
||||
object_index: int):
|
||||
start = int(start_sample)
|
||||
duration = int(duration_samples)
|
||||
if start < 0 or duration < 0:
|
||||
raise ValueError("position transition timing must be non-negative")
|
||||
target = np.asarray(target, dtype=np.float64)
|
||||
if self.transitions:
|
||||
previous = self.transitions[-1]
|
||||
if start < previous.end_sample:
|
||||
raise UnsupportedVariantError(
|
||||
"oamd", "overlapping_binaural_position_ramps",
|
||||
"同一对象的新位置更新在上一双耳 ramp 完成前到达",
|
||||
details={
|
||||
"object": object_index,
|
||||
"ramp_start_sample": previous.start_sample,
|
||||
"ramp_end_sample": previous.end_sample,
|
||||
"next_update_sample": start,
|
||||
})
|
||||
if start == previous.start_sample and previous.duration_samples == 0:
|
||||
self.transitions[-1] = PositionTransition(
|
||||
start, duration, previous.origin.copy(), target.copy())
|
||||
self.last_target = target.copy()
|
||||
return
|
||||
self.transitions.append(PositionTransition(
|
||||
start, duration, self.last_target.copy(), target.copy()))
|
||||
self.last_target = target.copy()
|
||||
|
||||
def position_at(self, sample: int) -> np.ndarray:
|
||||
sample = int(sample)
|
||||
if sample < self.last_query_sample:
|
||||
raise ValueError("binaural metadata positions must be queried monotonically")
|
||||
self.last_query_sample = sample
|
||||
while self.cursor < len(self.transitions):
|
||||
transition = self.transitions[self.cursor]
|
||||
if sample < transition.end_sample:
|
||||
break
|
||||
self.initial = transition.target.copy()
|
||||
self.cursor += 1
|
||||
if self.cursor >= len(self.transitions):
|
||||
return self.initial
|
||||
transition = self.transitions[self.cursor]
|
||||
if sample < transition.start_sample:
|
||||
return self.initial
|
||||
if transition.duration_samples == 0:
|
||||
return transition.target
|
||||
amount = (sample - transition.start_sample) / float(transition.duration_samples)
|
||||
return transition.origin + (transition.target - transition.origin) * amount
|
||||
|
||||
|
||||
class OamdPositionTimeline:
|
||||
"""Convert OAMD state updates into a sample-timed Cartesian trajectory."""
|
||||
|
||||
def __init__(self, object_count: int = 15):
|
||||
if object_count != 15:
|
||||
raise ValueError("JOC OAMD currently requires 15 object slots")
|
||||
self.object_count = int(object_count)
|
||||
self.state = JocFieldState()
|
||||
self.tracks = [_ObjectPositionTrack() for _ in range(self.object_count)]
|
||||
self.initialized = False
|
||||
self.previous_targets: list[tuple[float, float, float] | None] = [
|
||||
None] * self.object_count
|
||||
self.payload_count = 0
|
||||
self.transition_count = 0
|
||||
self.last_coded_event_sample = -1
|
||||
|
||||
def _targets(self) -> list[tuple[float, float, float]]:
|
||||
q = self.state.q
|
||||
return [
|
||||
q_to_adm_xyz(
|
||||
q[(object_index, "q1")],
|
||||
q[(object_index, "q2")],
|
||||
q[(object_index, "q3")],
|
||||
)
|
||||
for object_index in range(1, self.object_count + 1)
|
||||
]
|
||||
|
||||
def submit_update(self, update: dict, *, frame_start_sample: int,
|
||||
outer_sample_offset: int = 0,
|
||||
object_delay_samples: int = 1473,
|
||||
processed_sample: int = 0):
|
||||
"""Schedule one already-parsed :func:`oamd_bits.frame_update` result."""
|
||||
frame_start = int(frame_start_sample)
|
||||
outer_offset = int(outer_sample_offset)
|
||||
object_delay = int(object_delay_samples)
|
||||
if min(frame_start, outer_offset, object_delay) < 0:
|
||||
raise ValueError("OAMD frame, outer offset, and object delay must be non-negative")
|
||||
self.state.apply(update["values"])
|
||||
targets = self._targets()
|
||||
coded_event = (
|
||||
frame_start + outer_offset + int(update["block_offset_samples"]))
|
||||
if coded_event < self.last_coded_event_sample:
|
||||
raise UnsupportedVariantError(
|
||||
"oamd", "non_monotonic_binaural_updates",
|
||||
"双耳 OAMD 更新时间倒退",
|
||||
details={
|
||||
"event_sample": coded_event,
|
||||
"previous_event_sample": self.last_coded_event_sample,
|
||||
})
|
||||
self.last_coded_event_sample = coded_event
|
||||
|
||||
if not self.initialized:
|
||||
if int(processed_sample) > 0:
|
||||
raise UnsupportedVariantError(
|
||||
"oamd", "late_initial_binaural_state",
|
||||
"首个 OAMD 状态在双耳 PCM 已处理后才出现,无法回填 sample 0",
|
||||
details={
|
||||
"processed_sample": int(processed_sample),
|
||||
"first_event_sample": coded_event,
|
||||
})
|
||||
for index, target in enumerate(targets):
|
||||
self.tracks[index].set_initial(target)
|
||||
self.previous_targets[index] = target
|
||||
self.initialized = True
|
||||
self.payload_count += 1
|
||||
return
|
||||
|
||||
ramp_duration = int(update["ramp_duration_samples"])
|
||||
effective_ramp = max(0, ramp_duration - OAMD_UPDATE_QUANTUM_SAMPLES)
|
||||
transition_start = coded_event + object_delay
|
||||
if effective_ramp:
|
||||
transition_start += OAMD_UPDATE_QUANTUM_SAMPLES
|
||||
for index, target in enumerate(targets):
|
||||
if self.previous_targets[index] == target:
|
||||
continue
|
||||
self.tracks[index].append(
|
||||
transition_start, effective_ramp, target, index + 1)
|
||||
self.previous_targets[index] = target
|
||||
self.transition_count += 1
|
||||
self.payload_count += 1
|
||||
|
||||
def submit_payload(self, payload, *, frame_start_sample: int,
|
||||
outer_sample_offset: int = 0,
|
||||
object_delay_samples: int = 1473,
|
||||
processed_sample: int = 0):
|
||||
update = frame_update(payload)
|
||||
self.submit_update(
|
||||
update,
|
||||
frame_start_sample=frame_start_sample,
|
||||
outer_sample_offset=outer_sample_offset,
|
||||
object_delay_samples=object_delay_samples,
|
||||
processed_sample=processed_sample,
|
||||
)
|
||||
return update
|
||||
|
||||
def positions_at(self, sample: int) -> np.ndarray:
|
||||
return np.stack(
|
||||
[track.position_at(sample) for track in self.tracks], axis=0
|
||||
).astype(np.float64, copy=False)
|
||||
Reference in New Issue
Block a user