diff --git a/native/include/eac3joc_core.h b/native/include/eac3joc_core.h index 53e8bd4..a227d15 100644 --- a/native/include/eac3joc_core.h +++ b/native/include/eac3joc_core.h @@ -64,6 +64,15 @@ EJOC_API ejoc_renderer_handle EJOC_CALL ejoc_renderer_create(void); EJOC_API void EJOC_CALL ejoc_renderer_destroy(ejoc_renderer_handle handle); EJOC_API int EJOC_CALL ejoc_renderer_reset(ejoc_renderer_handle handle); EJOC_API int EJOC_CALL ejoc_renderer_set_threads(ejoc_renderer_handle handle, uint32_t total_threads); +/* +Enables or disables the Ls/Rs band-0 21-tap DC compensation. The caller derives +it from the JOC downmix configuration: only configurations 3 and 4 enable the +filter. When disabled, band 0 keeps the common per-band processing (surround +delay plus -j rotation) instead of being overwritten by the FIR. The delay line +and DC history advance either way, so the flag may change between frames. +Defaults to enabled when never called. +*/ +EJOC_API int EJOC_CALL ejoc_renderer_set_dc_filter(ejoc_renderer_handle handle, uint32_t enabled); EJOC_API uint32_t EJOC_CALL ejoc_renderer_thread_count(ejoc_renderer_handle handle); EJOC_API const char* EJOC_CALL ejoc_renderer_last_error(ejoc_renderer_handle handle); diff --git a/native/src/eac3joc_core.cpp b/native/src/eac3joc_core.cpp index 9ea832d..3b79a24 100644 --- a/native/src/eac3joc_core.cpp +++ b/native/src/eac3joc_core.cpp @@ -125,6 +125,10 @@ public: return error_[0] ? error_ : ""; } + void set_dc_filter(const bool enabled) noexcept { + dc_filter_enabled_ = enabled; + } + int process( const float* bed5, const float* lfe, @@ -305,16 +309,18 @@ private: for (int i = 0; i < 4; ++i) { dc_buffer[20 + i] = current[i][0]; } - for (int slot = 0; slot < 4; ++slot) { - Complex sum{0.0, 0.0}; - for (int tap = 0; tap < 21; ++tap) { - const Complex sample = dc_buffer[slot + tap]; - const double cr = kDcB[tap]; - const double ci = kDcA[tap]; - sum.re += sample.re * cr - sample.im * ci; - sum.im += sample.re * ci + sample.im * cr; + if (dc_filter_enabled_) { + for (int slot = 0; slot < 4; ++slot) { + Complex sum{0.0, 0.0}; + for (int tap = 0; tap < 21; ++tap) { + const Complex sample = dc_buffer[slot + tap]; + const double cr = kDcB[tap]; + const double ci = kDcA[tap]; + sum.re += sample.re * cr - sample.im * ci; + sum.im += sample.re * ci + sample.im * cr; + } + x_[channel][0][group + slot] = {2.0 * sum.re, 2.0 * sum.im}; } - x_[channel][0][group + slot] = {2.0 * sum.re, 2.0 * sum.im}; } for (int i = 0; i < 20; ++i) { surround_history_[surround][i] = dc_buffer[i + 4]; @@ -641,6 +647,8 @@ private: float analysis_phase_; alignas(64) Complex surround_delay_[2][10][64]; alignas(64) Complex surround_history_[2][20]; + // band-0 的 21-tap DC 补偿开关;仅 downmix 配置 3/4 由调用方置位。 + bool dc_filter_enabled_ = true; alignas(64) double lfe_delay_[kLfeDelay]; alignas(64) double matrix_previous_[15][5][64]; alignas(64) double synthesis_state_[15][640]; @@ -696,6 +704,14 @@ int EJOC_CALL ejoc_renderer_set_threads(ejoc_renderer_handle handle, uint32_t to return static_cast(handle)->set_threads(total_threads); } +int EJOC_CALL ejoc_renderer_set_dc_filter(ejoc_renderer_handle handle, uint32_t enabled) { + if (!handle) { + return -1; + } + static_cast(handle)->set_dc_filter(enabled != 0); + return 0; +} + uint32_t EJOC_CALL ejoc_renderer_thread_count(ejoc_renderer_handle handle) { if (!handle) { return 0; diff --git a/src/binaural_metadata.py b/src/binaural_metadata.py index 5966baf..12d387e 100644 --- a/src/binaural_metadata.py +++ b/src/binaural_metadata.py @@ -7,10 +7,9 @@ import numpy as np from adm_atmos import q_to_adm_xyz from oamd_bits import JocFieldState, frame_update +from oamd_tracks import align_metadata_sample from variant_error import UnsupportedVariantError -OAMD_UPDATE_QUANTUM_SAMPLES = 64 - @dataclass(frozen=True) class PositionTransition: @@ -154,11 +153,8 @@ class OamdPositionTimeline: 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 + effective_ramp = max(0, int(update["ramp_duration_samples"])) + transition_start = align_metadata_sample(coded_event + object_delay) for index, target in enumerate(targets): if self.previous_targets[index] == target: continue diff --git a/src/joc_decode.py b/src/joc_decode.py index bc716d1..b883476 100644 --- a/src/joc_decode.py +++ b/src/joc_decode.py @@ -35,6 +35,8 @@ def _load_huff_tables(): H = _load_huff_tables() JOC_NUM_CHANNELS = {0: 5, 1: 7, 2: 7, 3: 5, 4: 7} # Table 33 +# band-0 的 21-tap DC 补偿只在 downmix 配置 3/4 下启用。 +DC_FILTER_DMX_CONFIGS = (3, 4) JOC_NUM_BANDS = {0: 1, 1: 3, 2: 5, 3: 7, 4: 9, 5: 12, 6: 15, 7: 23} # Table 35 JOC_NUM_QUANT = {0: 96, 1: 192} # Table 51 # dense 的量化零点就是 nquant/2;sparse 的递推起点比它高 2 个量化步。 diff --git a/src/joc_qmf.py b/src/joc_qmf.py index 77e6cc6..220324c 100644 --- a/src/joc_qmf.py +++ b/src/joc_qmf.py @@ -130,8 +130,12 @@ _SURROUND_DC_B = np.array([ _SURROUND_DC_C = _SURROUND_DC_B + 1j * _SURROUND_DC_A -def surround_post_frame(x, delay, dc_hist): - """处理 Ls/Rs 的 10 槽延迟、-j 旋转和 band-0 FIR。""" +def surround_post_frame(x, delay, dc_hist, apply_dc_filter=True): + """处理 Ls/Rs 的 10 槽延迟、-j 旋转和 band-0 FIR。 + + ``apply_dc_filter=False`` 时跳过 band-0 的 21-tap DC 补偿,只做延迟与 + -j 旋转;延迟线与 DC 历史仍照常推进,便于逐帧切换。 + """ src = np.asarray(x, dtype=np.complex128) qdelay = np.asarray(delay, dtype=np.complex128).copy() hist = np.asarray(dc_hist, dtype=np.complex128).copy() @@ -144,8 +148,9 @@ def surround_post_frame(x, delay, dc_hist): block = -1j * queued[:, :4, :] qdelay = queued[:, 4:, :] dc_buf = np.concatenate((hist, current[:, :, 0]), axis=1) - windows = np.lib.stride_tricks.sliding_window_view(dc_buf, 21, axis=1) - block[:, :, 0] = 2.0 * np.sum(windows * _SURROUND_DC_C[None, None, :], axis=2) + if apply_dc_filter: + windows = np.lib.stride_tricks.sliding_window_view(dc_buf, 21, axis=1) + block[:, :, 0] = 2.0 * np.sum(windows * _SURROUND_DC_C[None, None, :], axis=2) hist = dc_buf[:, 4:] out[:, :, group:group + 4] = block.transpose(0, 2, 1) return out, qdelay, hist diff --git a/src/native_renderer.py b/src/native_renderer.py index 1ba013b..5982cb2 100644 --- a/src/native_renderer.py +++ b/src/native_renderer.py @@ -14,7 +14,7 @@ import sys import numpy as np from evo_unpack import unpack_evolution -from joc_decode import dequantize, diff_decode, parse_joc +from joc_decode import DC_FILTER_DMX_CONFIGS, dequantize, diff_decode, parse_joc FRAME_SAMPLES = 1536 MAX_OBJECTS = 15 @@ -88,6 +88,9 @@ def _load_library(path): lib.ejoc_renderer_reset.restype = ctypes.c_int lib.ejoc_renderer_set_threads.argtypes = [ctypes.c_void_p, ctypes.c_uint32] lib.ejoc_renderer_set_threads.restype = ctypes.c_int + if hasattr(lib, "ejoc_renderer_set_dc_filter"): + lib.ejoc_renderer_set_dc_filter.argtypes = [ctypes.c_void_p, ctypes.c_uint32] + lib.ejoc_renderer_set_dc_filter.restype = ctypes.c_int lib.ejoc_renderer_thread_count.argtypes = [ctypes.c_void_p] lib.ejoc_renderer_thread_count.restype = ctypes.c_uint32 lib.ejoc_renderer_last_error.argtypes = [ctypes.c_void_p] @@ -225,6 +228,21 @@ class NativeJocRenderer: self._dq[object_index, :points, :, :bands] = values return mask + def _set_dc_filter(self, dmx_config_idx): + """band-0 的 21-tap DC 补偿只在 downmix 配置 3/4 下启用。""" + enabled = dmx_config_idx in DC_FILTER_DMX_CONFIGS + setter = getattr(self._lib, "ejoc_renderer_set_dc_filter", None) + if setter is None: + if not enabled: + raise RuntimeError( + "native library has no ejoc_renderer_set_dc_filter; rebuild " + "eac3joc_core to disable the band-0 DC filter for " + f"dmx_config_idx={dmx_config_idx}") + return + result = setter(self._handle, 1 if enabled else 0) + if result != 0: + self._raise_native("set_dc_filter", result) + def render_frame(self, payload_bytes, bed5_pcm, lfe_pcm=None): subs, _ = unpack_evolution(payload_bytes, loose=True) return self.render_subpayloads(subs, bed5_pcm, lfe_pcm) @@ -233,6 +251,7 @@ class NativeJocRenderer: self._require_open() out, _, mix_dq = self.decode_subpayloads(subs) object_mask = self._pack_frame(out, mix_dq) + self._set_dc_filter(out["dmx_config_idx"]) bed5 = np.ascontiguousarray(bed5_pcm, dtype=np.float32) if bed5.shape != (CORE_CHANNELS, FRAME_SAMPLES): raise ValueError(f"core PCM shape must be (5,1536), got {bed5.shape}") diff --git a/src/oamd_tracks.py b/src/oamd_tracks.py index b7ec34e..746fb17 100644 --- a/src/oamd_tracks.py +++ b/src/oamd_tracks.py @@ -5,6 +5,18 @@ from adm_atmos import q_to_adm_xyz from oamd_bits import JocFieldState, frame_update from variant_error import UnsupportedVariantError +# 元数据更新时刻按渲染器处理块对齐,与 Dolby 的 processing_block_size 及 +# speaker_renderer 的 block_size 落在同一网格。 +METADATA_BLOCK_SAMPLES = 32 + + +def align_metadata_sample(sample, block_samples=METADATA_BLOCK_SAMPLES): + """把更新时刻量化到处理块边界。""" + block = int(block_samples) + if block <= 0: + raise ValueError("block_samples must be positive") + return block * ((int(sample) + block // 2 - 1) // block) + def _lerp_xyz(start, target, amount): return tuple(a + (b - a) * amount for a, b in zip(start, target)) @@ -18,18 +30,19 @@ def _append_point(points, sample, xyz, interpolation_samples): points.append(item) -def _expand_events_dense64(events, total_samples, rate, update_quantum_samples, +def _expand_events_dense64(events, total_samples, rate, update_block_samples, object_delay_samples, object_index): if not events: return [(0.0, 0.0, 0.0, 0.0, total_samples / float(rate), 0.0)] + block = int(update_block_samples) # 初始位置从成品 sample 0 起有效;合成延迟只作用于后续位置变化。 current = events[0][1] points = [] _append_point(points, 0, current, 0) for event_index, (coded_start, target, ramp_samples) in enumerate(events[1:], 1): - start = coded_start + object_delay_samples + start = align_metadata_sample(coded_start + object_delay_samples, block) if start >= total_samples: break if start < points[-1][0]: @@ -43,15 +56,16 @@ def _expand_events_dense64(events, total_samples, rate, update_quantum_samples, if start > points[-1][0]: _append_point(points, start, current, 0) - effective_ramp = max(0, int(ramp_samples) - update_quantum_samples) - if effective_ramp == 0: + ramp = max(0, int(ramp_samples)) + if ramp == 0: _append_point(points, start, target, 0) current = target continue - end = start + math.ceil(effective_ramp / update_quantum_samples) * update_quantum_samples + end = start + math.ceil(ramp / block) * block if event_index + 1 < len(events): - next_start = events[event_index + 1][0] + object_delay_samples + next_start = align_metadata_sample( + events[event_index + 1][0] + object_delay_samples, block) if next_start < end: raise UnsupportedVariantError( "oamd", "overlapping_position_ramps", @@ -61,23 +75,23 @@ def _expand_events_dense64(events, total_samples, rate, update_quantum_samples, "ramp_start_sample": start, "ramp_end_sample": end, "next_update_sample": next_start, - "repair_hint": "按 64-sample 状态机截断旧 ramp,再从当前插值位置启动新 ramp", + "repair_hint": "按 32-sample 状态机截断旧 ramp,再从当前插值位置启动新 ramp", }) - # 逐位置更新节拍复现状态机。1536-sample ramp 在首次 64-sample - # 更新后剩余 1472 samples,因此共有 23 个中间/终点坐标。 - future = effective_ramp + # 从对齐后的更新点起按处理块推进,整条 ramp 覆盖 ramp_samples 个样本; + # 几何式逼近望远镜化简为精确线性,每个中间点都落在真实 ramp 上。 + future = ramp elapsed = 0 position = current while future > 0: - amount = min(update_quantum_samples / float(future), 1.0) + amount = min(block / float(future), 1.0) position = _lerp_xyz(position, target, amount) - elapsed += update_quantum_samples + elapsed += block sample = start + elapsed if sample >= total_samples: break - _append_point(points, sample, position, update_quantum_samples) - future -= update_quantum_samples + _append_point(points, sample, position, block) + future -= block current = target blocks = [] @@ -94,16 +108,14 @@ def _expand_events_dense64(events, total_samples, rate, update_quantum_samples, -def _compact_events(events, total_samples, rate, update_quantum_samples, +def _compact_events(events, total_samples, rate, update_block_samples, object_delay_samples, object_index): """Represent each linear OAMD ramp with one ADM interpolation block. - The existing dense64 representation keeps the old position at ``start``, - writes its first interpolated target at ``start + quantum``, and lets ADM - interpolate that block over one quantum. Consequently, the interpreted - motion begins at ``start + quantum`` and reaches the final target at - ``start + ramp_duration``. This compact form preserves that timing with one - target block whose interpolationLength is ``ramp_duration - quantum``. + The update instant is quantized to the processing block boundary, the motion + starts there immediately, and ``interpolationLength`` spans the full + ``ramp_duration``. The interpreted motion therefore covers + ``[align(start), align(start) + ramp_duration]``. """ if not events: return [(0.0, 0.0, 0.0, 0.0, total_samples / float(rate), 0.0)] @@ -112,15 +124,16 @@ def _compact_events(events, total_samples, rate, update_quantum_samples, current = events[0][1] _append_point(points, 0, current, 0) + block = int(update_block_samples) for event_index, (coded_start, target, ramp_samples) in enumerate(events[1:], 1): - event_start = coded_start + object_delay_samples + event_start = align_metadata_sample(coded_start + object_delay_samples, block) if event_start >= total_samples: break - effective_ramp = max(0, int(ramp_samples) - update_quantum_samples) - block_start = event_start + (update_quantum_samples if effective_ramp else 0) + ramp = max(0, int(ramp_samples)) + block_start = event_start if block_start >= total_samples: break - ramp_end = block_start + effective_ramp + ramp_end = block_start + ramp if block_start < points[-1][0]: raise UnsupportedVariantError( @@ -131,9 +144,9 @@ def _compact_events(events, total_samples, rate, update_quantum_samples, if event_index + 1 < len(events): next_coded_start, _, next_ramp_samples = events[event_index + 1] - next_event_start = next_coded_start + object_delay_samples - next_effective = max(0, int(next_ramp_samples) - update_quantum_samples) - next_block_start = next_event_start + (update_quantum_samples if next_effective else 0) + next_event_start = align_metadata_sample( + next_coded_start + object_delay_samples, block) + next_block_start = next_event_start if next_block_start < ramp_end: raise UnsupportedVariantError( "oamd", "overlapping_compact_position_ramps", @@ -147,10 +160,10 @@ def _compact_events(events, total_samples, rate, update_quantum_samples, }) block_target = target - block_interpolation = effective_ramp + block_interpolation = ramp available = total_samples - block_start - if effective_ramp > available: - block_target = _lerp_xyz(current, target, available / float(effective_ramp)) + if ramp > available: + block_target = _lerp_xyz(current, target, available / float(ramp)) block_interpolation = available _append_point(points, block_start, block_target, block_interpolation) current = target @@ -168,25 +181,26 @@ def _compact_events(events, total_samples, rate, update_quantum_samples, return blocks -def _expand_events(events, total_samples, rate, update_quantum_samples, +def _expand_events(events, total_samples, rate, update_block_samples, object_delay_samples, object_index, trajectory_mode): if trajectory_mode == "compact": - return _compact_events(events, total_samples, rate, update_quantum_samples, + return _compact_events(events, total_samples, rate, update_block_samples, object_delay_samples, object_index) if trajectory_mode == "dense64": - return _expand_events_dense64(events, total_samples, rate, update_quantum_samples, + return _expand_events_dense64(events, total_samples, rate, update_block_samples, object_delay_samples, object_index) raise ValueError(f"未知 trajectory_mode: {trajectory_mode}") def build_adm_tracks(index, frames=None, rate=48000, frame_samples=1536, - update_quantum_samples=64, object_delay_samples=1473, - trajectory_mode="compact"): + update_block_samples=METADATA_BLOCK_SAMPLES, + object_delay_samples=1473, trajectory_mode="compact"): """从统一 metadata index 构造 15 条 ADM 轨迹。 返回 ``[(name, [(rtime,x,y,z,duration,interpolation), ...]), ...]``。 - OAMD 的内外层 sample offset、block offset 和 ramp 均保留。 + OAMD 的内外层 sample offset、block offset 和 ramp 均保留;更新时刻量化到 + ``update_block_samples`` 的块边界,ramp 覆盖完整的 ramp_duration。 ``trajectory_mode="compact"`` 用一个长 ADM interpolation block 表示每条 - 线性 ramp;``dense64`` 保留逐 64-sample 展开作为兼容回退。 + 线性 ramp;``dense64`` 保留逐块展开作为兼容回退。 ``object_delay_samples`` 将位置更新与对象 PCM 的 decoder 输出时刻对齐。 slot1..15 与对象 PCM ch1..15 一一对应。 """ @@ -221,7 +235,7 @@ def build_adm_tracks(index, frames=None, rate=48000, frame_samples=1536, return [ (f"JOC_Object_{obj}", _expand_events(events[obj - 1], total_samples, rate, - update_quantum_samples, object_delay_samples, obj, + update_block_samples, object_delay_samples, obj, trajectory_mode)) for obj in range(1, 16) ] diff --git a/src/renderer.py b/src/renderer.py index 540dd7e..516ba89 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -13,7 +13,7 @@ output_scale: """ import numpy as np -from joc_decode import parse_joc, diff_decode, dequantize +from joc_decode import DC_FILTER_DMX_CONFIGS, parse_joc, diff_decode, dequantize from joc_qmf import (N, QMF5_WINDOW, qmf_analysis_frame, surround_post_frame, interp_matrix) from evo_unpack import unpack_evolution @@ -60,11 +60,12 @@ class JocRenderer: mix_dq = dequantize(out, mix_q) return out, mix_q, mix_dq - def qmf_x(self, bed5, phase_new=0.0625): + def qmf_x(self, bed5, phase_new=0.0625, apply_dc_filter=True): """核心 5ch PCM → 对象矩阵使用的复数 QMF ``x``。 先以 float32 对当前帧应用 phase;phase 变化时仅前 256 个样本从旧值 线性过渡。缩放后的 L/R/C 延迟 10 槽,Ls/Rs 不延迟,再进入分析 QMF。 + ``apply_dc_filter`` 控制 Ls/Rs band-0 的 21-tap DC 补偿。 """ pcm = np.asarray(bed5, dtype=np.float32) if pcm.shape != (5, 1536): @@ -87,7 +88,8 @@ class JocRenderer: x, self._analysis_fifo = qmf_analysis_frame(self._analysis_fifo, blocks) self._analysis_phase = new_phase x[3:], self._surround_qmf_delay, self._surround_dc_hist = surround_post_frame( - x[3:], self._surround_qmf_delay, self._surround_dc_hist) + x[3:], self._surround_qmf_delay, self._surround_dc_hist, + apply_dc_filter=apply_dc_filter) return x def object_z(self, out, mix_dq, x): @@ -244,7 +246,9 @@ class JocRenderer: def render_subpayloads(self, subs, bed5_pcm, lfe_pcm=None): """以已拆出的 EMDF payload 字典渲染一帧,避免绑定 transport 容器。""" out, mix_q, mix_dq = self.decode_subpayloads(subs) - x = self.qmf_x(bed5_pcm) + x = self.qmf_x( + bed5_pcm, + apply_dc_filter=out["dmx_config_idx"] in DC_FILTER_DMX_CONFIGS) self._last_x = x.copy() z_all = self.object_z(out, mix_dq, x) # joc_clipgain 在对象逆 QMF 后应用,并与用户 output_scale 分离。