From 2a296099fa17808f731321cebb648e591aa5eb80 Mon Sep 17 00:00:00 2001 From: TheM14 Date: Sun, 6 Sep 2026 19:14:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=8C=E8=80=B3=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/native-release.yml | 198 ++-- .gitignore | 11 + README.en.md | 110 ++- README.md | 95 +- THIRD_PARTY_NOTICES.md | 39 + data/README.en.md | 65 +- data/README.md | 55 +- data/rosella_kernels.npz | Bin 0 -> 177268 bytes docs/binaural.en.md | 277 ++++++ docs/binaural.md | 238 +++++ docs/native.en.md | 25 +- docs/native.md | 38 +- main.py | 460 ++++++++- native/CMakeLists.txt | 2 + native/include/eac3joc_core.h | 117 ++- native/src/binaural_renderer.cpp | 664 +++++++++++++ native/src/eac3joc_core.cpp | 8 +- native/src/sofa_binaural_renderer.cpp | 1231 +++++++++++++++++++++++++ requirements.txt | 2 + src/adm_atmos.py | 26 +- src/binaural_metadata.py | 188 ++++ src/binaural_native_renderer.py | 214 +++++ src/binaural_renderer.py | 272 ++++++ src/public_filterbank.py | 692 ++++++++++++++ src/public_room.py | 253 +++++ src/reference_distance.py | 115 +++ src/rosella_binaural_renderer.py | 308 +++++++ src/rosella_core.py | 81 ++ src/rosella_direct.py | 302 ++++++ src/rosella_filterbank.py | 190 ++++ src/rosella_model.py | 517 +++++++++++ src/rosella_room.py | 198 ++++ src/sofa_binaural_backend.py | 552 +++++++++++ src/sofa_canonical.py | 637 +++++++++++++ src/sofa_hrtf_field.py | 1020 ++++++++++++++++++++ src/sofa_native_backend.py | 398 ++++++++ src/speaker_wav.py | 132 ++- src/spherical_harmonics.py | 144 +++ 38 files changed, 9660 insertions(+), 214 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 data/rosella_kernels.npz create mode 100644 docs/binaural.en.md create mode 100644 docs/binaural.md create mode 100644 native/src/binaural_renderer.cpp create mode 100644 native/src/sofa_binaural_renderer.cpp create mode 100644 src/binaural_metadata.py create mode 100644 src/binaural_native_renderer.py create mode 100644 src/binaural_renderer.py create mode 100644 src/public_filterbank.py create mode 100644 src/public_room.py create mode 100644 src/reference_distance.py create mode 100644 src/rosella_binaural_renderer.py create mode 100644 src/rosella_core.py create mode 100644 src/rosella_direct.py create mode 100644 src/rosella_filterbank.py create mode 100644 src/rosella_model.py create mode 100644 src/rosella_room.py create mode 100644 src/sofa_binaural_backend.py create mode 100644 src/sofa_canonical.py create mode 100644 src/sofa_hrtf_field.py create mode 100644 src/sofa_native_backend.py create mode 100644 src/spherical_harmonics.py diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 3e18fb0..b0df8bc 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1,99 +1,99 @@ -name: Native builds - -on: - workflow_dispatch: - push: - branches: - - main - tags: - - "v*" - -permissions: - contents: read - -jobs: - build: - name: ${{ matrix.asset }} - runs-on: ${{ matrix.runner }} - - strategy: - fail-fast: false - matrix: - include: - - runner: windows-2022 - asset: windows-x64 - cmake_args: -A x64 - - - runner: ubuntu-22.04 - asset: linux-x64 - cmake_args: "" - - - runner: macos-15-intel - asset: macos-x64 - cmake_args: -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 - - - runner: macos-15 - asset: macos-arm64 - cmake_args: -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Configure - run: > - cmake - -S native - -B build/native - -DCMAKE_BUILD_TYPE=Release - -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/stage" - ${{ matrix.cmake_args }} - - - name: Build - run: cmake --build build/native --config Release --parallel - - - name: Install - run: cmake --install build/native --config Release - - - name: Package - working-directory: stage - run: > - cmake -E tar - cf "../JustOneCacophony-native-${{ matrix.asset }}.zip" - --format=zip - -- . - - - name: Upload workflow artifact - uses: actions/upload-artifact@v4 - with: - name: JustOneCacophony-native-${{ matrix.asset }} - path: JustOneCacophony-native-${{ matrix.asset }}.zip - if-no-files-found: error - retention-days: 14 - - release: - name: Publish GitHub Release - if: startsWith(github.ref, 'refs/tags/v') - needs: build - runs-on: ubuntu-24.04 - - permissions: - contents: write - - steps: - - name: Download native packages - uses: actions/download-artifact@v5 - with: - pattern: JustOneCacophony-native-* - path: dist - merge-multiple: true - - - name: Create release - run: > - gh release create "$GITHUB_REF_NAME" - dist/*.zip - --verify-tag - --generate-notes - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} +name: Native builds + +on: + workflow_dispatch: + push: + branches: + - main + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.asset }} + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + asset: windows-x64 + cmake_args: -A x64 + + - runner: ubuntu-22.04 + asset: linux-x64 + cmake_args: "" + + - runner: macos-15-intel + asset: macos-x64 + cmake_args: -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 + + - runner: macos-15 + asset: macos-arm64 + cmake_args: -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure + run: > + cmake + -S native + -B build/native + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/stage" + ${{ matrix.cmake_args }} + + - name: Build + run: cmake --build build/native --config Release --parallel + + - name: Install + run: cmake --install build/native --config Release + + - name: Package + working-directory: stage + run: > + cmake -E tar + cf "../JustOneCacophony-native-${{ matrix.asset }}.zip" + --format=zip + -- . + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: JustOneCacophony-native-${{ matrix.asset }} + path: JustOneCacophony-native-${{ matrix.asset }}.zip + if-no-files-found: error + retention-days: 14 + + release: + name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-24.04 + + permissions: + contents: write + + steps: + - name: Download native packages + uses: actions/download-artifact@v5 + with: + pattern: JustOneCacophony-native-* + path: dist + merge-multiple: true + + - name: Create release + run: > + gh release create "$GITHUB_REF_NAME" + dist/*.zip + --verify-tag + --generate-notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} diff --git a/.gitignore b/.gitignore index c49ecf0..64fc5a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.py[cod] +.pytest_cache/ .venv/ venv/ @@ -15,3 +16,13 @@ metadata_cache/ *.variant-error.json *.objects16.f32le +HRTF/ + +# User HRTF data and compiled caches are never committed: +# SOFA/measurement data (conventionally under HRTF/), Dolby personalization +# scan models, and rebuilt-from-SOFA .jochrtf caches. +*.sofa +*.personalized_headphone +*.jochrtf + +# 测试与研究内容一律不入库(tests/ 全部忽略,无白名单)。 diff --git a/README.en.md b/README.en.md index 091d50f..3adfebe 100644 --- a/README.en.md +++ b/README.en.md @@ -4,9 +4,9 @@ > JustOneCacophony is an experimental/test implementation of E-AC-3 JOC for studying JOC parsing, reconstruction, rendering, and the associated mathematics. -The project can extract and parse EMDF, ID14 JOC parameters, and ID11 OAMD metadata from common E-AC-3 JOC streams. It combines those data with the core 5.1 PCM decoded by FFmpeg, reconstructs LFE plus 15 object channels, and writes either ADM BWF or a WAV file for a selected speaker layout. +The project can extract and parse EMDF, ID14 JOC parameters, and ID11 OAMD metadata from common E-AC-3 JOC streams. It combines those data with the core 5.1 PCM decoded by FFmpeg, reconstructs LFE plus 15 object channels, and writes ADM BWF, a WAV file for a selected speaker layout, or direct binaural stereo using a standard SOFA HRTF. -This is research code, not a complete, standards-compliant, or production-grade Dolby JOC decoder. It covers only the stream forms currently implemented. Unknown variants fail explicitly—because when the math goes wrong, all that may remain is the cacophony. +This is research code, not a complete, standards-compliant, or production-grade JOC decoder. It covers only the stream forms currently implemented. Unknown variants fail explicitly—because when the math goes wrong, all that may remain is the cacophony. ## Current features @@ -16,7 +16,9 @@ This is research code, not a complete, standards-compliant, or production-grade - Reconstruct LFE plus 15 object channels through analysis QMF, parameter interpolation, the object matrix, and inverse QMF. - Write a 25-channel ADM BWF: a 10-channel 7.1.2 bed (silent except for LFE) plus 15 objects. - Render directly to `2.0`, `3.1`, `5.1`, `7.1`, `5.1.2`, `5.1.4`, `7.1.2`, `7.1.4`, `9.1.4`, or `9.1.6`. -- Write float32 or PCM24 WAV and require an explicit policy when PCM24 would clip. +- Run public SOFA binaural rendering directly from `pcm16 + ID11/OAMD`, without a temporary ADM BWF. +- Keep the binaural DSP in float64/complex128, including 961-sample latency compensation, cross-frame state, and the room tail. +- Use a shared float32/PCM24 WAV writer and explicit PCM24 clipping policy for direct outputs. - Use the NumPy backend or an optional C++20 core through `ctypes`; `auto` falls back to Python when the native library is unavailable. - Read or write metadata sidecars and produce metadata, timing, and output reports. @@ -30,15 +32,18 @@ M4A / E-AC-3 ├─ ID11 OAMD → object positions and timing └─ LFE + 15 objects ├─ 25ch ADM BWF - └─ speaker WAV for the selected layout + ├─ speaker WAV for the selected layout + └─ direct ID11 timeline + SOFA HRTF → binaural WAV ``` -The Python and C++ backends follow the same documented mathematics. The native core handles the state-heavy DSP and speaker rendering; high-level bitstream parsing, ADM assembly, and CLI behavior remain in Python. +The Python and C++ backends follow the same mathematics for JOC object reconstruction and speaker rendering. The public SOFA binaural backend currently runs in Python; bitstream parsing, the OAMD timeline, and CLI behavior also remain in Python. ## Requirements - Python 3.10+ - NumPy 1.24+ +- h5py 3.8+ +- SciPy 1.10+ - A standalone FFmpeg executable; `ffmpeg-python` is not required. FFmpeg is discovered through `PATH` by default or selected with `--ffmpeg` - Optional: CMake and a C++20 toolchain to build the native core @@ -78,12 +83,58 @@ python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 python main.py input.m4a --speaker-layout 7.1.2 --speaker-output output.7.1.2.wav ``` -When PCM24 may clip in a non-interactive environment, select a policy explicitly: +Write binaural stereo directly (ordinary objects are Near/Mid/Far only; Mid is +the default). The HRTF input accepts three sources: + +```powershell +# 1) SOFA (defaults to HRTF/binaural.sofa, or an explicit path) +python main.py input.m4a --binaural +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa + +# 2) Rosella .personalized_headphone (defaults to HRTF/binaural.personalized_headphone) +python main.py input.m4a --binaural --personalized-headphone +python main.py input.m4a --binaural --personalized-headphone C:\HRTF\subject.personalized_headphone + +# 3) .jochrtf compiled cache +python main.py input.m4a --binaural --compiled-hrtf-cache C:\HRTF\subject.jochrtf + +# Common options +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --binaural-mode near +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --hrtf-cache-policy disk +python main.py input.m4a --binaural --binaural-output output.binaural.wav +``` + +With none of the three specified, resolution tries, in order: +`HRTF/binaural.sofa`, the unique `.jochrtf` under `output/hrtf-cache`, then +`HRTF/binaural.personalized_headphone`; if none exist, an error asks for an +explicit path. + +- `.sofa` is the portable source of truth; it can hold self-scanned or any + generic HRTF data. +- `.personalized_headphone` is a model produced by Dolby's official + personalization scan; its JSON parsing is implemented by this project + (`src/rosella_model.py`) and does not invoke any Dolby software. +- `.jochrtf` is a project-internal cache compiled from SOFA; it is disposable, + rebuildable, and written to `output/hrtf-cache` by default. + +HRTF data lives under `HRTF/` (git-ignored): the default SOFA +`HRTF/binaural.sofa` and the default model +`HRTF/binaural.personalized_headphone`. Because the cache contains transformed +HRTF data, its use and redistribution remain subject to the source dataset's +terms. See [Binaural Rendering](docs/binaural.en.md) and +[Third-party notices](THIRD_PARTY_NOTICES.md) for format boundaries, formulas, +state, timing, and distribution considerations. + +Speaker and binaural output share peak analysis, the WAV writer, and clipping policy. When PCM24 may clip in a non-interactive environment, select a policy explicitly: ```powershell python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 --clip-action abort python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 --clip-action float32 python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 --clip-action continue +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --binaural-format int24 --clip-action abort ``` Metadata and diagnostics: @@ -95,17 +146,29 @@ python main.py input.m4a --metadata-cache metadata_cache python main.py input.m4a --metadata-dir metadata_cache ``` -### Experimental binaural mode settings for JOC objects +### Binaural render mode -The binaural mode written here is a user-selected, experimental rendering hint for downstream ADM renderers. It is **not original binaural metadata extracted or recovered from the input E-AC-3 JOC bitstream**, nor does it represent the original mix's per-object binaural settings. The selected mode is applied uniformly to all 15 JOC objects; the default `unspecified` is this tool's default, not a mode detected in the source file. +`--binaural-mode off|near|mid|far` selects the binaural render mode; the default +is `mid`, and both outputs share this single option: -Use `--joc-binaural-mode off|near|far|mid|unspecified` to select a mode, encoded as `0|1|2|3|4` respectively. The default is `unspecified`: +- **Direct binaural rendering** (`--binaural`): `off` is rejected (error); + near/mid/far apply, defaulting to `mid`; +- **ADM BWF**: the low 3 binaural-render-mode bits of the last 15 JOC object + entries in DBMD segment 10 carry `off=0/near=1/far=2/mid=3`, leaving the first + 10 bed entries unchanged; the default is `mid`, and `off` explicitly disables + the binaural metadata hint. ```powershell -python main.py input.m4a --joc-binaural-mode mid +python main.py input.m4a --binaural-mode mid +python main.py input.m4a --binaural-mode off # ADM BWF only: disable the DBMD hint ``` -This option only sets the low 3 binaural-render-mode bits of the last 15 JOC object entries in ADM BWF DBMD segment 10, leaving the first 10 bed entries unchanged. It does not change PCM, object trajectories, or direct speaker rendering, and does not itself produce binaural stereo audio. The adjacent `.report.json` records the mode name and value in `joc_binaural_mode` and `joc_binaural_mode_value`; both are `null` for direct speaker output, where the option does not apply. +**The default `mid` is a human-specified rendering hint**; it is not original +binaural metadata extracted or recovered from the input E-AC-3 JOC bitstream, +nor does it represent the original mix's per-object binaural settings. The hint +does not change PCM, object trajectories, or direct speaker rendering. The +adjacent `.report.json` records `binaural_mode` (the mode name) and +`binaural_mode_value` (the ADM code; `null` for direct binaural output). ### OAMD time alignment @@ -117,6 +180,17 @@ align32(1473) = 1472 Override the two paths with `--object-delay-samples` and `--speaker-metadata-offset`, respectively. The 1473-sample timing offset is distinct from the 640-value inverse-QMF filter/window state; 640 is a QMF state length, not a metadata delay. +The direct binaural path uses `--object-delay-samples`. Each ID11/OAMD event is +placed on an absolute sample timeline from its frame start, outer-subpayload +offset, and block offset, then shifted by that delay. Each 1536-sample input +frame is processed as three consecutive 512-sample blocks; the interpolated +position, direction, and profile are updated at each block's absolute starting +sample. + +### Binaural calculation + +See [Binaural Rendering Mathematics](docs/binaural.en.md) for QMF, hybrid processing, direction fields, distance, ITD, room processing, the 512-sample parameter updates above, and 961-sample latency compensation. + For all options: ```powershell @@ -150,8 +224,10 @@ JustOneCacophony/ ├─ main.py command-line entry point ├─ src/ Python implementation modules ├─ native/ C/C++ acceleration core, C ABI, and required table data -├─ data/ runtime table data for Python +├─ data/ Python runtime table data ├─ lib/ native runtime drop-in directory (create as needed) +├─ HRTF/ user HRTF data directory (create as needed, git-ignored) +├─ output/ output directory (create as needed; the .jochrtf cache defaults to its hrtf-cache subdirectory) ├─ docs/ math and native-core notes in both languages ├─ requirements.txt Python dependency ├─ README.md Chinese documentation @@ -170,7 +246,8 @@ The main documented stages are: - OAMD Q15 coordinate conversion; - equal-power panning over target-layout regions; - layout-dependent position compensation and sample-wise gain ramps; -- float32 and PCM24 output quantization. +- float32 and PCM24 output quantization; +- SOFA canonical import, 64-QMF/77-hybrid projection, `36×2×77` fifth-order fields, exactly-once delay/phase, project early/late room behavior, and special LFE. See the [mathematical notes](docs/math.en.md) for the equations used by the decoding and rendering process. @@ -178,13 +255,16 @@ See the [mathematical notes](docs/math.en.md) for the equations used by the deco - Only the common contiguous EMDF transport is covered. Fragmented transport across multiple audio-block skip fields is not covered. - Dense JOC is the main path. The Sparse JOC branch should not be treated as supported. -- The speaker path currently covers ordinary point objects; extent, spread, divergence, and similar modes are outside the supported scope. +- The speaker and SOFA binaural paths currently cover ordinary point objects; extent, spread, diffuse, divergence, channel lock, and similar controls are outside the supported scope. - OAMD trim elements are boundary-checked and skipped; warp, balance, and trim parameters are not applied to raw object trajectories or speaker rendering. - Multi-data-point streams, uncommon band configurations, and unusual OAMD scheduling have less coverage than common 12-band, single-data-point material. - A speaker limiter is outside the current primary formula. -- ADM output, native binaries, and speaker layouts still need broader interoperability checks across platforms, players, and real material. +- The SOFA importer currently supports the strict `SimpleFreeFieldHRIR` FIR subset; other SOFA conventions require explicit adapters. +- The binaural runtime is fixed at 48 kHz, fifth order, and one measurement-radius shell at a time; the public binaural backend defaults to the native accelerator and falls back to Python when the native library is unavailable. +- ADM output, native binaries, speaker layouts, and binaural models still need broader interoperability checks across platforms, players, and real material. ## Documentation - [Mathematical notes](docs/math.en.md) · [中文](docs/math.md) - [Native-core notes](docs/native.en.md) · [中文](docs/native.md) +- [Binaural rendering](docs/binaural.en.md) · [中文](docs/binaural.md) diff --git a/README.md b/README.md index 1a506db..cc6b240 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ > JustOneCacophony 是一个 E-AC-3 JOC 的实验性 / 测试实现,用于研究 JOC 的解析、重建、渲染以及相关数学过程。 -项目可以从常见 E-AC-3 JOC 码流中提取并解析 EMDF、ID14 JOC 参数和 ID11 OAMD 元数据,结合 FFmpeg 解码出的核心 5.1 PCM 重建 LFE 与 15 路对象 PCM,并输出 ADM BWF 或指定扬声器布局的 WAV。 +项目可以从常见 E-AC-3 JOC 码流中提取并解析 EMDF、ID14 JOC 参数和 ID11 OAMD 元数据,结合 FFmpeg 解码出的核心 5.1 PCM 重建 LFE 与 15 路对象 PCM,并输出 ADM BWF、指定扬声器布局的 WAV,或使用标准 SOFA HRTF 直接输出双耳 WAV。 -这是研究代码,不是完整、标准兼容或生产级的 Dolby JOC 解码器。它只覆盖当前已实现的码流形态;遇到未知变体时会明确报错,而不是假装一切都很和谐——如果哪里算错了,它可能就真的只剩 cacophony 了。 +这是研究代码,不是完整、标准兼容或生产级的 JOC 解码器。它只覆盖当前已实现的码流形态;遇到未知变体时会明确报错,而不是假装一切都很和谐——如果哪里算错了,它可能就真的只剩 cacophony 了。 ## 当前功能 @@ -16,7 +16,9 @@ - 通过 analysis QMF、参数插值、对象矩阵和 inverse QMF 重建 LFE + 15 路对象 PCM; - 输出 25 声道 ADM BWF:10 声道 7.1.2 bed(除 LFE 外静音)+ 15 个对象; - 直接渲染 `2.0`、`3.1`、`5.1`、`7.1`、`5.1.2`、`5.1.4`、`7.1.2`、`7.1.4`、`9.1.4`、`9.1.6`; -- 输出 float32 或 PCM24 WAV,并在 PCM24 削波前提供明确处理策略; +- 从 `pcm16 + ID11/OAMD` 直接运行公开 SOFA 双耳渲染,不生成临时 ADM BWF; +- 双耳 DSP 全程使用 float64/complex128,并保留 961-sample latency compensation、跨帧状态和 room 尾声; +- 直接输出统一支持 float32 或 PCM24 WAV,并在 PCM24 削波前提供明确处理策略; - 使用 NumPy 后端,或通过 `ctypes` 调用可选的 C++20 原生核;`auto` 模式在原生库不可用时回退到 Python; - 读取或写入 metadata sidecar,并生成元数据、运行时间和输出摘要。 @@ -30,15 +32,20 @@ M4A / E-AC-3 ├─ ID11 OAMD → 对象位置与时间轨迹 └─ LFE + 15 objects ├─ 25ch ADM BWF - └─ 指定布局的扬声器 WAV + ├─ 指定布局的扬声器 WAV + └─ ID11 直接时间轴 + SOFA HRTF → 双耳 WAV ``` -Python 与 C++ 后端使用同一组已记录的数学过程。原生核只处理状态密集的 DSP 和扬声器渲染,高层位流解析、ADM 组装与命令行逻辑仍在 Python 中。 +Python 与 C++ 后端在 JOC 对象重建、扬声器渲染和公开 SOFA 双耳渲染中使用同一组 +数学过程;native 双耳后端与 Python 参考实现逐值一致(差异 < 1e-9)。位流解析、 +OAMD 时间轴和命令行逻辑在 Python 中。 ## 环境 - Python 3.10+ - NumPy 1.24+ +- h5py 3.8+ +- SciPy 1.10+ - 独立的 FFmpeg 可执行程序;不需要 `ffmpeg-python`。默认从 `PATH` 查找,也可通过 `--ffmpeg` 指定可执行文件路径 - 可选:支持 C++20 的 CMake 工具链,用于自行构建原生核 @@ -78,12 +85,50 @@ python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 python main.py input.m4a --speaker-layout 7.1.2 --speaker-output output.7.1.2.wav ``` -在非交互环境请求 PCM24 且可能削波时,需要显式选择处理方式: +直接输出双耳渲染 WAV(普通对象仅 Near/Mid/Far,默认 Mid)。HRTF 输入支持三种来源: + +```powershell +# 1) SOFA(缺省取 HRTF/binaural.sofa,也可显式指定) +python main.py input.m4a --binaural +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa + +# 2) Rosella .personalized_headphone(缺省取 HRTF/binaural.personalized_headphone) +python main.py input.m4a --binaural --personalized-headphone +python main.py input.m4a --binaural --personalized-headphone C:\HRTF\subject.personalized_headphone + +# 3) .jochrtf 编译缓存 +python main.py input.m4a --binaural --compiled-hrtf-cache C:\HRTF\subject.jochrtf + +# 常用选项 +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --binaural-mode near +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --hrtf-cache-policy disk +python main.py input.m4a --binaural --binaural-output output.binaural.wav +``` + +三者都不指定时的自动选择顺序:`HRTF/binaural.sofa` → `output/hrtf-cache` 下唯一的 +`.jochrtf` → `HRTF/binaural.personalized_headphone`;都没有则报错并提示显式指定。 + +- `.sofa` 是可移植的 source of truth;可以是自行扫描或任何来源的通用 HRTF 数据。 +- `.personalized_headphone` 是杜比官方软件个性化扫描得到的模型,其 JSON 解析由 + 本项目自行实现(`src/rosella_model.py`),不调用杜比软件。 +- `.jochrtf` 是从 SOFA 编译出的项目内部 cache,可删除、可从 SOFA 重建,默认写在 + `output/hrtf-cache`。 + +HRTF 数据统一放在 `HRTF/`(git 忽略):默认 SOFA `HRTF/binaural.sofa`、默认模型 +`HRTF/binaural.personalized_headphone`。cache 含有源 HRTF 的变换数据,使用与再分发 +仍受源数据许可约束;格式边界、计算公式、状态、时间轴及发布注意事项见 +[双耳渲染](docs/binaural.md) 和 [第三方通知](THIRD_PARTY_NOTICES.md)。 + +扬声器和双耳输出共享峰值检查、writer 与削波策略。在非交互环境请求 PCM24 且可能削波时,需要显式选择处理方式: ```powershell python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 --clip-action abort python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 --clip-action float32 python main.py input.m4a --speaker-layout 5.1 --speaker-format int24 --clip-action continue +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --binaural-format int24 --clip-action abort ``` 元数据与诊断: @@ -95,17 +140,24 @@ python main.py input.m4a --metadata-cache metadata_cache python main.py input.m4a --metadata-dir metadata_cache ``` -### 实验性 JOC 对象双耳模式设置 +### 双耳渲染模式 -这里写入的双耳模式是用户手动指定、供下游 ADM 渲染器使用的实验性渲染提示,**不是从输入 E-AC-3 JOC 码流中提取或还原的原始双耳元数据**,也不代表原始混音中各对象的双耳设置。所选模式会统一应用到 15 个 JOC 对象;默认 `unspecified` 只是本工具的默认值,并非从源文件检测到的模式。 +`--binaural-mode off|near|mid|far` 选择双耳渲染模式,默认 `mid`,两种输出共用这一个选项: -使用 `--joc-binaural-mode off|near|far|mid|unspecified` 选择模式,编码分别为 `0|1|2|3|4`,默认 `unspecified`: +- **直接双耳渲染**(`--binaural`):`off` 不可用(报错),near/mid/far 生效,默认 `mid`; +- **ADM BWF**:DBMD segment 10 中后 15 个 JOC 对象的 binaural render mode 写 + `off=0/near=1/far=2/mid=3`,前 10 个 bed 保持不变,默认 `mid`;`off` 用于显式 + 关闭双耳元数据提示。 ```powershell -python main.py input.m4a --joc-binaural-mode mid +python main.py input.m4a --binaural-mode mid +python main.py input.m4a --binaural-mode off # 仅 ADM BWF:关闭 DBMD 双耳提示 ``` -此选项仅设置 ADM BWF 的 DBMD segment 10 中后 15 个 JOC 对象的 binaural render mode 低 3 bit;前 10 个 bed 保持不变。它不改变 PCM、对象轨迹或直接扬声器渲染,也不直接生成双耳立体声音频。输出旁的 `.report.json` 用 `joc_binaural_mode` 和 `joc_binaural_mode_value` 记录模式名称与数值;直接扬声器输出时两者为 `null`,表示不适用。 +**默认 `mid` 是本工具人为指定的渲染提示**,不是从输入 E-AC-3 JOC 码流中提取或 +还原的原始双耳元数据,也不代表原始混音中各对象的双耳设置。该提示不改变 PCM、 +对象轨迹或直接扬声器渲染。输出旁的 `.report.json` 用 `binaural_mode`(模式名) +和 `binaural_mode_value`(ADM 编码值,直接双耳输出时为 `null`)记录。 ### OAMD 时间对齐 @@ -117,6 +169,15 @@ align32(1473) = 1472 可分别用 `--object-delay-samples` 和 `--speaker-metadata-offset` 覆盖默认值。这里的 1473 不应与 inverse-QMF 的 640 项 filter/window state 混淆;后者是 QMF 状态长度,不是 metadata delay。 +直接双耳路径使用 `--object-delay-samples`。每个 ID11/OAMD event 先按 frame start、 +outer subpayload offset 与 block offset 落到绝对 sample timeline,再加该 delay;每个 +1536-sample 输入帧按三个连续 512-sample block 处理,并在每块的绝对起始 sample +查询插值后的位置、更新方向和 profile。 + +### 双耳计算 + +双耳路径的 QMF、hybrid、方向场、距离、ITD、room、上述 512-sample 参数更新和 961-sample 延迟补偿见[双耳渲染数学](docs/binaural.md)。 + 更多参数可查看: ```powershell @@ -152,6 +213,8 @@ JustOneCacophony/ ├─ native/ C/C++ 加速核、C ABI 与必要表数据 ├─ data/ Python 运行时表数据 ├─ lib/ 原生运行库投放目录(按需创建) +├─ HRTF/ 用户 HRTF 数据目录(按需创建,git 忽略) +├─ output/ 输出目录(按需创建;.jochrtf 缓存默认在其 hrtf-cache 子目录) ├─ docs/ 数学与原生核文档(中英文) ├─ requirements.txt Python 依赖 ├─ README.md 中文说明 @@ -170,7 +233,8 @@ JustOneCacophony/ - OAMD Q15 坐标转换; - 基于目标布局 region 的等功率声像; - 布局位置补偿与逐样本增益斜坡; -- float32 与 PCM24 输出量化。 +- float32 与 PCM24 输出量化; +- SOFA canonical importer、64-QMF/77-hybrid 投影、`36×2×77` 五阶方向 field、exactly-once delay/phase、项目 early/late room 与 special LFE。 解码与渲染过程使用的公式见[数学说明](docs/math.md)。 @@ -178,13 +242,16 @@ JustOneCacophony/ - 当前只覆盖常见 continuous EMDF transport;跨多个 audio-block skip field 的碎片化 transport 尚未覆盖。 - Dense JOC 是当前主要路径;Sparse JOC 分支不应视为受支持能力。 -- 扬声器路径当前只覆盖普通点对象;extent、spread、divergence 等对象模式不在支持范围内。 +- 扬声器与 SOFA 双耳路径当前只覆盖普通点对象;extent、spread、diffuse、divergence、channel lock 等对象控制不在支持范围内。 - OAMD trim element 会按声明边界校验并跳过;warp、balance 和 trim 参数不应用于当前原始对象轨迹或扬声器渲染。 - 多数据点、少见参数带配置和特殊 OAMD 调度的覆盖度低于常见 12-band、单数据点素材。 - 扬声器 limiter 不属于当前实现的主公式。 -- ADM 输出、原生库和扬声器布局仍需在更多平台、播放器与真实素材上确认互操作性。 +- SOFA importer 当前严格支持 `SimpleFreeFieldHRIR` FIR;其它 SOFA convention 需要显式 adapter。 +- 双耳 runtime 固定 48 kHz、五阶和一次选择一个 measurement-radius shell;公开双耳默认走 native 加速,原生库不可用时自动回退 Python。 +- ADM 输出、原生库、扬声器布局和双耳模型仍需在更多平台、播放器与真实素材上确认互操作性。 ## 文档 - [数学说明](docs/math.md) · [English](docs/math.en.md) - [原生核说明](docs/native.md) · [English](docs/native.en.md) +- [双耳渲染](docs/binaural.md) · [English](docs/binaural.en.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..948e164 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,39 @@ +# Third-party notices / 第三方通知 + +本文件记录 `data/rosella_kernels.npz`(`src/public_filterbank.py` 使用的滤波器组表) +的公开标准来源,以及 HRTF 数据与专利的边界说明。 + +## 公开标准来源 + +64-QMF → 77-hybrid 结构与 13-tap 低带 prototype 定义于 +[3GPP TS 26.405 / ETSI TS 126 405](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf) +第 5.2.2 节(Table 1 的 $Q=8$/$Q=4$ 系数,delay 6): + +$$G_q^p[n] = g^p[n]\cdot\exp\!\Bigl(j\,\frac{2\pi}{Q^p}\bigl(q+\tfrac12\bigr)(n-6)\Bigr)$$ + +64-band QMF analysis 即 ISO/IEC 14496-3/AMD1:2003 第 4.B.18.2 节的 MPEG-4 +AAC/SBR 64 complex QMF bank;打包的 $64\times10$ 表是公开 640-tap prototype 的 +多相重排: + +$$A_{r,t} = \frac{(-1)^t}{128}\,c_{63-r+64t}$$ + +QMF synthesis 表为 analysis 多相矩阵 $\mathbf{A}$ 的因果左逆 +$\mathbf{A}\,\mathbf{W}=\mathbf{P}$($\mathbf{P}$ 为 577-sample 延迟置换; +全链 $961 = 577 + 6\times64$),rank-4 分解存储: + +$$W_{b,l} = \sum_{r=1}^{4} t_{b,l,r}\,\mathbf{b}_{b,r}^{\top}$$ + +hybrid synthesis 表为 77→64 重组:高频带恒等 $Y_{3+b}=X_{16+b}$,低频带: + +$$Y_p = \sum_{q\in C_p}\Bigl(\operatorname{Re}X_q + j\,s_q\,\operatorname{Im}X_q\Bigr),\qquad s_q\in\{\pm1\}$$ + +相同数值可在 FFmpeg(`aacps_tablegen.h`、`aacsbrdata.h`)等公开实现中查到。 + +## HRTF 数据与 `.jochrtf` + +`.jochrtf` 含有特定源 SOFA/HRTF 数据集的变换系数与 delay;其使用、复制与再分发 +仍受源数据集许可约束,权限不明确时应作为私有 cache 保存。 + +## 专利说明 + +标准可公开获取不等于获准实施相关专利。 diff --git a/data/README.en.md b/data/README.en.md index 75db08c..aafe4ba 100644 --- a/data/README.en.md +++ b/data/README.en.md @@ -2,7 +2,9 @@ [中文](README.md) -`tables.npz` contains the static table data used by the Python path: +This directory contains static production tables. It does not contain user HRTFs. + +`tables.npz` contains the JOC core decoding tables: ```text analysis_window float64[10,64] @@ -18,3 +20,64 @@ joc_huff_code_7ch_pos_index_sparse int64[6,2] `src/joc_qmf.py` loads the QMF tables, while `src/joc_decode.py` loads the JOC Huffman trees. Python does not read C/C++ headers under `native/`. The corresponding native data are stored in `native/src/qmf_tables.h` and `native/src/joc_huffman_tables.h`. Changes on either side should update the other and be checked for value-by-value agreement. + +## Binaural rendering tables + +`rosella_kernels.npz` contains the fixed 64-QMF/77-hybrid tables used by the +public SOFA binaural path: + +```text +format_version little-endian int32[1] +qmf_analysis_coefficients float32[64,10] +hybrid_analysis_low_kernel float32[3,2,13,16,2] +hybrid_synthesis_indices int16[154,4] +hybrid_synthesis_values float32[154] +qmf_synthesis_basis float64[64,4,128] +qmf_synthesis_taps float64[64,10,4] +``` + +The float32 table values are promoted to float64 when loaded. +`src/public_filterbank.py` verifies the archive and every array by SHA-256. +Those hashes, the table version, and the 77 reference band-center values all +participate in the `.jochrtf` cache key. The full analysis/synthesis latency is +961 samples. + +The packaged tables implement publicly standardized filter banks, computable +from the following formulas. + +The 64-QMF → 77-hybrid structure, the 13-tap low-band prototypes, and their +half-bin complex modulation are defined in +[3GPP TS 26.405 / ETSI TS 126 405](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf), +Section 5.2.2 (Table 1 $Q=8$/$Q=4$ coefficients, delay 6): + +$$G_q^p[n] = g^p[n]\cdot\exp\!\Bigl(j\,\frac{2\pi}{Q^p}\bigl(q+\tfrac12\bigr)(n-6)\Bigr),\qquad n=0,\dots,12$$ + +The 64-band QMF analysis is the MPEG-4 AAC/SBR 64 complex QMF analysis bank of +ISO/IEC 14496-3/AMD1:2003, subclause 4.B.18.2; the packaged $64\times10$ table +is the polyphase reordering of the public 640-tap prototype $c_0,\dots,c_{639}$: + +$$A_{r,t} = \frac{(-1)^t}{128}\,c_{63-r+64t},\qquad r=0,\dots,63,\ t=0,\dots,9$$ + +The QMF synthesis table is the causal left inverse of the analysis polyphase +matrix $\mathbf{A}$, i.e. the solution of $\mathbf{A}\,\mathbf{W}=\mathbf{P}$ +($\mathbf{P}$ is the 577-sample delay permutation; total latency +$961 = 577 + 6\times64$), stored as a rank-4 factorization: + +$$W_{b,l} = \sum_{r=1}^{4} t_{b,l,r}\,\mathbf{b}_{b,r}^{\top}$$ + +The hybrid synthesis table is the 77→64 recombination: identity for the high +bands, $Y_{3+b}=X_{16+b}$, and for the low bands ($C_p$ is the $8+4+4$ child +partition): + +$$Y_p = \sum_{q\in C_p}\Bigl(\operatorname{Re}X_q + j\,s_q\,\operatorname{Im}X_q\Bigr),\qquad s_q\in\{\pm1\}$$ + +The same values also appear in other public implementations of these standards +(for example FFmpeg's `aacps_tablegen.h` and `aacsbrdata.h`). + +Public availability of a standard does not by itself grant permission to +practice related patent claims. + +SOFA is the user-visible source of truth. A `.jochrtf` file is a disposable JOC +compiled HRTF cache that can be rebuilt from SOFA. The cache contains +transformed source-HRTF data and remains subject to the source SOFA/HRTF +dataset's licence and redistribution restrictions. diff --git a/data/README.md b/data/README.md index 085106b..8d29578 100644 --- a/data/README.md +++ b/data/README.md @@ -2,7 +2,9 @@ [English](README.en.md) -`tables.npz` 集中保存 Python 路径使用的静态表数据: +本目录保存 Python 生产路径使用的静态表数据,不保存用户 HRTF。 + +`tables.npz` 保存 JOC 核心解码表: ```text analysis_window float64[10,64] @@ -18,3 +20,54 @@ joc_huff_code_7ch_pos_index_sparse int64[6,2] `src/joc_qmf.py` 读取 QMF 表,`src/joc_decode.py` 读取 JOC Huffman 树。Python 不读取 `native/` 下的 C/C++ 头文件。 原生侧对应数据分别位于 `native/src/qmf_tables.h` 与 `native/src/joc_huffman_tables.h`。修改任何一侧时,应同步更新另一侧并进行逐值一致性检查。 + +## 双耳渲染表 + +`rosella_kernels.npz` 保存公开 SOFA 双耳路径使用的 64-QMF/77-hybrid 固定表: + +```text +format_version little-endian int32[1] +qmf_analysis_coefficients float32[64,10] +hybrid_analysis_low_kernel float32[3,2,13,16,2] +hybrid_synthesis_indices int16[154,4] +hybrid_synthesis_values float32[154] +qmf_synthesis_basis float64[64,4,128] +qmf_synthesis_taps float64[64,10,4] +``` + +float32 表值载入后提升为 float64。`src/public_filterbank.py` 在读取时校验 archive +及每个数组的 SHA-256;这些 hash、table version 和 77 个 band-center 参考值共同进入 +`.jochrtf` cache key。analysis/synthesis 全链 latency 为 961 samples。 + +打包表实现的是公开标准化的滤波器组,各表可由如下公式计算。 + +64-QMF → 77-hybrid 结构、13-tap 低带 prototype 与半 bin 复调制定义于 +[3GPP TS 26.405 / ETSI TS 126 405](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf) +第 5.2.2 节(Table 1 的 $Q=8$/$Q=4$ 系数,delay 6): + +$$G_q^p[n] = g^p[n]\cdot\exp\!\Bigl(j\,\frac{2\pi}{Q^p}\bigl(q+\tfrac12\bigr)(n-6)\Bigr),\qquad n=0,\dots,12$$ + +64-band QMF analysis 即 ISO/IEC 14496-3/AMD1:2003 第 4.B.18.2 节的 MPEG-4 +AAC/SBR 64 complex QMF bank;打包的 $64\times10$ 表是公开 640-tap prototype +$c_0,\dots,c_{639}$ 的多相重排: + +$$A_{r,t} = \frac{(-1)^t}{128}\,c_{63-r+64t},\qquad r=0,\dots,63,\ t=0,\dots,9$$ + +QMF synthesis 表为上述 analysis 多相矩阵 $\mathbf{A}$ 的因果左逆,即求解 +$\mathbf{A}\,\mathbf{W}=\mathbf{P}$($\mathbf{P}$ 为 577-sample 延迟置换; +全链 $961 = 577 + 6\times64$),以 rank-4 分解形式存储: + +$$W_{b,l} = \sum_{r=1}^{4} t_{b,l,r}\,\mathbf{b}_{b,r}^{\top}$$ + +hybrid synthesis 表为 77→64 重组:高频带恒等 $Y_{3+b}=X_{16+b}$;低频带 +($C_p$ 为 $8+4+4$ 子带划分): + +$$Y_p = \sum_{q\in C_p}\Bigl(\operatorname{Re}X_q + j\,s_q\,\operatorname{Im}X_q\Bigr),\qquad s_q\in\{\pm1\}$$ + +相同数值可在 FFmpeg(`aacps_tablegen.h`、`aacsbrdata.h`)等公开实现中查到。 + +标准可公开获取不等于获准实施相关专利。 + +`.sofa` 是用户可见的 source of truth;`.jochrtf` 是可删除、可从 SOFA 重建的 +JOC compiled HRTF cache。cache 含有源 HRTF 的变换数据,仍受源 SOFA/HRTF +数据集的许可与再分发限制约束。 diff --git a/data/rosella_kernels.npz b/data/rosella_kernels.npz new file mode 100644 index 0000000000000000000000000000000000000000..12bff3ab6b2b0648b933e749105699ed991da939 GIT binary patch literal 177268 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 z zj4vFMi6u8QFvoshBsCZA8O&{77&q>_51V}Q+}iymNa%$7fMs!Gu6|R_`+Nq=%sz_Z zAzYo~`xb542ipSN(W^xv<b+;>WwcBA`#BuJwnG%xmHZE&fl2|svM2#;;u>VMy zW_#0k$Nu6U?50?b!^Dl@Q&Pv@#7*p`jgRX?_S2etL6AQ@MP>LfqTM5``YIljomq%? zvk{MNlQYj1sqlFr*3OLQXvnWcp1pButmAe(+r~Y8GE}hp2U25U#ZPT^3)?a%#C`{( zzUh4J^IkI7DEnh;SL~x>tPsTXSb%2gjC;6s-+|#QtkSQ3TC%q4U|aL<#d~`JvQeEh zM>4Yh8LV!e{`Kccc4BXbjqkQj4ynVNXKn_RFd{YQ8A+lqL$xc%YB z&U}B~#7jC)^<|*8@l+koI(+g z#DaA42ROV4v5 z%DBKcm;|zRrs|GDpN^olSe=T5wZ>Zv!r+GPZ!MYa3Y5{x@w?9&Y5Q1wAV19`WYm>W zmY9kd`#RM>pC)sxzd|m)PZ!m#Uz8XGH}8p%(?a9iii@{iJ53$Cyul>UA{`a) z80^fjPE|dj*(q+0zU385*tI7gsRMJm8EhsR=%h)8FF_X>$^2wRQq;$4Yd7e8Sk$M> zsVKyUmS(JGKf1%Y5oPTelV$_F^n1=8K;0x74MAkgiCt~0D-MUX|1n;*IxLGg%!x!X zXhjKhr6c}N1J?iHsfrT)c*$`|5H&4HcHGDif6q7d_pzE1I39fA)@>k{T$cpY6crJi0c-MU6PU+{7=fNngqNN zlD~km+OTbEj?IEYO1Wn_-zmY2Ic;SWS_&e;#9S^8`!jE#bkShz8f! z1=Mffr(`4=Cdp8k`aDy~u&Ki%Lyn}0URXT)Iju8(W}75zAcnLHt8=~=*yYo0UZneZ z;Xpi%egW<0AK97um3)B*+7k9VNUGdny6E@%`k1nmD=Kl89R(oJ^@KjY@NpnWOxo<{yGOQz1X}!DX&H?jEb&lUDW+t7vX(##bgPWe6P}=Wl~wW;^MA`(K+rD(8{dK}n;l0w?9z$2?-8 z8(8STBRC^qolO?~X5f)5BfBB;ho2AJ6II|LD=Uw6n%?#Q=$*T305KSE5dor7ULmCp zM8{0VWds?>iZcNli>1nao30Uy;lM=orm7-XJ{v^$pqy@Roczb*h0yQy_MHg7u6*Bd zVTqIlHJSDjF^9x_ANqrLNakXM37m=Hg>&pA@qmYit;6mZpJ9QW1uE}(WCmHLT7gKCiMkg zD-KAwPajv8dK0dL9n1|g!w#{fsOVz`>jUVKLt{j#WLd~ek?s9!g@%fijTxif-@8hM zr(Zd*zTIuA){wE4&~OMwS93OTvu=CBYQ{106U$u9`J*0sDOqax#R#EW&Ad30QVcmb zJ6{Rp{(k!{EYa8C1d@tINn4G1j=_^kUxQMlx7FxLSqck$o?i@`fQl=Wl3MJ!k)^&F zrBQik2ko|HwtBv*%c65TT!+su<^?`xqRy+Qk>cvGTn_F%2tvxsQHfak>awD6*r;`6 z`fq9%?tQ$0&;DP@Q;wIlJeN0?r2XJr1rbN%d*vy53Z`FLJ9H$MkK~=eyd56Th<2-E z1{>WbBDmshpCi|qN!lKdgOgoR#vY7O(QP=ZcifK&;d`2z=_K&56l=C^1E8!HV@3TDFB5TmpEKa(F6;9A*HJ(<*O|XEY8yIf{(;@{K!m z6+HM#^KMfH>NhMlf4c{BY7(v)ny8DcVI8t?Bz~*=3hFlkI5mFU!tx|zHqp&>m|ELW(M9@DdmdkT-X{M5Fqr8a= zvTTsyhj3iRSV%7SzxN5C4{BwWuiw0U&Iki#I2vy(|8qEk@*XrwFZ16l62JvwFw)sXXR96y{drTD`Fh2I# z9dPbTunj9B1jC-$jB7g?p;bN;R~dAiin5}hqr=#a%mF!u?ObZX^P|dU3@7#wj%%1b z+pXp2AssqFKC-cU>3?Pu?^=1gt}P3dRx-7CvSl5veVC0%{L!kvb-#~AtYb;rsJi;6 z0-*t;`0G8-F8y~)S!sHfvB4i`#)SuKgE)8)!Wz3SB#jy37SJOenFlXkH%ZM6jxAXX zW3fltdHb(CjK0EFjYLe&IhJH!F?28RHTsyM9?ppl7+254O1)F%%r!0aX41oOQG;9# z+R8gWAYouco0Lw)A7~lP6PI*avr2J1Yb&1&K*^mj)4S|8l{)Z%M8Kmwk|IF=|xX4IgQSHR5h}KsIYqc!cbk~PY6wWA@2ZqwlMA0?BqkPT~NHS z$H+J*84ceGX^=M*5X{^`(8OjKtsxOPs`P8Ox)RyhUHQE&SR@w6<+Q`FKXxJdHN_02?*#KKUa`0c z8z4O9Bk`Y>@BNr;zHry`__Q3z>#dko=m9;u+xcO0%kelAy8IrurIEA_S#f-D+<_r* zV4&z)clb<86NVe5RQoR}Rrr^_t80`r6u9@yFO;Q=`W5bL?!jTJ`}G(c)gu)6y_+i( znB6UQ6IToH4}7ZgKl(b(*LM|^N%o{*Dx-M-{LdW!q5y)sr08|UI}hL5NtQy4Pv&7l zzC?p6;1hC zZpmxbNBhu&@wl4MZ>7OsI?tn@9}O_~06@Nk?!OjDGN`6AJOv9v@>_mdfyQcY6qQ=StF!7Y8UwO@Jn8TZ~; z^icm^b<@OZw&Yb^1I%*v7J}~IBR?G%yXfK9D@@*mzxzay%VvPCnW1X4mp}SxClUJr zH5VX77SaECURxNbVNEL8d1O`7OE)`gZ)WmQe^>fU!1~3Pwp6*N6$Z8~9!5_MwvWmb zj)ygCDJ&oxw(7i56Y?1TW|*LlTiv5dbrdZ(7Sn(M&z|N_|0?-7m(L;cF#Q?PPxXg; zh*e<(TaMr4=m}}ad0(ghL=mf<qX@AS#iXtswi7Ak1hrrXno&NY{kk$jB zG5m7Y_ur>tukj$o(-9B8UTl#4{UxtE89OuZ5nNSTM)8w)17bdNd0g0yNqt%g*==z0 zNP6f)2Cbef%DpT0PqfFVhwKyr{%z5Y{gmD&%cB>olPv+M(rx38hRx|`qze=<32xcH zJ)EmHUS+vZ)pzf6(0Tw#Zt{t4Qb6vkr^M+Mn#uf5kjs$TiCy(ffHnHeFczANn~Y@i zG~_eE-4q~!utG-HwLhxqRc;G}d@%Nni)Yi^?qmk$YsINN&I6<8Ti+I=Uzavl1)^i` zN+%qhnWSxH7rk~)=!y~R88@ty)qNztJ)F1gzo@2f)>wwpEW9gr`#vxLhQX{lD&~Vw zMS={{&INKKcj$DOiv-*blVIC187AMhg_J$02eRtXp6?Krg=5){N^xWGu7*Vht=Gr` z#}+@X0=}u-xvEff#JoprxcR(?&(_4?k>_-=-?3Ri=|PHFHV*NS6(>(4>piLMAr3L~ zjvo95yx21fIyEaqB^2?-7%Zyc1pe)>FT6*}nZ*Gk0Chi$%=?fcR%2hgOb_JJ`_6~s zi94zVuHP|UkR#5UnI4{RHbq!<*axvf@FMJgSd;R8=Vtq$QR{6jyZhdU zrR;s=5$Y>zsmIvy3^YUDr$yj2A?}7g-$CnA9oufHVYF#ISR^hC)ik= zB9v;_%}ID#ZqID;_=kG)n(HFlE-Oll8Te8*WrC}N{$uHg*DM>`Z*||3t_M3ekUdb7 zkg9Aq^N&3#Zc(lwaNT)BKKvQx~FIy(?U7BAb~>EitYPf);aR`gs$jM&1PPM#BU{-yp1lo zCEJB9B7~EIV}lzm#(B=~P(~!IrmR(oVLp6xQd4=`ues@jLmvb&uM8jOE+Y)HvzPyj z;O&Ocg-Dfx#6VzR1$|!pOzq>J+^yuD%b)6{r^_CXtd&cv0!tX~t0vnmj`DF=fgi_% zqZ5k^#ho{I!bxGMqH1BqAj%>j#?@^*iarhA&t)#^E5jV`%(xVXFU?{I%b1HH#Qxb; z-kkIzJ2na_BPOGY0tEB}G$*_sV8X7x1iff? z`|G^&#dPLE;?$Gg-KRpQ+r0S_7e6dl^X2A_xUzhDYeYgPF(cc-E+kKstA5QL7cx-S zd!aVH4<6f+Yw&cZZQo{Eem-ga$8-fKHMzvBpV&6*odVcEP%fO#b@xaV6Y(1=4hhkx zo?qa6eY+=)jhEkfA<=!SOAZGj?sNYk^)J=hr-|4y$V72{roVdhP&abxPwAHk)EMOhenO z=?hF7CDqAP$DZQ0aI+^Xv&HL@!|sdp1;YhB9J&Zt*zETk`NcwFnx2CUeCvw375bti z%6()FU!B>g$t>waUYkiTHGQyI5IF({`9>;>>|rRvArejtt|?1_IKeOXtR(NVA=uC+ zfB~^Qfjy(@%s9;~cg6HC>Q}TL#xHUWEI<$5>*o<)4^U_`vp4}L!;LgXF97d%52$Vd ze!8)>9NiJIRhU<;nWjZXvrQ&fN<_=^M%;3mB(@SYv}+cAT*5HhEKr~&*e!JtKAkR*x0dt@joKJ9|eW%taB|}_%82S zFaP?r@1be)L&)7=9uXqy4(6M$%z*K?{2qwSXQKC5L9ppC*B!K1F&0NF%wo{@A)|z ztH*u-9&54W%YE`TPChd&Y%v9=>q%|PL0L9nDi_m5uRR4J762O)Pqh|P1LlPw7@b&d znudMJ@!R`*Hh3R^(hM6YF=?P$HnB4L9*V8HDhz)_QI0Bdph(Hz43KBPDtImc+x^h+Nb zEjm8g#IMxJT`-#g3JY(M~0!hpw%DA<91o&DLnK6-RH4r|kUzy=@q@ zeNbGZ?CQ?v-rOd}H7~7!(&j#imqX_L*igaK5iSI*j7IsG=?IyUiu*F%?s7%TVJjC& z#d@#}3+GRMW`V}chcO;Hd3Q@W7A|k7%Wu8))l89pWy|a&qOkug?ZU2P^9L)vI3?;o z!G?c|MacZ|DE`$h{gxu?jLB5(d(xiOJP^6m&Z%%p)DFQ-oDy|8QSZ=sb}^MFasZ{5 zc|7|KI(ps?wY5Mz^}OGA$Q_=e%bGp6;15%u(BnMS zfAf98tlmJ<{b*{?X?_$wPN2CapKg8f0YDyzzF!cFN;P9XW(CV>#nHvuRXp!3&O>}g zuxZ*HHY-=kFr033EpD)#!o2&MxzdT|_I}G}u=_s3Vk8cy*IS(Vx>3A&)pNOs&;JON zQooofFw*~#077vRfaG70KF3DnBQgdWDu)}C!c-3Ul$R?N##cC%lHk=9y9|d;=QzCj z`tRrEwbfU%JTFzABD6Z6XI!ra(zcNENQjN+5jKNr?Q0%^h)IkD znWjCUOATaAA%+pruZPsB(XNDUtqI<)7(hR1hfE3zYO`?XOV4O3{g8ojn`}xdVl1af z^7uuK_XVR|7fFYAQ?N1!@3|w2)pwc(?>d(dyK^l~IHg)24Ie$FUozCcmgjZ$+WwlKJLT zMXDaB*R2Z=L(@-8BCAiU+Ob3qvzf`K#(-DtkVKo3e!;#VZFlb=X`olrj|mY?L_;uae9Ez^b5s z2Y9NvOL{EH;0yb!e$DT=ATsVOI$qig9ssWveS|?cWz=X$#f@lENBq??E0Z0Ps;1f_ zd|p>PxS-fzwJf{hf^(?1%XbfKTI05;76PnYFTNji_o-yZ%jehO-dKwblRLpAEn;AO zjkW8OamPANngdelgSVp`Q3>y7JsPxcrz*FLXcMs1Q9|B3SI8smBX8X7)u-3vrY2L* z8528S_<9YZM9;2x5Y?I8#&Wj_%@hz%_~pxetHa?M(}V5j>f!I#`|{I&l&+R-z-_9? z*?S*$VjRELEK|N2Y!v)utxIK%tCBrIEZ1=W0tUCSx<22mVC z7f$~dZ|@b>blY}$tB5ElsGt-nHUz0ENG~D+O79?5q<1Ny2Lz-@l`g&a-g}TDq4&^9 zqy|C>ErcWlCg^=X&;7nLvoSN@_iVcnIgazV{^zx>wSKF@=H%)16Yke6^LY9zIZu#H z4_vQJNa~||2P36uYRw}9!}#4!?=2yxzFj9Ls^u;w#&k|N4XP29X#MDr;#Qr|JsmEe z7M)>l^I#II#Z?@UFZr`A-A-lVy71C{+5Hqq{(!fEh9fwf zXlnX6bdIV;;z0}Fu}8m%0UvC%iZ&%e*O0;xJ-mM1<*yIm-M65g9x*M1(H(~e)A>-B&3`w#*D;J+XJ{?z=1Jwy%=^eE*|egp_Q zp|$V6z;B-e!D`BSZZ#z>Qz5+LWba=1Ld`02aq$LeT; zdr0r7T?9MjE=5L^g*7Rme$9F|Em*ehb&vj#P)|pGrH9I+!VA+-Mn#+TM;fC9rDdQ* zH^0yTV1KOf7NDIz=dN1_VgzJuUo28G$*pf1!LF;oCs682{CbU9uTY2I0J1>^h8#_G zZ;}wp;R`8GlntZi9roA|JhG9R@;M$ppNI5%k$s$zo(wrcM;QPXmLhwI##Fb8;jilD zp|=Riq#SNq74eT2(WCxponmN+AazmooP~f-4%YeTN1&avPeG)lnU-3r_}cMxQA8xy z4k>S5)5b#+j%=|H8zo^Nb;F2gAI0HRnT_xBK{e2v65_xK*c&Rw8AZTC@Exe^M84j- zR{23jt{4YR&Nn##Dj-9wb=_l}U3jN87XX!3!voY^@75*?`CnexKvw8^1!kxWpU!4VTA`u!mu2LwHi~*$Q{MDnnuo8A9;oD zf_fVa#>{P7t}qpf4}QXUCu6@>nZA$qUXm0J*sN)51tczPcvkCA-eoEzykLJ?zQIt= z>w;yaqYz1yUiKZXE5sF+&+Ktjg>+kc9j%l10*AP2RifvS3!cp@-qF~^J9l_tSbK>b zsz_=nOwb1I3RyF`54PseRY$sx{i(rFCwZ~5NI3{&s|1V0IFCNd7b_V4rt#=0G=krl z87+5W^W4BOwFl!JdZa8)hEG~=ysiv~-*->Lg6qM41@@av`Y*qvH5)v!ueK zvzKv4i~uAngk~45yVDT9ldj)kiE3UM8_20FuJoS8^YlpH!emN52uaVD*9H=g*ke(GOoA3mvI=c zjrErHaMIOn;)Y(%&oFdmz=Ed_t@dXs>d$T=rmV^A?DCLWlLjKiQlS5HRqEg1p#K|D zisjm{&sF5+VLt7QjOpSp(SGxF3P7Mj;3j0Pi}PnV^&N8t3^$#t;@APC_^rQT8_egF zmyc#n(Ddk~>YKwbw_T%+fpG!r*`4vx<(Nzh1&64P(T@URjmekT3});Wy~kZfO@WvGEX8j`DvfD zo{4xfIg<`CkOff*XFr#_YwBlR@MwpY=Q=5!*;_o#y@>E9!qQ^Shp#rBm?52a9_?wA zwWE^vHIxt0Js91lxLp?Do0a;v+!hDy4I3ZkXau6nf7$aDTn+4{fB6l#6hxhR!(~B9 z%eR>N&Vo=`m~So9BxATTd56n2>beOFY-g9h&Yi-e{9BW2DK-^S^N&SGq)VHb3jpF1 z?Jo?eO=3xG6nR_&#;&zl$9PqZ@=qa-4%)Unr&%w<`5 z==7jg9}I(8opqwYt^RV!~FgVL|NZUwtd{kYuiwOY5bm|GuTL3Mi;nhqUfSk@Xnbb)H}?K+%6KOy5}uO^Kp z>^UFVYiGxlyKVVXy3Bs`TsQC%Ag{ksa4f)jUHE&yp)P;@^%Alx5kAgOsU<;M-&{n} zuabHAYzlu^Kv=aJtV^7FBO~rKZ;j`Oc{9YPw zHsuKTCBAkTZU+zlnkCU9=_8T$3-ULu4{NVH&RJhmS?=B3JI(!kD(=kPv1K?+qMmbC zQYuz`0UV+AO8UsPoz|i-p`4M^&U=|OuIUjUMZdW3qqLve+w;+W*egoX{qc;KXl14_ zlKSMvtzIrxXVuK>wA$)3cFpWPh7Deb9U|%iXyd2R3xg(~nwYX)$o5@xM#~l=&faxp z;s*OT$fE&@4hrS zt&-PE%Vf*dbSAD9>-Ev%Z6wY2GF8IYpI%igb~~CgH@@K23*6-|Up-6g+^Qv`yxy@l z?O5t}DfHDy)y#%}k(5R3@uY{cGgXhMP0Q@iFj+?!iodiaK8$*>i6)j}|0ntB&t;Z| z_{IJ$Y;<1qrvl)^gRLh|Uge<5yn$6b&pn5k=U8#^Al?kK>OcchyO)saDB<}fC!Beb zICpCMm*da!!2IHKC>6o=vP{_FF#=wCj3kkv;NIo6zp;!&;DF^Whh+grJ;5P*R{{q6 zpLoW#zv_+uXX0Yw`33Ua>v9y_`f+$#_~_gp75=5Z^9y(j1>fJ^>^W$7?-~KJ+5;`w zHeEs~9h`{CPBxYg$TvN0G=%(A@iDJEWN^hhQx=ko*fNTxV%w>0^J7&O9G6;SO@Pnx z{R2#ZVCh-R1Be<=f;in9;2i!s1+1A9H5Ua4j|R9@i<_NUv~pTxH;IUvV{Muzo=)&1 zyg`{tC^a>eA~BST3xV6Bl8PF+uTrH5na?2ns*Cc83U|??Y#6mGuf>M$02o=Efg`4; z@O1q$iCVGEugygz(MsWRE%cwTtzXqoAdR9-dT5dQ&UO~;Suo zW0}KIKosJI>BJfIC6p^i~h7In+sxZ3^bV)6Ju`%EG7fkq zk19S-|NS*862Gr|ub!WKhwpX$cGI25Z9eG<5H=*L-(GTwuD{)!uU5`GMWmx#3G;HH z{aiiI{PmZCn6Ow6a5@nldI|Z1^EK(_Z$mk0Ilr8TPle0R=*LOJ-;V5SEkS(Dk{+Y(A9k{&iS|$R zs%oD9lw3ZH&yU9SQu8t79DJ@CH0}iu-{C)TQ5A_Cj0e5mJHptfUQZ>dndo+9Mp{F) zrd7lg8_(pky#y72`=L9JcHbThOYV!UE&8$c!G8+s#K$6seXHdNI+BrIr0PDD15m;t z>iWjvG|sCR>3i28g&?9)pOrRBLF<9I@$YU;{Lh_l?D3hZC6T0(_>szyd_z|sz2{(N zTy_Fz-70_YPZ$_$M{|WrQg?9=-Cxd2Gqt>9ac)!h45lK7h$M)d9z&U6s(Wc(yX(i5 z-1?E9eCB>BI5WOwymZim%a(BI>}6S9$4IZ}!drdvM%*)y@6M)HT08vkMk~gCk7Uat zVX-kflJJYI4~TrwmV2OTe+6 zur_=gQa%>~{t@v@bh> z%jIep&&wiY;#cNO>l5CZOD}!_rQQgZNins^9_2%~{?WX&yDC(Q;ucHVx?IA5$=lJwLXgmTlfgk{|3P`pS zpi_V3^8rHElwdvoV@SEwagU18cQ-_oXp!M6;Z8SViJ}VCZ4F4#jC|e*u|7Fy2ik|i z1?)E$oH(J9_~YXmHNM1(LsNGn)MxrFxMqg7aN%|DVWwtPoF&^3O>2eOaK!R-%8Qop z4e*<3t$njzhsf&!x7_MC-xUfBgv*sae4-2$L;PfO&33U`ePq6U z6J}*~n^xD~m+L?YT$+16xhP~CU{D^jtY6g6&Q+T%< z2vSpKAHZm_Xlc3G)3Y8dnO-e(e7Qb*SV9Gs{?f;)NBTOq`bz5)z20_=XTP$UrU-q! zT$od#v)|7d)A199$&C4p-shR(l_pDxV@dh>k7=oZHa}V*SnEVXWzTkFSOV+ulwov2ox02h$OchwOtJD^!K1IPk zUGIxHIfW-PjU)MPh%@qJc~l)4F)0-lH`VGtbSIB#&c?cW?0lbz+<65UZRJXghAWv1 z=#}SSCtD|H(-AxvAP}UiZx2CvutDU&T5sC&L6$?cb$#m8)VBKB5`ZfBP8D7qiMA{# z%k(&l&a4{e)Gu-TS_U!m5Hn214{VR`wgU||* zUR1F(*Rbmtpo3w>I<_!vd)uxd649rym&~u6T8JN=^TlQt87U?da)pA*%3lL^5<)^# zEm}2g-`|C{} z&=~9u*gIKDOHntUS|3uBc|tcAgA87ZyD>dboqzaiagrqPZOG5}w7o)Yld7JGO)mI~ zhg$j+_-MtfC?DL?+#HXvBv?M(g-_{`LFj(MVYV;erYCYfIOlH9b0JfJi~jr^J2%#y zd(r<(eH#zS|GJXxrPH|$onWWyfL6$C-SO;(RtBA+Ro(gsQ?m8alktAbS*by@##p`$ z8%!3DY8xFUU|Fx`J#n(Y0d&IAmOf{KK8jhV((RTP87Sgy1YF}H64WqQl5iOwHV1N` zy-8(_!M>MYm#%CgFSOG^+S*bqzE*Oe06CiE=thdluTzyY9QOi5mzb%E)EyN#Wq?}U zfZJ?XgN)q?5?XB9E6%+>(~x{Xhp(b@>Rama=JG;rH8fjzj$8ilZj@eI>W0&k2$12r z7|(k`V$WDp6sHP`e#z$aG@VQIPD(+W=U7Ds14^xvd)1|iJxce7k}tdirYB0}5d+05(VrU`Fhl>ji`vZj&RqtbMqv0CTl+#< z9|VZ98D-TrTK!zVnsT?!bbjqD+9Aykx?yvJKi1>8W<`2MW#lP%L5lBUgIDl)bM@X; z^3#5q0vhQbX*)V{)zoIMv(;ioA9J&4SAE*(UeZ>YZpJzLHhrZyOzifS(;e+0b0FO`0Iz=}~j)`N#m)-bna2MW83#bX%FU|?#79UJkZtmm}Qqo=zL zFNCj@Yvl}7z7-A^A6%ZTi5d!IB(Ww*te?n?Dvw>LqZlL!b(d{-G(Pgrm9gnNFn3s= zG_zU@OqI}HGe=GS7^got^sfzjz{KqX)HkvQ-0@4HhrI)aNa19*)mqH&EL~|VbDe~e zziM1V7cpTQmcKus#m)xQePhHtF_l?-(sH{6Nu>C;lT>nMhTOJq@o+4QS?58*ContA zjt+jy7-<&iEnp?yLnYeVawNitwjbkhw2C3&r%q{65qU@pKBX(1@)EkEr6_Q^IJzQ# z!sC(Nr0RaL1nXs}9=sd1x!B7#54fXrQ3+;d(8 z2+v@ZRyos{zRuE0Ly;C+A{?IWv?&V}MUoq=lr6_D!BhBb{ts+-)ti|0yNKds?&NsK zcJ3u6i3!2De)xY&q!<(<_A@?8vbVj$vRI2xPrl(}-pS_qh>|ks!inDQ!;(F$(f9oh z^~n#Wbk4Mtj&A;rx06^>(>|5w`po4#NapwWNH%qa&AXiwUcJojBBG+_d{Ld+IT71} zBTfOF8L70SaC^*J4>;@H2_uqtDT_o2sQ=yo!{#wvt zx_^QhJ*4k)WQ&Xc>^wkfVeX(lbzJ-!*kGQ`%?tg)JFPgmaroh-=0h@lvzCUN9pLY< zN#_s}xjX(j%!Vh6mPg&l{hv^L(2SkfAbyccyPgt!!U-~Zf7u}8Af z01i&II^R8gCRJWq2*~dzbWwhC;-<8ppnJ(>7Yydw5VaKQiPV z%ips}8POG`G@M`)LEuEL*UHo(PdFb@!mXJLL}?xGE>p)d;MVHt4h-v1jCW!#cp7V! z4RKOP{f9n&kzW$Fo_ZEP5>dQuFt-v$3#|Qlpyhcg8-*TL#n)J-B6>qF$rYWI&7<|- z0Mb3nJ)N0÷jLlABLK09SuVxL^l^My4LfGOjAK9oG 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 literal 0 HcmV?d00001 diff --git a/docs/binaural.en.md b/docs/binaural.en.md new file mode 100644 index 0000000..20f9864 --- /dev/null +++ b/docs/binaural.en.md @@ -0,0 +1,277 @@ +# Binaural rendering + +[中文](binaural.md) · [Back to README](../README.en.md) + +JustOneCacophony's binaural backend supports three HRTF sources: +`SimpleFreeFieldHRIR` SOFA, the Rosella `.personalized_headphone` model exported +by Dolby's official personalization scan (its JSON parsing is implemented by +this project and invokes no Dolby software), and the `.jochrtf` cache compiled +from SOFA. SOFA is compiled into an in-memory directional field when the model +is loaded. A `.jochrtf` file is only a disposable, reproducible JOC compiled +HRTF cache; it is neither an interchange format nor a prerequisite for using +SOFA. + +```text +SOFA FIR + -> CanonicalHrtf + -> 48 kHz / one radius shell / delay-phase policy + -> 64-QMF / 77-hybrid projection + -> fifth-order ACN/N3D real-SH field + -> per-object direct + early reflections + -> shared unitary-FDN late room + -> float64 stereo +``` + +## Inputs + +The CLI has three mutually exclusive HRTF input sources; with none given, a +default rule resolves the input: + +```powershell +# 1) SOFA: defaults to HRTF/binaural.sofa, or an explicit path +python main.py input.m4a --binaural +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa + +# 2) Rosella .personalized_headphone: defaults to HRTF/binaural.personalized_headphone +python main.py input.m4a --binaural --personalized-headphone +python main.py input.m4a --binaural --personalized-headphone C:\HRTF\subject.personalized_headphone + +# 3) .jochrtf: explicitly load a compiled cache +python main.py input.m4a --binaural ` + --compiled-hrtf-cache C:\HRTF\subject.jochrtf + +# Optional: create/reuse a transparent disk cache for SOFA +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --hrtf-cache-policy disk +``` + +The default order is `HRTF/binaural.sofa`, then the unique `.jochrtf` under +`output/hrtf-cache`, then `HRTF/binaural.personalized_headphone`; if none of +the three exist, an error asks for an explicit path. Multiple `.jochrtf` files +under `output/hrtf-cache` are also an error requiring an explicit choice. + +The `.personalized_headphone` JSON parsing is implemented by this project +(`src/rosella_model.py`) and does not invoke any Dolby software. + +`--hrtf-cache-policy` accepts `none`, `memory`, or `disk`. The default is +`memory`; neither `none` nor `memory` creates a file. `disk` writes to +`output/hrtf-cache` by default, or to `--hrtf-cache-dir`. `--hrtf-radius-m` +selects the nearest measurement-radius shell. + +The Python API also uses explicit factories: + +```python +from sofa_binaural_backend import SofaBinauralBackend + +renderer = SofaBinauralBackend.from_sofa( + "subject.sofa", + source_count=16, + default_profile="mid", + cache_policy="memory", +) + +cached = SofaBinauralBackend.from_compiled_cache( + "subject.jochrtf", + source_count=16, + default_profile="mid", +) +``` + +The factories never guess a format from an unknown suffix: SOFA and `.jochrtf` +always use distinct loaders. + +## Binaural render mode + +`--binaural-mode off|near|mid|far` (default `mid`) is a **human-specified +rendering hint**, not original binaural metadata extracted or recovered from the +input E-AC-3 JOC bitstream: + +- Direct binaural rendering (`--binaural`): near/mid/far apply, default `mid`; + `off` is an error; +- ADM BWF: the low 3 binaural-render-mode bits of the last 15 JOC object entries + in DBMD segment 10 carry `off=0/near=1/far=2/mid=3`, leaving the first 10 bed + entries unchanged; the default is `mid`, and `off` explicitly disables the + binaural metadata hint. + +## Canonical SOFA contract + +The strict importer currently accepts: + +- `Conventions=SOFA`; +- `SOFAConventions=SimpleFreeFieldHRIR`, version `0.4`, `1.0`, or `1.1`; +- `DataType=FIR` and `Data.IR[M,2,N]`; +- one positive finite `Data.SamplingRate` in hertz/Hz; +- spherical or Cartesian `SourcePosition`; +- singleton or per-measurement `ListenerPosition/View/Up`; +- two receivers whose listener-local lateral geometry uniquely identifies L/R; +- one zero-offset emitter; +- causal `Data.Delay[I,2]` or `[M,2]`; +- an explicitly free-field/anechoic `RoomType`. + +Receiver order comes from geometry, never from the receiver array index. SOFA +listener coordinates are $+X$ front, $+Y$ left, $+Z$ up; ADM coordinates are +$+X$ right, $+Y$ front, $+Z$ up: + +$$\bigl(x_{\mathrm{SOFA}},\ y_{\mathrm{SOFA}},\ z_{\mathrm{SOFA}}\bigr) = \bigl(y_{\mathrm{ADM}},\ -x_{\mathrm{ADM}},\ z_{\mathrm{ADM}}\bigr)$$ + +`CanonicalHrtf` keeps `Data.IR` and `Data.Delay` separate. Only a time-domain +baseline calls `materialized_measurement()` to apply delay once; the runtime SH +path never materializes and then restores the delay. Non-48-kHz HRIRs are +normalized with float64 `scipy.signal.resample_poly`, and delay samples scale by +the same ratio. + +GeneralFIR, BRIR, TF, multiple emitters, ambiguous receivers, and non-free-field +data require convention-specific adapters. They cannot enter the core importer +through a reshape. + +## Exactly-once delay and phase + +The compiler recognizes three mutually exclusive representations: + +1. Nonzero `Data.Delay` is external to `Data.IR`; the FIR is not de-rotated and + runtime applies the delay once. +2. With `Data.Delay=0` and an ordinary positive-onset HRIR, each ear's main peak + supplies arrival time. Compilation separates it and runtime restores it once. + The current threshold is a peak index greater than two samples. +3. With `Data.Delay=0` and both FIRs at a shared sample-zero origin, no external + delay is invented. The authored complex phase stays in the fifth-order field. + +No path may add a second ear delay or phase-group delay. + +## Public filterbank and directional field + +The runtime is fixed at: + +- 48 kHz; +- a 64-sample QMF hop; +- 64-QMF / 77 hybrid bands; +- 961 samples of analysis/synthesis latency; +- fifth order, 36 terms, ACN/N3D real spherical harmonics; +- float64 PCM, delay, SH, and room state; complex128 band transfers and spectra. + +Real and imaginary unit gains for every hybrid band pass through the same +analysis/synthesis chain to form a 154-real-parameter impulse dictionary. The +compiler does not sample 77 FFT bins. Defaults are `1e-3` projection ridge and +`1e-5` SH ridge. Coincident directions are merged before a spherical-Voronoi +weighted ridge fit. + +The fixed resource is `data/rosella_kernels.npz`, which implements publicly +standardized filter banks, computable from the following formulas. + +The hybrid analysis kernels are defined in [3GPP TS 26.405 / ETSI TS 126 405](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf), +Section 5.2.2 (Table 1 $Q=8$/$Q=4$ coefficients, delay 6): + +$$G_q^p[n] = g^p[n]\cdot\exp\!\Bigl(j\,\frac{2\pi}{Q^p}\bigl(q+\tfrac12\bigr)(n-6)\Bigr),\qquad n=0,\dots,12$$ + +The QMF analysis table is the MPEG-4 AAC/SBR 64 complex QMF bank of +ISO/IEC 14496-3/AMD1:2003, subclause 4.B.18.2, stored as the polyphase +reordering of the public 640-tap prototype $c_0,\dots,c_{639}$: + +$$A_{r,t} = \frac{(-1)^t}{128}\,c_{63-r+64t},\qquad r=0,\dots,63,\ t=0,\dots,9$$ + +The QMF synthesis table is the causal left inverse of the analysis polyphase +matrix $\mathbf{A}$, i.e. the solution of $\mathbf{A}\,\mathbf{W}=\mathbf{P}$ +($\mathbf{P}$ is the 577-sample delay permutation; total latency +$961 = 577 + 6\times64$), stored as a rank-4 factorization: + +$$W_{b,l} = \sum_{r=1}^{4} t_{b,l,r}\,\mathbf{b}_{b,r}^{\top}$$ + +The hybrid synthesis table is the 77→64 recombination: identity for the high +bands, $Y_{3+b}=X_{16+b}$, and for the low bands ($C_p$ is the $8+4+4$ child +partition): + +$$Y_p = \sum_{q\in C_p}\Bigl(\operatorname{Re}X_q + j\,s_q\,\operatorname{Im}X_q\Bigr),\qquad s_q\in\{\pm1\}$$ + +The loader verifies the archive and every array by SHA-256; the table version, +all array hashes, and the 77 reference band-center values are part of the cache +key. Public availability of a standard does not by itself grant permission to +practice related patent claims. See +[`data/README.en.md`](../data/README.en.md) and +[`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md) for the sources and the +rights boundary. + +## `.jochrtf` + +A `.jochrtf` file is a pickle-free compressed NumPy archive with an exact member set: + +| key | dtype / shape | +|---|---| +| `metadata_json` | NumPy Unicode scalar containing JSON text (`dtype.kind == "U"`) | +| `band_center_frequencies_hz` | little-endian `float64[77]` | +| `coefficients` | little-endian `complex128[36,2,77]` | +| `delay_coefficients` | little-endian `float64[36,2]` | +| `delay_bounds` | little-endian `float64[2,2]` | + +Metadata uses the `JOC-HRTF-CACHE` magic and records the schema, compiler and +phase-policy versions, ACN/N3D convention, filterbank hashes, SOFA content +SHA-256, sample rate, radius, order, both ridge values, payload hash, and fit +report. Every setting that changes compilation participates in the cache key. +Metadata never persists an absolute local `source_path`; it may keep a display +name only. + +Before constructing a field, the loader uses `allow_pickle=False` and validates +ZIP members and expanded sizes, shapes, dtypes, byte order, contiguous layout, +finite values, delay bounds, band centers, payload hash, and cache key. The +writer uses a same-directory temporary file, `fsync`, a process-held OS file +lock, and atomic `os.replace`. Its hidden `.lock` sidecar may remain and does not +mean that a writer still owns the lock. Outdated, damaged, or mismatched +caches cannot hit. SOFA input rebuilds an invalid cache; an explicitly selected +cache reports the error. + +Deleting a disk cache must not change the field or render produced from the same +SOFA and compiler configuration. + +A `.jochrtf` file contains directional-field coefficients and delay data +transformed from the source HRIRs. Its reproducibility therefore does not make +it licence-free. Creating a cache does not enlarge the rights granted by the +source SOFA/HRTF dataset: use, copying, and redistribution remain subject to +that dataset's terms. If those terms are unclear, keep `.jochrtf` as a private +local cache and do not ship it with the program or another build artifact. +`source_sha256` is only a content-integrity identifier, not proof of provenance +or permission. + +## JOC objects and room behavior + +The production adapter retains the existing JOC schedule: + +- `[1536,16]` input per frame; +- channel 0 is special LFE and channels 1..15 are JOC objects; +- ID11/OAMD positions use a sample-timed timeline; +- source parameters update every 512 samples; +- every object owns independent direct/early history while one late FDN is shared; +- `finish()` drains early/late tails; output gain is explicit, with no implicit + limiter or programme loudness normalization. + +Near/Mid/Far, equal-power direct level, six first-order shoebox image sources, +late sends, the unitary FDN, the 120–180 Hz cosine-squared LFE low-pass, and room +calibration are JOC project-defined behavior, not constants published by SOFA or +Dolby. + +The public SOFA binaural renderer defaults to the C++20 native core under +`--backend auto/native` (`ejoc_sofa_binaural_*` in `lib/eac3joc_core.dll`): the +filterbank, the SH direction-field evaluation, the per-object direct/early +histories and the shared FDN all run natively, while Python only compiles the +SOFA source and issues the per-512-sample metadata updates. When the native +library is unavailable the renderer falls back to the Python/NumPy reference +implementation; the two agree to better than 1e-9. `--backend python` forces +the Python backend. +`--backend` still selects native/Python JOC reconstruction and speaker rendering; +native acceleration for the public binaural DSP is outside the current API. + +## Technical references and rights boundary + +- [SOFA SimpleFreeFieldHRIR convention](https://www.sofaconventions.org/mediawiki/index.php/SimpleFreeFieldHRIR) +- [3GPP TS 26.405 / ETSI TS 126 405 (64-QMF/77-hybrid definition)](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf) +- [Dolby binaural render-mode workflow](https://professionalsupport.dolby.com/s/article/What-is-Binaural-Render-Mode-and-how-do-the-settings-affect-my-mix) +- [EP3090576A1](https://patents.google.com/patent/EP3090576A1/en), used only as + architectural background for direct/early/late, subbands, and FDNs; it does + not establish that any product uses a particular embodiment. + +Public availability of a specification, source file, or patent document does +not by itself authorize copying its contents, redistribution of derivatives, +or practice of patent claims. These technical references grant no patent +licence and make no non-infringement representation. Anyone preparing a release +or product integration must assess the applicable data and software licences, +patent permissions, and freedom to operate. See +[`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md) for the public-standard +provenance and rights boundary. diff --git a/docs/binaural.md b/docs/binaural.md new file mode 100644 index 0000000..8d12e5c --- /dev/null +++ b/docs/binaural.md @@ -0,0 +1,238 @@ +# 双耳渲染 + +[English](binaural.en.md) · [返回 README](../README.md) + +JustOneCacophony 的双耳后端支持三种 HRTF 来源:`SimpleFreeFieldHRIR` SOFA、 +杜比官方软件个性化扫描导出的 Rosella `.personalized_headphone`(JSON 解析由本项目 +自行实现,不调用杜比软件),以及从 SOFA 编译出的 `.jochrtf` 缓存。SOFA 在模型加载 +时编译成内存方向场;`.jochrtf` 只是可删除、可重建的 JOC compiled HRTF cache, +不是交换格式,也不是使用 SOFA 的前置步骤。 + +```text +SOFA FIR + -> CanonicalHrtf + -> 48 kHz / 单 radius shell / delay-phase policy + -> 64-QMF / 77-hybrid projection + -> 五阶 ACN/N3D 实球谐场 + -> 逐对象 direct + early reflections + -> shared unitary-FDN late room + -> stereo float64 +``` + +## 输入接口 + +CLI 有三个互斥的 HRTF 输入来源;都不指定时按默认规则自动选择: + +```powershell +# 1) SOFA:缺省取 HRTF/binaural.sofa,也可显式指定 +python main.py input.m4a --binaural +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa + +# 2) Rosella .personalized_headphone:缺省取 HRTF/binaural.personalized_headphone +python main.py input.m4a --binaural --personalized-headphone +python main.py input.m4a --binaural --personalized-headphone C:\HRTF\subject.personalized_headphone + +# 3) .jochrtf:显式读取预编译 cache +python main.py input.m4a --binaural ` + --compiled-hrtf-cache C:\HRTF\subject.jochrtf + +# 可选:SOFA 透明生成/复用磁盘 cache +python main.py input.m4a --binaural --sofa-hrtf C:\HRTF\subject.sofa ` + --hrtf-cache-policy disk +``` + +默认选择顺序:`HRTF/binaural.sofa` → `output/hrtf-cache` 下唯一的 `.jochrtf` → +`HRTF/binaural.personalized_headphone`;三者都没有时报错并提示显式指定。 +`output/hrtf-cache` 下有多个 `.jochrtf` 时同样报错,要求显式选择。 + +`.personalized_headphone` 的 JSON 解析由本项目自行实现(`src/rosella_model.py`), +不调用任何杜比软件。 + +`--hrtf-cache-policy` 可取 `none`、`memory`、`disk`。默认是 `memory`;`none` 和 +`memory` 都不会创建磁盘文件。`disk` 默认写入 `output/hrtf-cache`,也可用 +`--hrtf-cache-dir` 指定。`--hrtf-radius-m` 选择距离目标最近的 measurement shell。 + +Python API 使用显式 factory: + +```python +from sofa_binaural_backend import SofaBinauralBackend + +renderer = SofaBinauralBackend.from_sofa( + "subject.sofa", + source_count=16, + default_profile="mid", + cache_policy="memory", +) + +cached = SofaBinauralBackend.from_compiled_cache( + "subject.jochrtf", + source_count=16, + default_profile="mid", +) +``` + +文件工厂不会按“未知后缀”猜格式:SOFA 和 `.jochrtf` 始终走不同 loader。 + +## 双耳渲染模式 + +`--binaural-mode off|near|mid|far`(默认 `mid`)是**人为指定的渲染提示**,不是 +从输入 E-AC-3 JOC 码流提取或还原的原始双耳元数据: + +- 直接双耳渲染(`--binaural`):near/mid/far 生效,默认 `mid`;`off` 报错; +- ADM BWF:DBMD segment 10 中后 15 个 JOC 对象的 binaural render mode 写 + `off=0/near=1/far=2/mid=3`,前 10 个 bed 保持不变,默认 `mid`;`off` 用于显式 + 关闭双耳元数据提示。 + +## Canonical SOFA 契约 + +当前 strict importer 接受: + +- `Conventions=SOFA`; +- `SOFAConventions=SimpleFreeFieldHRIR`,version `0.4`、`1.0` 或 `1.1`; +- `DataType=FIR`,`Data.IR[M,2,N]`; +- 单一正有限 `Data.SamplingRate`,单位为 hertz/Hz; +- spherical 或 Cartesian `SourcePosition`; +- 单值或 per-measurement 的 `ListenerPosition/View/Up`; +- 两个能由 listener-local lateral 坐标唯一识别左右的 receiver; +- 单一且零偏移的 emitter; +- causal `Data.Delay[I,2]` 或 `[M,2]`; +- 明确的 free-field/anechoic `RoomType`。 + +receiver 左右顺序由几何决定,不能假定 `Data.IR` 的 receiver index。SOFA listener +坐标为 $+X$ front、$+Y$ left、$+Z$ up;ADM 坐标为 $+X$ right、$+Y$ front、 +$+Z$ up,转换为: + +$$\bigl(x_{\mathrm{SOFA}},\ y_{\mathrm{SOFA}},\ z_{\mathrm{SOFA}}\bigr) = \bigl(y_{\mathrm{ADM}},\ -x_{\mathrm{ADM}},\ z_{\mathrm{ADM}}\bigr)$$ + +`CanonicalHrtf` 将 `Data.IR` 与 `Data.Delay` 分开保存。只有时域 baseline 才调用 +`materialized_measurement()` 将 delay 应用一次;运行时 SH 路径不先 materialize。 +非 48 kHz HRIR 使用 float64 `scipy.signal.resample_poly` 规范化,delay samples 按 +相同比例缩放。 + +GeneralFIR、BRIR、TF、多 emitter、多义 receiver 或非 free-field 数据需要单独的 +convention adapter,不能只通过 reshape 进入核心 importer。 + +## Delay/phase:exactly once + +编译器只允许三种互斥语义: + +1. 非零 `Data.Delay` 是 `Data.IR` 外部 delay;FIR 不去旋,运行时应用一次。 +2. `Data.Delay=0` 且 HRIR 有普通正 onset:以每耳 main peak 分离 arrival,拟合后 + 在运行时恢复一次;当前阈值为 peak index 大于 2 samples。 +3. `Data.Delay=0` 且双耳 FIR 共享 sample-0 起点:不发明外部 delay,原 complex + phase 直接进入五阶场。 + +任何路径都不能再叠加第二套 ear delay 或 phase-group delay。 + +## 公开 filterbank 与方向场 + +运行时固定为: + +- 48 kHz; +- 64-sample QMF hop; +- 64-QMF / 77-hybrid; +- analysis/synthesis latency 961 samples; +- 五阶、36 项、ACN/N3D real spherical harmonics; +- PCM、delay、SH、room state 为 float64;频带传递和频域状态为 complex128。 + +每个 hybrid band 的 real/imaginary 单位增益都通过同一套 analysis/synthesis 链生成 +脉冲字典,共 154 个实参数;编译不是直接读取 77 个 FFT bin。默认 projection +ridge 为 `1e-3`,SH ridge 为 `1e-5`。同方向 measurement 先合并,再用球面 Voronoi +面积权重做 ridge fit。 + +固定表位于 `data/rosella_kernels.npz`,实现公开标准化的滤波器组,各表可由如下 +公式计算。 + +hybrid 分析核定义于 [3GPP TS 26.405 / ETSI TS 126 405](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf) +第 5.2.2 节(Table 1 的 $Q=8$/$Q=4$ 系数,delay 6): + +$$G_q^p[n] = g^p[n]\cdot\exp\!\Bigl(j\,\frac{2\pi}{Q^p}\bigl(q+\tfrac12\bigr)(n-6)\Bigr),\qquad n=0,\dots,12$$ + +QMF analysis 表即 MPEG-4 AAC/SBR(ISO/IEC 14496-3/AMD1:2003 第 4.B.18.2 节) +的 64 complex QMF bank;打包的 $64\times10$ 表是公开 640-tap prototype +$c_0,\dots,c_{639}$ 的多相重排: + +$$A_{r,t} = \frac{(-1)^t}{128}\,c_{63-r+64t},\qquad r=0,\dots,63,\ t=0,\dots,9$$ + +QMF synthesis 表为上述 analysis 多相矩阵 $\mathbf{A}$ 的因果左逆,即求解 +$\mathbf{A}\,\mathbf{W}=\mathbf{P}$($\mathbf{P}$ 为 577-sample 延迟置换; +全链 $961 = 577 + 6\times64$),以 rank-4 分解形式存储: + +$$W_{b,l} = \sum_{r=1}^{4} t_{b,l,r}\,\mathbf{b}_{b,r}^{\top}$$ + +hybrid synthesis 表为 77→64 重组:高频带恒等 $Y_{3+b}=X_{16+b}$;低频带 +($C_p$ 为 $8+4+4$ 子带划分): + +$$Y_p = \sum_{q\in C_p}\Bigl(\operatorname{Re}X_q + j\,s_q\,\operatorname{Im}X_q\Bigr),\qquad s_q\in\{\pm1\}$$ + +loader 校验 archive 和每个数组的 SHA-256;table version、所有数组 hash 与 +77 个 band-center 参考值都属于 cache key。标准可公开获取不等于获准实施相关 +专利;更多来源信息见 [`data/README.md`](../data/README.md) 与 +[`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md)。 + +## `.jochrtf` + +`.jochrtf` 是无 pickle 的压缩 NumPy archive,固定包含: + +| key | dtype / shape | +|---|---| +| `metadata_json` | 含 JSON 文本的 NumPy Unicode scalar(`dtype.kind == "U"`) | +| `band_center_frequencies_hz` | little-endian `float64[77]` | +| `coefficients` | little-endian `complex128[36,2,77]` | +| `delay_coefficients` | little-endian `float64[36,2]` | +| `delay_bounds` | little-endian `float64[2,2]` | + +metadata magic 固定为 `JOC-HRTF-CACHE`,并记录 schema/compiler/phase-policy、 +ACN/N3D、filterbank table hashes、SOFA content SHA-256、采样率、radius、order、 +两个 ridge、payload hash 和 fit report。cache key 覆盖所有会改变编译结果的字段。 +metadata 不保存本机绝对 `source_path`,仅可保存 source display name。 + +loader 使用 `allow_pickle=False`,并在构造对象前检查 ZIP 成员集、解压大小、shape、 +dtype、端序、连续布局、有限值、delay bounds、band centers、payload hash 和 cache +key。writer 使用同目录临时文件、`fsync`、进程持有的 OS 文件锁和原子 +`os.replace`;对应的隐藏 `.lock` sidecar 可保留,但不代表仍有 writer 持锁。 +旧版本、损坏或配置不匹配的 cache 不能命中;从 SOFA 启动时会重建,显式 cache +入口则直接报错。 + +删除磁盘 cache 后,从同一 SOFA 和同一编译配置得到的场与渲染结果不得改变。 + +`.jochrtf` 包含由源 HRIR 变换得到的方向场系数与 delay 数据,因此“可以重建”不表示 +它不受数据许可约束。生成 cache 不会扩大源 SOFA/HRTF 数据集授予的权利;cache 的 +使用、复制和再分发仍须遵守源数据集条款。不能确认条款时,应把 `.jochrtf` 作为本地 +私有 cache,不随程序或构建产物发布。`source_sha256` 只用于内容一致性校验,不是许可 +或来源证明。 + +## JOC 对象与房间 + +生产适配器继续使用现有 JOC 调度: + +- 每帧输入 `[1536,16]`; +- channel 0 是 special LFE,channel 1..15 是 JOC objects; +- ID11/OAMD position 使用 sample-timed timeline; +- 每 512 samples 更新方向/profile; +- 每个对象拥有独立 direct/early history,late FDN 全局共享; +- `finish()` 排空 early/late tail;输出增益显式应用,不隐含 limiter 或节目响度归一化。 + +Near/Mid/Far、equal-power direct level、六面 shoebox 一阶 image source、late send、 +unitary FDN、LFE 120–180 Hz cosine-squared 低通及 room calibration 都是 JOC +项目定义行为,不是 SOFA 或 Dolby 公布常数。 + +公开 SOFA 双耳渲染在 `--backend auto/native` 下默认走 C++20 原生核 +(`lib/eac3joc_core.dll` 的 `ejoc_sofa_binaural_*` 接口:filterbank、SH 方向场求值、 +逐对象 early/direct 历史与共享 FDN 全部在原生侧执行,Python 只做 SOFA 编译与每 +512-sample 的元数据更新);原生库不可用时自动回退 Python/NumPy 参考实现,两者 +逐值一致(差异 < 1e-9)。`--backend python` 强制使用 Python 后端。 + +## 技术引用与权利边界 + +- [SOFA SimpleFreeFieldHRIR convention](https://www.sofaconventions.org/mediawiki/index.php/SimpleFreeFieldHRIR) +- [3GPP TS 26.405 / ETSI TS 126 405(64-QMF/77-hybrid 定义)](https://www.etsi.org/deliver/etsi_ts/126400_126499/126405/06.00.00_60/ts_126405v060000p.pdf) +- [Dolby binaural render mode workflow](https://professionalsupport.dolby.com/s/article/What-is-Binaural-Render-Mode-and-how-do-the-settings-affect-my-mix) +- [EP3090576A1](https://patents.google.com/patent/EP3090576A1/en),仅作 direct/early/late、 + subband 与 FDN 架构背景,不证明某个产品使用特定实施例。 + +规范、源码或专利文献可公开获取,不等于获准复制其内容、再分发派生产物或实施其中的 +专利权利要求。本项目的技术引用本身不授予专利许可,也不作不侵权保证;准备发布或集成 +到产品的一方应自行审查适用的数据许可、软件许可、专利许可及 freedom-to-operate。 +公开标准来源与权利边界见 +[`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md)。 diff --git a/docs/native.en.md b/docs/native.en.md index eec24e9..3f5a1d6 100644 --- a/docs/native.en.md +++ b/docs/native.en.md @@ -116,7 +116,26 @@ Supported layouts: 2.0 3.1 5.1 7.1 5.1.2 5.1.4 7.1.2 7.1.4 9.1.4 9.1.6 ``` -## 6. Building +## 6. Binaural-rendering ABI + +The shared library provides a 512-sample float64 binaural DSP interface: + +```c +ejoc_binaural_renderer_handle ejoc_binaural_renderer_create(void); +int ejoc_binaural_renderer_configure_kernels(...); +int ejoc_binaural_renderer_configure_room(...); +int ejoc_binaural_renderer_process( + ejoc_binaural_renderer_handle handle, + const double* input16_interleaved, /* [512][16] */ + const double* gains_complex, /* [16][2][77][2] */ + const double* room_sends, /* [16] */ + double output_gain, + double* output_stereo_interleaved); /* [512][2] */ +``` + +Python parses the model, evaluates the OAMD timeline, and supplies complex gains and room sends every 512 samples. The C++ handle owns QMF, hybrid, recursive-room, and QMF-synthesis state. Inputs, state, accumulation, and output are double/complex double. + +## 7. Building The CMake definition is `native/CMakeLists.txt`. Run from the repository root: @@ -138,7 +157,7 @@ The MSVC configuration uses the static CRT. Other runtime dependencies depend on The repository does not include native binaries by default. A prebuilt Release runtime or a locally built runtime can be placed directly under `lib/`. -## 7. Runtime lookup and fallback +## 8. Runtime lookup and fallback Lookup order: @@ -148,7 +167,7 @@ Lookup order: `--backend auto` falls back to NumPy when loading fails, and `--backend python` skips native discovery. The current CLI also prints the failure and falls back for `--backend native`; this existing behavior should not be read as successful native execution. -## 8. Implementation boundaries +## 9. Implementation boundaries - The native layer accepts only dense-JOC data already parsed by Python. - The ABI fixes a 1536-sample JOC frame, at most 15 objects, at most 23 parameter bands, and at most 2 data points. diff --git a/docs/native.md b/docs/native.md index 703ca28..970211c 100644 --- a/docs/native.md +++ b/docs/native.md @@ -116,7 +116,39 @@ int ejoc_speaker_renderer_process( 2.0 3.1 5.1 7.1 5.1.2 5.1.4 7.1.2 7.1.4 9.1.4 9.1.6 ``` -## 6. 构建 +## 6. 双耳渲染 ABI + +共享库提供 512-sample float64 双耳 DSP: + +```c +ejoc_binaural_renderer_handle ejoc_binaural_renderer_create(void); +int ejoc_binaural_renderer_configure_kernels(...); +int ejoc_binaural_renderer_configure_room(...); +int ejoc_binaural_renderer_process( + ejoc_binaural_renderer_handle handle, + const double* input16_interleaved, /* [512][16] */ + const double* gains_complex, /* [16][2][77][2] */ + const double* room_sends, /* [16] */ + double output_gain, + double* output_stereo_interleaved); /* [512][2] */ +``` + +Python 负责模型解析、OAMD 时间轴和每 512 samples 的 complex gains/room sends。C++ handle 保存 QMF、hybrid、递归 room 和 QMF synthesis 状态。全部输入、状态、乘加和输出均为 double/complex double。 + +## 6.1 公开 SOFA 双耳渲染 ABI + +共享库同时提供完整的原生 SOFA 双耳渲染器(`ejoc_sofa_binaural_*`),它镜像 +Python `SofaBinauralBackend` 的全部数学:64-QMF/77-hybrid analysis/synthesis、 +五阶 ACN/N3D 实球谐方向场求值、whole-QMF-slot 逐对象 delay 历史、六面一阶 +image-source early reflections、共享 unitary FDN late room、LFE 120–180 Hz +低通与 961-sample latency 语义。kernel 表、编译好的 HRTF 场与房间常数通过 +`configure_kernels/configure_field/configure_room` 一次上传;每 512-sample +block 先 `set_source` 更新 16 个 source,再 `process` 输入 PCM;`process` 返回 +裁剪后的 stereo 样本数(首个 961 samples 被丢弃)。`finish` 以 64-sample 对齐的 +块排空尾音。Python 桥位于 `src/sofa_native_backend.py`,与 Python 参考实现逐值 +一致(差异 < 1e-9);原生库缺失时 `main.py` 自动回退 Python。 + +## 7. 构建 CMake 定义位于 `native/CMakeLists.txt`。从仓库根目录运行: @@ -138,7 +170,7 @@ MSVC 配置使用静态 CRT。其他运行时依赖由平台和工具链决定 仓库默认不附带原生二进制。预构建的 Release 运行库或自行构建的运行库均可直接放入 `lib/`。 -## 7. 运行时查找与回退 +## 8. 运行时查找与回退 查找顺序为: @@ -148,7 +180,7 @@ MSVC 配置使用静态 CRT。其他运行时依赖由平台和工具链决定 `--backend auto` 在加载失败时回退到 NumPy;`--backend python` 跳过原生探测。`--backend native` 当前也会打印失败原因后回退,这是现有 CLI 行为,不应理解为原生库已成功使用。 -## 8. 实现边界 +## 9. 实现边界 - 原生层只接收 Python 已解析的 dense JOC 数据。 - ABI 固定了 1536-sample JOC 帧、最多 15 个对象、最多 23 个参数带和最多 2 个数据点。 diff --git a/main.py b/main.py index c88a253..f5868e3 100644 --- a/main.py +++ b/main.py @@ -26,10 +26,24 @@ from metadata import DirectPayloadIndex, PayloadIndex, write_summary import oamd_tracks from renderer import JocRenderer from native_renderer import NativeBackendUnavailable, NativeJocRenderer +from binaural_renderer import ( + DEFAULT_SOFA_HRTF, + SofaBinauralRenderer, + resolve_compiled_hrtf_cache, + resolve_sofa_hrtf, +) +from rosella_binaural_renderer import ( + DEFAULT_PERSONALIZED_HEADPHONE, + ROSSELLA_BLOCK_SAMPLES, + ROSSELLA_LATENCY_SAMPLES, + RosellaBinauralRenderer, + resolve_personalized_headphone, +) +from sofa_hrtf_field import DEFAULT_HRTF_CACHE_DIR 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,18 +52,111 @@ 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() +def _find_default_compiled_hrtf_cache(): + """在默认 cache 目录寻找唯一的 .jochrtf;无文件返回 None,多个则报错。""" + directory = DEFAULT_HRTF_CACHE_DIR + if not directory.is_dir(): + return None + candidates = sorted(directory.glob("*.jochrtf")) + if not candidates: + return None + if len(candidates) > 1: + listing = ", ".join(path.name for path in candidates[:8]) + raise ValueError( + f"{directory} 下有多个 .jochrtf 缓存({listing}…),无法自动选择;" + "请用 --compiled-hrtf-cache PATH 或 --sofa-hrtf PATH 显式指定") + return candidates[0] + + +def resolve_binaural_hrtf_input(args, *, required): + """解析 binaural 的 HRTF 输入。 + + 无显式输入时按顺序回退:默认 HRTF/binaural.sofa → 默认 cache 目录下唯一的 + .jochrtf → 默认 HRTF/binaural.personalized_headphone → 报错。 + 只校验路径,不做编译。 + """ + sofa = args.sofa_hrtf + compiled = args.compiled_hrtf_cache + private = args.personalized_headphone + cache_policy = args.hrtf_cache_policy + cache_dir = args.hrtf_cache_dir + radius = args.hrtf_radius_m + + if compiled is not None and cache_policy is not None: + raise ValueError("显式 .jochrtf 输入不能再指定 --hrtf-cache-policy") + if compiled is not None and radius != 1.0: + raise ValueError("显式 .jochrtf 输入不能再选择 SOFA radius shell") + if private is not None and (cache_policy is not None or cache_dir is not None + or radius != 1.0): + raise ValueError( + "Rosella 模型输入不能使用 " + "--hrtf-cache-policy/--hrtf-cache-dir/--hrtf-radius-m") + + if required and sofa is None and compiled is None and private is None: + if DEFAULT_SOFA_HRTF.is_file(): + sofa = DEFAULT_SOFA_HRTF + else: + compiled = _find_default_compiled_hrtf_cache() + if compiled is None and DEFAULT_PERSONALIZED_HEADPHONE.is_file(): + private = DEFAULT_PERSONALIZED_HEADPHONE + + if sofa is None and compiled is None and private is None: + if cache_policy is not None or cache_dir is not None or radius != 1.0: + raise ValueError("HRTF cache/radius 选项需要 --sofa-hrtf") + if required: + raise ValueError( + "--binaural 未找到 HRTF 输入:默认 " + f"{DEFAULT_SOFA_HRTF}、{DEFAULT_PERSONALIZED_HEADPHONE} 与 " + f"{DEFAULT_HRTF_CACHE_DIR} 下的 .jochrtf 缓存都不存在;请用 " + "--sofa-hrtf PATH、--compiled-hrtf-cache PATH 或 " + "--personalized-headphone PATH 指定") + return None + + if sofa is None and (cache_policy is not None or cache_dir is not None + or radius != 1.0): + raise ValueError("HRTF cache/radius 选项需要 --sofa-hrtf") + effective_policy = "memory" if cache_policy is None else cache_policy + if cache_dir is not None and (sofa is None or effective_policy != "disk"): + raise ValueError("--hrtf-cache-dir 仅与 SOFA 的 disk cache policy 一起使用") + if sofa is not None: + return { + "kind": "sofa", + "path": resolve_sofa_hrtf(sofa), + "cache_policy": effective_policy, + "cache_dir": (DEFAULT_HRTF_CACHE_DIR if cache_dir is None else + cache_dir.expanduser().resolve()), + } + if compiled is not None: + return { + "kind": "compiled_cache", + "path": resolve_compiled_hrtf_cache(compiled), + "cache_policy": None, + "cache_dir": None, + } + if private is not None: + return { + "kind": "rosella", + "path": resolve_personalized_headphone(private), + "cache_policy": None, + "cache_dir": None, + } + return None + + def executable(value, name): path = shutil.which(value) if value else None if path is None and value and Path(value).is_file(): @@ -105,8 +212,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 +253,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 +309,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 +329,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 +342,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 +357,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 +380,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 +398,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,41 +408,80 @@ 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 或公开 SOFA 双耳 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="直接 SOFA 双耳渲染;不生成临时 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=("off", "near", "mid", "far"), + default="mid", + help="双耳渲染模式,默认 mid(人为指定的渲染提示,非码流 " + "原始元数据);直接双耳渲染与 ADM BWF 的 DBMD 提示共用。" + "off 仅用于 ADM BWF:关闭 DBMD 双耳提示(编码 0)") + hrtf_input = parser.add_mutually_exclusive_group() + hrtf_input.add_argument( + "--sofa-hrtf", type=Path, + help="SimpleFreeFieldHRIR SOFA;缺省时依次尝试 HRTF/binaural.sofa、" + "output/hrtf-cache 下唯一的 .jochrtf、" + "HRTF/binaural.personalized_headphone,均无则报错") + hrtf_input.add_argument( + "--compiled-hrtf-cache", type=Path, + help="高级入口:显式读取 JOC .jochrtf compiled cache") + hrtf_input.add_argument( + "--personalized-headphone", type=Path, nargs="?", + const=DEFAULT_PERSONALIZED_HEADPHONE, + help="Rosella .personalized_headphone 模型;不带路径时默认 " + "HRTF/binaural.personalized_headphone") + parser.add_argument( + "--hrtf-cache-policy", choices=("none", "memory", "disk"), default=None, + help="SOFA 编译缓存;默认 memory,disk 写入可删除的 .jochrtf") + parser.add_argument( + "--hrtf-cache-dir", type=Path, + help="disk cache 目录;默认 output/hrtf-cache") + parser.add_argument( + "--hrtf-radius-m", type=float, default=1.0, + help="选择最近的 SOFA measurement-radius shell,默认 1.0 m") + 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") - 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="对象 PCM/OAMD 时间补偿;ADM 与双耳默认 1473 samples") 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") + help="JOC/扬声器 DSP 后端;SOFA 双耳 DSP 当前使用 Python") parser.add_argument("--native-library", type=Path, help="显式指定原生库;默认从单层 lib 目录选择当前平台文件") parser.add_argument("--native-threads", type=int, @@ -332,24 +515,61 @@ 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) + binaural_render_mode = args.binaural_mode + if binaural_mode and binaural_render_mode == "off": + raise ValueError( + "--binaural-mode off 仅用于 ADM BWF 输出(关闭 DBMD 双耳提示);" + "直接双耳渲染请使用 near/mid/far") 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 不能为负数") + hrtf_options_used = any(( + args.sofa_hrtf is not None, + args.compiled_hrtf_cache is not None, + args.personalized_headphone is not None, + args.hrtf_cache_policy is not None, + args.hrtf_cache_dir is not None, + args.hrtf_radius_m != 1.0, + )) + if hrtf_options_used and not binaural_mode: + raise ValueError("SOFA/HRTF 选项仅与 --binaural 一起使用") + if (not math.isfinite(args.binaural_tail_seconds) + or args.binaural_tail_seconds < 0): + raise ValueError("binaural-tail-seconds 必须是非负有限值") + if (not math.isfinite(args.binaural_tail_threshold) + or args.binaural_tail_threshold < 0): + raise ValueError("binaural-tail-threshold 必须是非负有限值") + if args.binaural_chunk_frames <= 0: + raise ValueError("binaural-chunk-frames 必须大于 0") + if not math.isfinite(args.hrtf_radius_m) or args.hrtf_radius_m <= 0.0: + raise ValueError("hrtf-radius-m 必须是正有限值") 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_hrtf_input = resolve_binaural_hrtf_input( + args, required=binaural_mode and not args.metadata_only) ffmpeg = executable(args.ffmpeg, "FFmpeg") total_started = time.perf_counter() @@ -394,7 +614,13 @@ def main(argv=None): speaker_wav_info = None speaker_clip_info = None speaker_actual_format = None + binaural_backend_info = None + binaural_hrtf_report = 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 +642,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,10 +662,158 @@ 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: + hrtf_source = binaural_hrtf_input + common_options = { + "mode": binaural_render_mode, + "object_delay_samples": args.object_delay_samples, + "tail_seconds": args.binaural_tail_seconds, + "output_gain": gain_float64, + "chunk_frames": args.binaural_chunk_frames, + } + if hrtf_source["kind"] == "sofa": + binaural_decoder = None + if args.backend in ("auto", "native"): + try: + from sofa_native_backend import create_native_sofa_renderer + binaural_decoder = timed_call( + timings, "create_binaural_renderer", + create_native_sofa_renderer, + hrtf_source["path"], + cache_policy=hrtf_source["cache_policy"], + cache_dir=hrtf_source["cache_dir"], + shell_radius_m=args.hrtf_radius_m, + **common_options) + except (ImportError, OSError, RuntimeError, ValueError) as exc: + print( + f"[binaural] native SOFA backend unavailable " + f"({exc.__class__.__name__}: {exc}); " + f"falling back to Python", flush=True) + binaural_decoder = None + if binaural_decoder is None: + binaural_decoder = timed_call( + timings, "create_binaural_renderer", + SofaBinauralRenderer.from_sofa, + hrtf_source["path"], + cache_policy=hrtf_source["cache_policy"], + cache_dir=hrtf_source["cache_dir"], + shell_radius_m=args.hrtf_radius_m, + **common_options) + elif hrtf_source["kind"] == "rosella": + binaural_decoder = timed_call( + timings, "create_binaural_renderer", + RosellaBinauralRenderer, + hrtf_source["path"], + mode=binaural_render_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) + else: + binaural_decoder = None + if args.backend in ("auto", "native"): + try: + from sofa_native_backend import ( + create_native_compiled_cache_renderer) + binaural_decoder = timed_call( + timings, "create_binaural_renderer", + create_native_compiled_cache_renderer, + hrtf_source["path"], + **common_options) + except (ImportError, OSError, RuntimeError, ValueError) as exc: + print( + f"[binaural] native SOFA backend unavailable " + f"({exc.__class__.__name__}: {exc}); " + f"falling back to Python", flush=True) + binaural_decoder = None + if binaural_decoder is None: + binaural_decoder = timed_call( + timings, "create_binaural_renderer", + SofaBinauralRenderer.from_compiled_cache, + hrtf_source["path"], + **common_options) + print( + f"[binaural] mode={binaural_render_mode} " + f"backend={binaural_decoder.dsp_backend} " + f"precision=float64/complex128 " + f"hrtf={hrtf_source['kind']}:{hrtf_source['path']}", flush=True) + if hrtf_source["kind"] == "rosella": + flush_samples = math.ceil( + (args.binaural_tail_seconds * RATE + + ROSSELLA_LATENCY_SAMPLES + ROSSELLA_BLOCK_SAMPLES) + / ROSSELLA_BLOCK_SAMPLES) * ROSSELLA_BLOCK_SAMPLES + spool_capacity = frame_count * FRAME_SAMPLES + flush_samples + else: + spool_capacity = ( + frame_count * FRAME_SAMPLES + + binaural_decoder.finish_capacity_samples) + spool = BinauralPcmSpool( + temp_dir / "binaural_interleaved_f64.raw", + spool_capacity, + 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 + if hrtf_source["kind"] == "rosella": + binaural_hrtf_report = { + "input_kind": "rosella", + "input_path": str(binaural_decoder.model_path.resolve()), + "model_coefficient_sha256": ( + binaural_decoder.model.coefficient_sha256), + "cache_policy": None, + } + else: + binaural_hrtf_report = { + "input_kind": binaural_backend_info["hrtf_input_kind"], + "input_path": binaural_backend_info["hrtf_input_path"], + "source_sha256": ( + binaural_backend_info["field"]["source_sha256"]), + "cache_policy": binaural_backend_info["cache_policy"], + "cache_key": ( + binaural_backend_info["field"]["cache_key"]), + "format_version": ( + binaural_backend_info["field"]["format_version"]), + } + finally: + spool.close() + timings["build_adm_tracks"] = 0.0 + timings["finalize_adm"] = 0.0 + timings["validate_adm"] = 0.0 + info = (f"binaural mode={binaural_render_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]) + joc_binaural_mode=adm_atmos.JOC_BINAURAL_MODES[args.binaural_mode]) try: render_seconds, renderer_backend, render_breakdown = timed_call( timings, "render_and_stream", variant_call, @@ -469,10 +843,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 +855,12 @@ 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, + "binaural_mode_value": ( + adm_atmos.JOC_BINAURAL_MODES[args.binaural_mode] + if mode_name == "adm" else None), "render_seconds": render_seconds, "render_breakdown": render_breakdown, "renderer_backend": renderer_backend, @@ -493,11 +869,20 @@ 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 or mode_name == "adm") else None), + "binaural_hrtf": ( + binaural_hrtf_report 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 +900,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 " diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 73eeff8..107d5ae 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -8,6 +8,8 @@ find_package(Threads REQUIRED) add_library(eac3joc_core SHARED src/eac3joc_core.cpp src/speaker_renderer.cpp + src/binaural_renderer.cpp + src/sofa_binaural_renderer.cpp src/joc_huffman_tables.h src/qmf_tables.h src/speaker_layouts.h diff --git a/native/include/eac3joc_core.h b/native/include/eac3joc_core.h index 10caecc..4bf7953 100644 --- a/native/include/eac3joc_core.h +++ b/native/include/eac3joc_core.h @@ -29,11 +29,17 @@ enum { EJOC_MAX_DPOINTS = 2, EJOC_MAX_PARAMETER_BANDS = 23, EJOC_SPEAKER_BLOCK_SAMPLES = 32, - EJOC_SPEAKER_COORDINATES = 3 + EJOC_SPEAKER_COORDINATES = 3, + EJOC_BINAURAL_BLOCK_SAMPLES = 512, + EJOC_BINAURAL_INPUT_CHANNELS = 16, + EJOC_BINAURAL_OUTPUT_CHANNELS = 2, + EJOC_BINAURAL_QMF_BANDS = 64, + EJOC_BINAURAL_HYBRID_BANDS = 77 }; typedef void* ejoc_renderer_handle; typedef void* ejoc_speaker_renderer_handle; +typedef void* ejoc_binaural_renderer_handle; /* Fixed array layouts used by ejoc_renderer_process(): @@ -114,6 +120,115 @@ EJOC_API int EJOC_CALL ejoc_speaker_renderer_process( const double* object_gains, double* output_interleaved); +EJOC_API ejoc_binaural_renderer_handle EJOC_CALL ejoc_binaural_renderer_create(void); +EJOC_API void EJOC_CALL ejoc_binaural_renderer_destroy(ejoc_binaural_renderer_handle handle); +EJOC_API int EJOC_CALL ejoc_binaural_renderer_reset(ejoc_binaural_renderer_handle handle); +EJOC_API const char* EJOC_CALL ejoc_binaural_renderer_last_error( + ejoc_binaural_renderer_handle handle); +EJOC_API int EJOC_CALL ejoc_binaural_renderer_configure_kernels( + ejoc_binaural_renderer_handle handle, + const double* qmf_analysis, + const double* hybrid_low, + const int16_t* hybrid_indices, + const double* hybrid_values, + uint32_t hybrid_count, + const double* qmf_basis, + const double* qmf_taps); +EJOC_API int EJOC_CALL ejoc_binaural_renderer_configure_room( + ejoc_binaural_renderer_handle handle, + uint32_t bands, + uint32_t allpass_count, + const uint32_t* allpass_delays, + const double* allpass_gains, + const uint32_t* fdn_delays, + const double* fdn_matrix, + uint32_t output_tap_delay, + const double* feedback_complex, + const double* output_taps, + const double* output_complex, + uint32_t extra_count, + const uint32_t* extra_delays, + const double* extra_fields_complex, + const double* extra_matrices); +EJOC_API int EJOC_CALL ejoc_binaural_renderer_process( + ejoc_binaural_renderer_handle handle, + const double* input16_interleaved, + const double* gains_complex, + const double* room_sends, + double output_gain, + double* output_stereo_interleaved); + +/* +Native SOFA binaural renderer. + +The handle owns the complete runtime: 64-QMF/77-hybrid analysis and synthesis, +fifth-order ACN/N3D real spherical-harmonic direction-field evaluation, +per-object whole-QMF-slot delay histories, six first-order image-source early +reflections, the shared unitary-FDN late room, the 120-180 Hz LFE low-pass and +the 961-sample latency compensation. The caller configures the filterbank +tables, the compiled HRTF field and the room constants once, then per 512-sample +block updates every source with ejoc_sofa_binaural_set_source() and calls +ejoc_sofa_binaural_process(). Process returns the number of trimmed stereo +samples written; the first 961 processed samples across calls are discarded. +*/ +typedef void* ejoc_sofa_binaural_handle; + +EJOC_API ejoc_sofa_binaural_handle EJOC_CALL ejoc_sofa_binaural_create(void); +EJOC_API void EJOC_CALL ejoc_sofa_binaural_destroy(ejoc_sofa_binaural_handle handle); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_reset(ejoc_sofa_binaural_handle handle); +EJOC_API const char* EJOC_CALL ejoc_sofa_binaural_last_error( + ejoc_sofa_binaural_handle handle); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_configure_kernels( + ejoc_sofa_binaural_handle handle, + const double* qmf_analysis, + const double* hybrid_low, + const int16_t* hybrid_indices, + const double* hybrid_values, + uint32_t hybrid_count, + const double* qmf_basis, + const double* qmf_taps); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_configure_field( + ejoc_sofa_binaural_handle handle, + const double* coefficients, + const double* delay_coefficients, + const double* delay_bounds, + const double* band_centers, + double measurement_radius_m); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_configure_room( + ejoc_sofa_binaural_handle handle, + const double* room_dims, + const double* listener_pos, + const double* wall_gains, + double speed_of_sound, + const uint32_t* fdn_delays, + const double* fdn_feedback, + double damping, + double fdn_output_gain, + const uint32_t* allpass_delays, + const double* allpass_gains, + uint32_t enable_early_reflections, + uint32_t enable_late_room); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_set_source( + ejoc_sofa_binaural_handle handle, + uint32_t source, + const double* position_adm, + uint32_t profile, + double gain, + uint32_t enabled, + uint32_t special_lfe, + uint32_t fade); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_process( + ejoc_sofa_binaural_handle handle, + const double* input16_interleaved, + uint32_t sample_count, + double output_gain, + double* output_stereo_interleaved); +EJOC_API int EJOC_CALL ejoc_sofa_binaural_finish( + ejoc_sofa_binaural_handle handle, + uint32_t flush_samples, + double* output_stereo_interleaved, + uint32_t capacity); + #ifdef __cplusplus } #endif diff --git a/native/src/binaural_renderer.cpp b/native/src/binaural_renderer.cpp new file mode 100644 index 0000000..1456d2a --- /dev/null +++ b/native/src/binaural_renderer.cpp @@ -0,0 +1,664 @@ +#define EJOC_BUILD_DLL +#include "eac3joc_core.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ejoc::binaural { + +struct Complex { + double re; + double im; +}; + +inline Complex add(Complex a, Complex b) noexcept { + return {a.re + b.re, a.im + b.im}; +} + +inline Complex mul(Complex a, Complex b) noexcept { + return {a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re}; +} + +inline Complex scale(Complex value, double gain) noexcept { + return {value.re * gain, value.im * gain}; +} + +constexpr double kPi = 3.141592653589793238462643383279502884; +constexpr int kChannels = EJOC_BINAURAL_INPUT_CHANNELS; +constexpr int kEars = EJOC_BINAURAL_OUTPUT_CHANNELS; +constexpr int kBlock = EJOC_BINAURAL_BLOCK_SAMPLES; +constexpr int kSlots = kBlock / 64; +constexpr int kQmf = EJOC_BINAURAL_QMF_BANDS; +constexpr int kHybrid = EJOC_BINAURAL_HYBRID_BANDS; +constexpr int kRank = 4; + +class Renderer final { +public: + Renderer() noexcept { + initialize_fft(); + reset(); + } + + int configure_kernels( + const double* qmf_analysis, + const double* hybrid_low, + const int16_t* hybrid_indices, + const double* hybrid_values, + uint32_t hybrid_count, + const double* qmf_basis, + const double* qmf_taps) noexcept { + if (!qmf_analysis || !hybrid_low || !hybrid_indices || !hybrid_values || + !qmf_basis || !qmf_taps || hybrid_count == 0) { + return fail("invalid binaural kernel configuration"); + } + std::memcpy(qmf_analysis_.data(), qmf_analysis, + qmf_analysis_.size() * sizeof(double)); + hybrid_low_.assign(hybrid_low, hybrid_low + 3 * 2 * 13 * 16 * 2); + hybrid_indices_.assign(hybrid_indices, hybrid_indices + hybrid_count * 4); + hybrid_values_.assign(hybrid_values, hybrid_values + hybrid_count); + std::memcpy(qmf_basis_.data(), qmf_basis, + qmf_basis_.size() * sizeof(double)); + std::memcpy(qmf_taps_.data(), qmf_taps, + qmf_taps_.size() * sizeof(double)); + kernels_ready_ = true; + reset(); + return 0; + } + + int configure_room( + uint32_t bands, + uint32_t allpass_count, + const uint32_t* allpass_delays, + const double* allpass_gains, + const uint32_t* fdn_delays, + const double* fdn_matrix, + uint32_t output_tap_delay, + const double* feedback_complex, + const double* output_taps, + const double* output_complex, + uint32_t extra_count, + const uint32_t* extra_delays, + const double* extra_fields_complex, + const double* extra_matrices) noexcept { + if (bands != 64 || !fdn_delays || !fdn_matrix || !feedback_complex || + !output_taps || !output_complex || + (allpass_count && (!allpass_delays || !allpass_gains)) || + (extra_count && (!extra_delays || !extra_fields_complex || !extra_matrices))) { + return fail("invalid binaural room configuration"); + } + room_bands_ = bands; + if (allpass_count) { + allpass_delays_.assign(allpass_delays, allpass_delays + allpass_count); + allpass_gains_.assign(allpass_gains, allpass_gains + allpass_count); + } else { + allpass_delays_.clear(); + allpass_gains_.clear(); + } + allpass_offsets_.resize(allpass_count); + allpass_positions_.assign(allpass_count, 0); + size_t allpass_size = 0; + for (uint32_t index = 0; index < allpass_count; ++index) { + if (allpass_delays_[index] == 0) { + return fail("binaural allpass delay must be positive"); + } + allpass_offsets_[index] = allpass_size; + allpass_size += static_cast(allpass_delays_[index]) * bands; + } + allpass_memory_.assign(allpass_size, {}); + + room_capacity_ = 0; + for (int branch = 0; branch < 4; ++branch) { + fdn_delays_[branch] = fdn_delays[branch]; + room_capacity_ = std::max(room_capacity_, fdn_delays_[branch]); + } + if (room_capacity_ == 0) { + return fail("binaural room delay must be positive"); + } + std::copy(fdn_matrix, fdn_matrix + 16, fdn_matrix_.begin()); + output_tap_delay_ = output_tap_delay; + for (int band = 0; band < 64; ++band) { + for (int branch = 0; branch < 4; ++branch) { + const size_t complex_index = (static_cast(band) * 4 + branch) * 2; + feedback_[band][branch] = { + feedback_complex[complex_index], feedback_complex[complex_index + 1]}; + output_taps_[band][branch] = output_taps[band * 4 + branch]; + for (int ear = 0; ear < 2; ++ear) { + const size_t output_index = + ((static_cast(ear) * 64 + band) * 4 + branch) * 2; + output_matrix_[ear][band][branch] = { + output_complex[output_index], output_complex[output_index + 1]}; + } + } + } + room_memory_.assign(static_cast(room_capacity_) * 64 * 4, {}); + if (extra_count) { + extra_delays_.assign(extra_delays, extra_delays + extra_count); + } else { + extra_delays_.clear(); + } + extra_fields_.resize(static_cast(extra_count) * 64); + extra_matrices_.resize(static_cast(extra_count) * 16); + for (uint32_t extra = 0; extra < extra_count; ++extra) { + for (int band = 0; band < 64; ++band) { + const size_t source = (static_cast(extra) * 64 + band) * 2; + extra_fields_[static_cast(extra) * 64 + band] = { + extra_fields_complex[source], extra_fields_complex[source + 1]}; + } + std::copy(extra_matrices + static_cast(extra) * 16, + extra_matrices + static_cast(extra + 1) * 16, + extra_matrices_.begin() + static_cast(extra) * 16); + } + room_ready_ = true; + reset(); + return 0; + } + + int reset() noexcept { + qmf_history_.fill(0.0); + hybrid_low_history_.fill({}); + hybrid_high_history_.fill({}); + synthesis_history_.fill(0.0); + std::fill(allpass_memory_.begin(), allpass_memory_.end(), Complex{}); + std::fill(allpass_positions_.begin(), allpass_positions_.end(), 0u); + std::fill(room_memory_.begin(), room_memory_.end(), Complex{}); + room_position_ = 0; + error_[0] = '\0'; + return 0; + } + + const char* error() const noexcept { + return error_[0] ? error_ : ""; + } + + int process( + const double* input, + const double* gains, + const double* room_sends, + double output_gain, + double* output) noexcept { + if (!kernels_ready_ || !room_ready_) { + return fail("binaural renderer is not configured"); + } + if (!input || !gains || !room_sends || !output || !std::isfinite(output_gain)) { + return fail("invalid binaural process arguments"); + } + for (int slot = 0; slot < kSlots; ++slot) { + std::array qmf{}; + std::array hybrid{}; + analyze_qmf(input + static_cast(slot) * 64 * kChannels, qmf); + analyze_hybrid(qmf, hybrid); + + std::array rendered{}; + std::array room_input{}; + for (int source = kChannels - 1; source >= 0; --source) { + for (int band = 0; band < kHybrid; ++band) { + const Complex value = hybrid[source * kHybrid + band]; + room_input[band] = add(room_input[band], scale(value, room_sends[source])); + for (int ear = 0; ear < kEars; ++ear) { + const size_t gain_index = + (((static_cast(source) * kEars + ear) * kHybrid + band) * 2); + const Complex gain{gains[gain_index], gains[gain_index + 1]}; + rendered[ear * kHybrid + band] = add( + rendered[ear * kHybrid + band], mul(value, gain)); + } + } + } + const auto room = process_room(room_input); + for (size_t index = 0; index < rendered.size(); ++index) { + rendered[index] = add(rendered[index], room[index]); + } + + std::array qmf_output{}; + synthesize_hybrid(rendered, qmf_output); + for (int ear = 0; ear < kEars; ++ear) { + std::array samples{}; + synthesize_qmf(qmf_output.data() + ear * kQmf, ear, samples); + for (int sample = 0; sample < 64; ++sample) { + output[(static_cast(slot) * 64 + sample) * 2 + ear] = + samples[sample] * output_gain; + } + } + } + return 0; + } + +private: + int fail(const char* message) noexcept { + std::snprintf(error_, sizeof(error_), "%s", message); + return -1; + } + + void initialize_fft() noexcept { + for (int index = 0; index < 128; ++index) { + int value = index; + int reversed = 0; + for (int bit = 0; bit < 7; ++bit) { + reversed = (reversed << 1) | (value & 1); + value >>= 1; + } + bit_reverse_[index] = static_cast(reversed); + } + for (int phase = 0; phase < 64; ++phase) { + const double angle = -kPi * static_cast(phase) / 128.0; + premod_[phase] = {std::cos(angle), std::sin(angle)}; + const double post_angle = + -3.0 * (static_cast(phase) + 0.5) * kPi / 128.0; + post_[phase] = {std::cos(post_angle), std::sin(post_angle)}; + even_post_[phase] = {0.0, (phase & 1) ? -1.0 : 1.0}; + } + } + + void fft128(std::array& values) const noexcept { + for (int index = 0; index < 128; ++index) { + const int reversed = bit_reverse_[index]; + if (reversed > index) { + std::swap(values[index], values[reversed]); + } + } + for (int length = 2; length <= 128; length <<= 1) { + const double angle = -2.0 * kPi / static_cast(length); + const Complex step{std::cos(angle), std::sin(angle)}; + for (int start = 0; start < 128; start += length) { + Complex rotation{1.0, 0.0}; + for (int offset = 0; offset < length / 2; ++offset) { + const Complex even = values[start + offset]; + const Complex odd = mul(values[start + offset + length / 2], rotation); + values[start + offset] = {even.re + odd.re, even.im + odd.im}; + values[start + offset + length / 2] = { + even.re - odd.re, even.im - odd.im}; + rotation = mul(rotation, step); + } + } + } + } + + void qmf_transform(const std::array& source, + std::array& target) const noexcept { + std::array work{}; + for (int phase = 0; phase < 64; ++phase) { + work[phase] = scale(premod_[phase], source[phase]); + } + fft128(work); + for (int band = 0; band < 64; ++band) { + target[band] = mul(work[band], post_[band]); + } + } + + void analyze_qmf(const double* input, + std::array& output) noexcept { + for (int channel = 0; channel < kChannels; ++channel) { + for (int lag = 9; lag > 0; --lag) { + for (int phase = 0; phase < 64; ++phase) { + qmf_history_[qmf_history_index(lag, channel, phase)] = + qmf_history_[qmf_history_index(lag - 1, channel, phase)]; + } + } + for (int phase = 0; phase < 64; ++phase) { + qmf_history_[qmf_history_index(0, channel, phase)] = + input[phase * kChannels + channel]; + } + std::array even{}; + std::array odd{}; + for (int phase = 0; phase < 64; ++phase) { + for (int lag = 0; lag < 10; ++lag) { + const double value = + qmf_history_[qmf_history_index(lag, channel, phase)] * + qmf_analysis_[phase * 10 + lag]; + (lag & 1 ? odd[phase] : even[phase]) += value; + } + } + std::array even_fft{}; + std::array odd_fft{}; + qmf_transform(even, even_fft); + qmf_transform(odd, odd_fft); + for (int band = 0; band < 64; ++band) { + output[channel * 64 + band] = add( + odd_fft[band], mul(even_fft[band], even_post_[band])); + } + } + } + + void analyze_hybrid( + const std::array& qmf, + std::array& output) noexcept { + for (int channel = 0; channel < kChannels; ++channel) { + for (int lag = 12; lag > 0; --lag) { + for (int band = 0; band < 3; ++band) { + hybrid_low_history_[hybrid_low_history_index(lag, channel, band)] = + hybrid_low_history_[hybrid_low_history_index(lag - 1, channel, band)]; + } + } + for (int band = 0; band < 3; ++band) { + hybrid_low_history_[hybrid_low_history_index(0, channel, band)] = + qmf[channel * 64 + band]; + } + for (int output_band = 0; output_band < 16; ++output_band) { + Complex value{}; + for (int lag = 0; lag < 13; ++lag) { + for (int input_band = 0; input_band < 3; ++input_band) { + const Complex source = hybrid_low_history_[ + hybrid_low_history_index(lag, channel, input_band)]; + const double components[2]{source.re, source.im}; + for (int input_component = 0; input_component < 2; ++input_component) { + value.re += components[input_component] * hybrid_low_[ + hybrid_low_kernel_index(input_band, input_component, lag, + output_band, 0)]; + value.im += components[input_component] * hybrid_low_[ + hybrid_low_kernel_index(input_band, input_component, lag, + output_band, 1)]; + } + } + } + output[channel * kHybrid + output_band] = value; + } + for (int band = 0; band < 61; ++band) { + output[channel * kHybrid + 16 + band] = + hybrid_high_history_[hybrid_high_history_index(0, channel, band)]; + for (int delay = 0; delay < 5; ++delay) { + hybrid_high_history_[hybrid_high_history_index(delay, channel, band)] = + hybrid_high_history_[hybrid_high_history_index(delay + 1, channel, band)]; + } + hybrid_high_history_[hybrid_high_history_index(5, channel, band)] = + qmf[channel * 64 + 3 + band]; + } + } + } + + std::array process_room( + const std::array& input) noexcept { + std::array filtered{}; + for (int band = 0; band < 64; ++band) { + filtered[band] = scale(input[band], 0.70710677); + } + for (size_t stage = 0; stage < allpass_delays_.size(); ++stage) { + const uint32_t position = allpass_positions_[stage]; + const double gain = allpass_gains_[stage]; + for (int band = 0; band < 64; ++band) { + Complex& memory = allpass_memory_[ + allpass_offsets_[stage] + static_cast(position) * 64 + band]; + const Complex residual = add(filtered[band], scale(memory, -gain)); + filtered[band] = add(scale(residual, gain), memory); + memory = residual; + } + allpass_positions_[stage] = (position + 1) % allpass_delays_[stage]; + } + + std::array branches{}; + std::array taps{}; + for (int band = 0; band < 64; ++band) { + for (int branch = 0; branch < 4; ++branch) { + Complex value = filtered[band]; + for (int source = 0; source < 4; ++source) { + const uint32_t position = + (room_position_ + room_capacity_ - fdn_delays_[source]) % room_capacity_; + value = add(value, scale(room_memory_[ + room_memory_index(position, band, source)], + fdn_matrix_[branch * 4 + source])); + } + branches[band * 4 + branch] = value; + const uint32_t tap_position = + (room_position_ + room_capacity_ - + (output_tap_delay_ % room_capacity_)) % room_capacity_; + taps[band * 4 + branch] = + room_memory_[room_memory_index(tap_position, band, branch)]; + } + } + for (int band = 0; band < 64; ++band) { + for (int branch = 0; branch < 4; ++branch) { + room_memory_[room_memory_index(room_position_, band, branch)] = + mul(branches[band * 4 + branch], feedback_[band][branch]); + } + } + room_position_ = (room_position_ + 1) % room_capacity_; + + std::array extra{}; + for (size_t index = 0; index < extra_delays_.size(); ++index) { + const uint32_t position = + (room_position_ + room_capacity_ - + ((extra_delays_[index] + 1) % room_capacity_)) % room_capacity_; + for (int band = 0; band < 64; ++band) { + for (int target = 0; target < 4; ++target) { + Complex mixed{}; + for (int source = 0; source < 4; ++source) { + mixed = add(mixed, scale(room_memory_[ + room_memory_index(position, band, source)], + extra_matrices_[index * 16 + target * 4 + source])); + } + extra[band * 4 + target] = add( + extra[band * 4 + target], + mul(mixed, extra_fields_[index * 64 + band])); + } + } + } + + std::array output{}; + for (int ear = 0; ear < 2; ++ear) { + for (int band = 0; band < 64; ++band) { + Complex value{}; + for (int branch = 0; branch < 4; ++branch) { + const Complex signal = add( + scale(taps[band * 4 + branch], output_taps_[band][branch]), + extra[band * 4 + branch]); + value = add(value, mul( + signal, output_matrix_[ear][band][branch])); + } + output[ear * kHybrid + band] = value; + } + } + return output; + } + + void synthesize_hybrid( + const std::array& input, + std::array& output) const noexcept { + for (size_t mapping = 0; mapping < hybrid_values_.size(); ++mapping) { + const int16_t* index = hybrid_indices_.data() + mapping * 4; + const int input_band = index[0]; + const int input_component = index[1]; + const int output_band = index[2]; + const int output_component = index[3]; + const double gain = hybrid_values_[mapping]; + for (int ear = 0; ear < 2; ++ear) { + const Complex source = input[ear * kHybrid + input_band]; + Complex& target = output[ear * kQmf + output_band]; + const double component = input_component == 0 ? source.re : source.im; + (output_component == 0 ? target.re : target.im) += component * gain; + } + } + } + + void synthesize_qmf(const Complex* input, int ear, + std::array& output) noexcept { + std::array features{}; + std::array flat{}; + for (int band = 0; band < 64; ++band) { + flat[band * 2] = input[band].re; + flat[band * 2 + 1] = input[band].im; + } + for (int phase = 0; phase < 64; ++phase) { + for (int rank = 0; rank < kRank; ++rank) { + double value = 0.0; + const size_t base = (static_cast(phase) * kRank + rank) * 128; + for (int component = 0; component < 128; ++component) { + value += flat[component] * qmf_basis_[base + component]; + } + features[phase * kRank + rank] = value; + } + } + for (int phase = 0; phase < 64; ++phase) { + double value = 0.0; + for (int lag = 0; lag < 10; ++lag) { + for (int rank = 0; rank < kRank; ++rank) { + const double feature = lag == 0 + ? features[phase * kRank + rank] + : synthesis_history_[synthesis_history_index( + ear, lag - 1, phase, rank)]; + value += feature * qmf_taps_[ + ((static_cast(phase) * 10 + lag) * kRank + rank)]; + } + } + output[phase] = value; + } + for (int lag = 8; lag > 0; --lag) { + for (int phase = 0; phase < 64; ++phase) { + for (int rank = 0; rank < kRank; ++rank) { + synthesis_history_[synthesis_history_index(ear, lag, phase, rank)] = + synthesis_history_[synthesis_history_index( + ear, lag - 1, phase, rank)]; + } + } + } + for (int phase = 0; phase < 64; ++phase) { + for (int rank = 0; rank < kRank; ++rank) { + synthesis_history_[synthesis_history_index(ear, 0, phase, rank)] = + features[phase * kRank + rank]; + } + } + } + + static size_t qmf_history_index(int lag, int channel, int phase) noexcept { + return (static_cast(lag) * kChannels + channel) * 64 + phase; + } + + static size_t hybrid_low_history_index(int lag, int channel, int band) noexcept { + return (static_cast(lag) * kChannels + channel) * 3 + band; + } + + static size_t hybrid_high_history_index(int delay, int channel, int band) noexcept { + return (static_cast(delay) * kChannels + channel) * 61 + band; + } + + static size_t hybrid_low_kernel_index( + int input_band, int input_component, int lag, + int output_band, int output_component) noexcept { + return (((static_cast(input_band) * 2 + input_component) * 13 + lag) * + 16 + output_band) * 2 + output_component; + } + + size_t room_memory_index(uint32_t position, int band, int branch) const noexcept { + return (static_cast(position) * 64 + band) * 4 + branch; + } + + static size_t synthesis_history_index( + int ear, int lag, int phase, int rank) noexcept { + return (((static_cast(ear) * 9 + lag) * 64 + phase) * kRank + rank); + } + + bool kernels_ready_ = false; + bool room_ready_ = false; + std::array qmf_analysis_{}; + std::vector hybrid_low_; + std::vector hybrid_indices_; + std::vector hybrid_values_; + std::array qmf_basis_{}; + std::array qmf_taps_{}; + + std::array qmf_history_{}; + std::array hybrid_low_history_{}; + std::array hybrid_high_history_{}; + std::array synthesis_history_{}; + + uint32_t room_bands_ = 0; + std::vector allpass_delays_; + std::vector allpass_gains_; + std::vector allpass_offsets_; + std::vector allpass_positions_; + std::vector allpass_memory_; + std::array fdn_delays_{}; + std::array fdn_matrix_{}; + uint32_t room_capacity_ = 0; + uint32_t output_tap_delay_ = 0; + std::array, 64> feedback_{}; + std::array, 64> output_taps_{}; + std::array, 64>, 2> output_matrix_{}; + std::vector room_memory_; + uint32_t room_position_ = 0; + std::vector extra_delays_; + std::vector extra_fields_; + std::vector extra_matrices_; + + std::array bit_reverse_{}; + std::array premod_{}; + std::array post_{}; + std::array even_post_{}; + char error_[256]{}; +}; + +} // namespace ejoc::binaural + +extern "C" { + +ejoc_binaural_renderer_handle EJOC_CALL ejoc_binaural_renderer_create(void) { + return new (std::nothrow) ejoc::binaural::Renderer(); +} + +void EJOC_CALL ejoc_binaural_renderer_destroy(ejoc_binaural_renderer_handle handle) { + delete static_cast(handle); +} + +int EJOC_CALL ejoc_binaural_renderer_reset(ejoc_binaural_renderer_handle handle) { + return handle ? static_cast(handle)->reset() : -1; +} + +const char* EJOC_CALL ejoc_binaural_renderer_last_error( + ejoc_binaural_renderer_handle handle) { + return handle ? static_cast(handle)->error() + : "null binaural renderer handle"; +} + +int EJOC_CALL ejoc_binaural_renderer_configure_kernels( + ejoc_binaural_renderer_handle handle, + const double* qmf_analysis, + const double* hybrid_low, + const int16_t* hybrid_indices, + const double* hybrid_values, + uint32_t hybrid_count, + const double* qmf_basis, + const double* qmf_taps) { + return handle ? static_cast(handle)->configure_kernels( + qmf_analysis, hybrid_low, hybrid_indices, hybrid_values, + hybrid_count, qmf_basis, qmf_taps) : -1; +} + +int EJOC_CALL ejoc_binaural_renderer_configure_room( + ejoc_binaural_renderer_handle handle, + uint32_t bands, + uint32_t allpass_count, + const uint32_t* allpass_delays, + const double* allpass_gains, + const uint32_t* fdn_delays, + const double* fdn_matrix, + uint32_t output_tap_delay, + const double* feedback_complex, + const double* output_taps, + const double* output_complex, + uint32_t extra_count, + const uint32_t* extra_delays, + const double* extra_fields_complex, + const double* extra_matrices) { + return handle ? static_cast(handle)->configure_room( + bands, allpass_count, allpass_delays, allpass_gains, + fdn_delays, fdn_matrix, output_tap_delay, + feedback_complex, output_taps, output_complex, + extra_count, extra_delays, extra_fields_complex, extra_matrices) : -1; +} + +int EJOC_CALL ejoc_binaural_renderer_process( + ejoc_binaural_renderer_handle handle, + const double* input16_interleaved, + const double* gains_complex, + const double* room_sends, + double output_gain, + double* output_stereo_interleaved) { + return handle ? static_cast(handle)->process( + input16_interleaved, gains_complex, room_sends, + output_gain, output_stereo_interleaved) : -1; +} + +} // extern "C" diff --git a/native/src/eac3joc_core.cpp b/native/src/eac3joc_core.cpp index fb3712d..9ea832d 100644 --- a/native/src/eac3joc_core.cpp +++ b/native/src/eac3joc_core.cpp @@ -664,13 +664,13 @@ uint32_t EJOC_CALL ejoc_abi_version(void) { const char* EJOC_CALL ejoc_build_info(void) { #if defined(_MSC_VER) - return "eac3joc-core abi=1 compiler=MSVC fft=fixed64 speaker=double crt=static-by-build"; + return "eac3joc-core abi=1 compiler=MSVC fft=fixed64 speaker=double binaural=double crt=static-by-build"; #elif defined(__clang__) - return "eac3joc-core abi=1 compiler=Clang fft=fixed64 speaker=double"; + return "eac3joc-core abi=1 compiler=Clang fft=fixed64 speaker=double binaural=double"; #elif defined(__GNUC__) - return "eac3joc-core abi=1 compiler=GCC fft=fixed64 speaker=double"; + return "eac3joc-core abi=1 compiler=GCC fft=fixed64 speaker=double binaural=double"; #else - return "eac3joc-core abi=1 compiler=unknown fft=fixed64 speaker=double"; + return "eac3joc-core abi=1 compiler=unknown fft=fixed64 speaker=double binaural=double"; #endif } diff --git a/native/src/sofa_binaural_renderer.cpp b/native/src/sofa_binaural_renderer.cpp new file mode 100644 index 0000000..cbf699b --- /dev/null +++ b/native/src/sofa_binaural_renderer.cpp @@ -0,0 +1,1231 @@ +#define EJOC_BUILD_DLL +#include "eac3joc_core.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ejoc::sofa_binaural { + +constexpr double kPi = 3.141592653589793238462643383279502884; +constexpr int kChannels = EJOC_BINAURAL_INPUT_CHANNELS; +constexpr int kHop = 64; +constexpr int kQmf = EJOC_BINAURAL_QMF_BANDS; +constexpr int kHybrid = EJOC_BINAURAL_HYBRID_BANDS; +constexpr int kRank = 4; +constexpr int kLatency = 961; +constexpr int kOrder = 5; +constexpr int kTerms = (kOrder + 1) * (kOrder + 1); +constexpr int kEarlyHistory = 256; +constexpr int kTransitionSlots = 8; +constexpr double kSampleRate = 48000.0; +constexpr int kFftSize = 128; +constexpr int kMaxBlockSamples = 512; + +struct Complex { + double re; + double im; +}; + +inline Complex add(Complex a, Complex b) noexcept { + return {a.re + b.re, a.im + b.im}; +} + +inline Complex mul(Complex a, Complex b) noexcept { + return {a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re}; +} + +inline Complex mulr(Complex a, double b) noexcept { + return {a.re * b, a.im * b}; +} + +inline Complex addmul(Complex acc, Complex b, Complex c) noexcept { + return {acc.re + b.re * c.re - b.im * c.im, acc.im + b.re * c.im + b.im * c.re}; +} + +// --------------------------------------------------------------------------- +// 128-point FFT, forward: X[k] = sum_p x[p] exp(-2j pi k p / N) (radix-2 DIT) +// --------------------------------------------------------------------------- +class Fft128 final { +public: + Fft128() noexcept { + for (int k = 0; k < kFftSize; ++k) { + const double angle = -2.0 * kPi * k / kFftSize; + twiddle_[k] = {std::cos(angle), std::sin(angle)}; + } + } + + void forward(const Complex* input, Complex* output) const noexcept { + // bit-reversal permutation for the 7-bit index (DIT) + for (int index = 0; index < kFftSize; ++index) { + unsigned int reversed = 0; + for (int bit = 0; bit < 7; ++bit) { + reversed = (reversed << 1) + | ((static_cast(index) >> bit) & 1u); + } + output[static_cast(reversed)] = input[index]; + } + for (int size = 2; size <= kFftSize; size <<= 1) { + const int half = size >> 1; + const int step = kFftSize / size; + for (int base = 0; base < kFftSize; base += size) { + for (int offset = 0; offset < half; ++offset) { + const Complex w = twiddle_[offset * step]; + const Complex even = output[base + offset]; + const Complex odd = mul(output[base + offset + half], w); + output[base + offset] = add(even, odd); + output[base + offset + half] = { + even.re - odd.re, even.im - odd.im}; + } + } + } + } + +private: + Complex twiddle_[kFftSize]; +}; + +// --------------------------------------------------------------------------- +// QMF analysis (mirrors QmfAnalysis: joined history, lag 0..9, modulations) +// --------------------------------------------------------------------------- +class QmfAnalysis final { +public: + void configure(const double* coefficients) noexcept { + std::memcpy(coeff_, coefficients, sizeof(coeff_)); + } + + void reset() noexcept { + std::memset(history_, 0, sizeof(history_)); + } + + // input: interleaved [samples][16]; output: [slots][16][64] complex + void process(const double* input, int slots, Complex* output) noexcept { + // joined[9 + slots][16][64] + for (int lag = 0; lag < 9; ++lag) { + std::memcpy(joined_[lag], history_[lag], sizeof(joined_[0])); + } + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < kChannels; ++channel) { + for (int p = 0; p < 64; ++p) { + joined_[9 + slot][channel][p] = + input[(static_cast(slot) * 64 + p) * kChannels + + channel]; + } + } + } + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < kChannels; ++channel) { + Complex even[kFftSize]; + Complex odd[kFftSize]; + for (int p = 0; p < kFftSize; ++p) { + even[p] = {0.0, 0.0}; + odd[p] = {0.0, 0.0}; + } + // 64 polyphase positions; the 128-point FFT zero-pads the rest + for (int p = 0; p < kQmf; ++p) { + double even_re = 0.0; + double odd_re = 0.0; + for (int lag = 0; lag < 5; ++lag) { + even_re += joined_[9 + slot - 2 * lag][channel][p] + * coeff_[p][2 * lag]; + } + for (int lag = 0; lag < 5; ++lag) { + odd_re += joined_[9 + slot - (2 * lag + 1)][channel][p] + * coeff_[p][2 * lag + 1]; + } + const double pre_angle = -kPi * p / kFftSize; + even[p] = {even_re * std::cos(pre_angle), + even_re * std::sin(pre_angle)}; + odd[p] = {odd_re * std::cos(pre_angle), odd_re * std::sin(pre_angle)}; + } + Complex even_fft[kFftSize]; + Complex odd_fft[kFftSize]; + fft_.forward(even, even_fft); + fft_.forward(odd, odd_fft); + Complex* band = output + + (static_cast(slot) * kChannels + channel) * kQmf; + for (int b = 0; b < kQmf; ++b) { + const double post_angle = -3.0 * (b + 0.5) * kPi / kFftSize; + const Complex post = {std::cos(post_angle), std::sin(post_angle)}; + const Complex even_post = {0.0, (b % 2 == 0) ? 1.0 : -1.0}; + // python: (odd_fft + even_fft * even_post) * post + band[b] = mul(post, add(odd_fft[b], + mul(even_fft[b], even_post))); + } + } + } + for (int lag = 0; lag < 9; ++lag) { + std::memcpy(history_[lag], joined_[slots + lag], sizeof(history_[0])); + } + } + +private: + double coeff_[kQmf][10]; + double history_[9][kChannels][64]; + double joined_[9 + kMaxBlockSamples / kHop][kChannels][64]; + Fft128 fft_; +}; + +// --------------------------------------------------------------------------- +// Hybrid analysis (mirrors HybridAnalysis: 13-tap low join, 6-slot high join) +// --------------------------------------------------------------------------- +class HybridAnalysis final { +public: + void configure(const double* low_kernel) noexcept { + std::memcpy(low_, low_kernel, sizeof(low_)); + } + + void reset() noexcept { + std::memset(history_, 0, sizeof(history_)); + std::memset(high_history_, 0, sizeof(high_history_)); + } + + // qmf: [slots][16][64] complex; output: [slots][16][77] complex + void process(const Complex* qmf, int slots, Complex* output) noexcept { + for (int lag = 0; lag < 12; ++lag) { + std::memcpy(low_joined_[lag], history_[lag], sizeof(low_joined_[0])); + } + for (int lag = 0; lag < 6; ++lag) { + std::memcpy(high_joined_[lag], high_history_[lag], + sizeof(high_joined_[0])); + } + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < kChannels; ++channel) { + const Complex* band = qmf + + (static_cast(slot) * kChannels + channel) * kQmf; + for (int parent = 0; parent < 3; ++parent) { + low_joined_[12 + slot][channel][parent][0] = band[parent].re; + low_joined_[12 + slot][channel][parent][1] = band[parent].im; + } + for (int b = 0; b < 61; ++b) { + high_joined_[6 + slot][channel][b] = band[3 + b]; + } + } + } + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < kChannels; ++channel) { + Complex* out = output + + (static_cast(slot) * kChannels + channel) * kHybrid; + for (int hb = 0; hb < 16; ++hb) { + Complex value = {0.0, 0.0}; + for (int parent = 0; parent < 3; ++parent) { + for (int comp = 0; comp < 2; ++comp) { + for (int lag = 0; lag < 13; ++lag) { + const double source = + low_joined_[12 + slot - lag][channel][parent][comp]; + value.re += source * low_[parent][comp][lag][hb][0]; + value.im += source * low_[parent][comp][lag][hb][1]; + } + } + } + out[hb] = value; + } + for (int b = 0; b < 61; ++b) { + out[16 + b] = high_joined_[slot][channel][b]; + } + } + } + for (int lag = 0; lag < 12; ++lag) { + std::memcpy(history_[lag], low_joined_[slots + lag], sizeof(history_[0])); + } + for (int lag = 0; lag < 6; ++lag) { + std::memcpy(high_history_[lag], high_joined_[slots + lag], + sizeof(high_history_[0])); + } + } + +private: + double low_[3][2][13][16][2]; + double history_[12][kChannels][3][2]; + double low_joined_[12 + kMaxBlockSamples / kHop][kChannels][3][2]; + Complex high_history_[6][kChannels][61]; + Complex high_joined_[6 + kMaxBlockSamples / kHop][kChannels][61]; +}; + +// --------------------------------------------------------------------------- +// Hybrid synthesis sparse map (mirrors HybridSynthesis) +// --------------------------------------------------------------------------- +struct SynthesisEntry { + int in_band; + int in_comp; + int out_band; + int out_comp; + double gain; +}; + +class HybridSynthesis final { +public: + void configure(const int16_t* indices, const double* values, + uint32_t count) noexcept { + entries_.clear(); + for (uint32_t i = 0; i < count; ++i) { + entries_.push_back({static_cast(indices[i * 4]), + static_cast(indices[i * 4 + 1]), + static_cast(indices[i * 4 + 2]), + static_cast(indices[i * 4 + 3]), + values[i]}); + } + } + + // hybrid: [slots][2][77]; output: [slots][2][64] + void process(const Complex* hybrid, int slots, Complex* output) noexcept { + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < 2; ++channel) { + const Complex* source = hybrid + + (static_cast(slot) * 2 + channel) * kHybrid; + Complex* target = output + + (static_cast(slot) * 2 + channel) * kQmf; + for (int b = 0; b < kQmf; ++b) { + target[b] = {0.0, 0.0}; + } + for (const auto& entry : entries_) { + const Complex value = source[entry.in_band]; + const double component = + (entry.in_comp == 0) ? value.re : value.im; + if (entry.out_comp == 0) { + target[entry.out_band].re += component * entry.gain; + } else { + target[entry.out_band].im += component * entry.gain; + } + } + } + } + } + +private: + std::vector entries_; +}; + +// --------------------------------------------------------------------------- +// QMF synthesis, rank-4 (mirrors QmfSynthesis: joined history, lag 0..9) +// --------------------------------------------------------------------------- +class QmfSynthesis final { +public: + void configure(const double* basis, const double* taps) noexcept { + std::memcpy(basis_, basis, sizeof(basis_)); + std::memcpy(taps_, taps, sizeof(taps_)); + } + + void reset() noexcept { + std::memset(history_, 0, sizeof(history_)); + } + + // qmf: [slots][2][64]; output: [slots][2][64] real + void process(const Complex* qmf, int slots, double* output) noexcept { + for (int lag = 0; lag < 9; ++lag) { + std::memcpy(joined_[lag], history_[lag], sizeof(joined_[0])); + } + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < 2; ++channel) { + const Complex* values = qmf + + (static_cast(slot) * 2 + channel) * kQmf; + double flat[kFftSize]; + for (int b = 0; b < kQmf; ++b) { + flat[2 * b] = values[b].re; + flat[2 * b + 1] = values[b].im; + } + for (int b = 0; b < kQmf; ++b) { + for (int r = 0; r < kRank; ++r) { + double value = 0.0; + for (int j = 0; j < kFftSize; ++j) { + value += flat[j] * basis_[b][r][j]; + } + joined_[9 + slot][channel][b][r] = value; + } + } + } + } + for (int slot = 0; slot < slots; ++slot) { + for (int channel = 0; channel < 2; ++channel) { + for (int b = 0; b < kQmf; ++b) { + double value = 0.0; + for (int lag = 0; lag < 10; ++lag) { + for (int r = 0; r < kRank; ++r) { + value += joined_[9 + slot - lag][channel][b][r] + * taps_[b][lag][r]; + } + } + output[(static_cast(slot) * 2 + channel) * 64 + b] + = value; + } + } + } + for (int lag = 0; lag < 9; ++lag) { + std::memcpy(history_[lag], joined_[slots + lag], sizeof(history_[0])); + } + } + +private: + double basis_[kQmf][kRank][kFftSize]; + double taps_[kQmf][10][kRank]; + double history_[9][2][kQmf][kRank]; + double joined_[9 + kMaxBlockSamples / kHop][2][kQmf][kRank]; +}; + +// --------------------------------------------------------------------------- +// Fifth-order ACN/N3D real spherical harmonics +// --------------------------------------------------------------------------- +class RealSh final { +public: + static void evaluate(const double* direction, double* basis) noexcept { + const double azimuth = std::atan2(direction[1], direction[0]); + const double x = std::max(-1.0, std::min(1.0, direction[2])); + int index = 0; + for (int degree = 0; degree <= kOrder; ++degree) { + for (int order = -degree; order <= degree; ++order) { + const int absolute = std::abs(order); + const double normalization = std::sqrt( + (2.0 * degree + 1.0) / (4.0 * kPi) + * factorial_ratio(degree, absolute)); + const double legendre = associated_legendre( + degree, absolute, x, pmm_value(absolute, x)); + if (order > 0) { + basis[index] = std::sqrt(2.0) * normalization * legendre + * std::cos(order * azimuth); + } else if (order < 0) { + basis[index] = std::sqrt(2.0) * normalization * legendre + * std::sin(absolute * azimuth); + } else { + basis[index] = normalization * legendre; + } + ++index; + } + } + } + +private: + static double factorial_ratio(int degree, int order) noexcept { + double ratio = 1.0; + for (int value = degree - order + 1; value <= degree + order; ++value) { + ratio /= static_cast(value); + } + return ratio; + } + + static double pmm_value(int m, double x) noexcept { + if (m == 0) { + return 1.0; + } + double double_factorial = 1.0; + for (int value = 1; value <= 2 * m - 1; value += 2) { + double_factorial *= static_cast(value); + } + double value = double_factorial + * std::pow(std::max(0.0, 1.0 - x * x), 0.5 * m); + if (m % 2 == 1) { + value = -value; + } + return value; + } + + static double associated_legendre(int degree, int order, double x, + double pmm) noexcept { + if (degree == order) { + return pmm; + } + double pm1 = pmm; + double pmm1 = (2.0 * order + 1.0) * x * pmm; + if (degree == order + 1) { + return pmm1; + } + double result = 0.0; + for (int l = order + 2; l <= degree; ++l) { + result = ((2.0 * l - 1.0) * x * pmm1 + - (l + order - 1.0) * pm1) / (l - order); + pm1 = pmm1; + pmm1 = result; + } + return result; + } +}; + +// --------------------------------------------------------------------------- +// Renderer +// --------------------------------------------------------------------------- +struct Path { + int delay_slots[2]; + Complex transfer[2][kHybrid]; +}; + +struct SourceState { + double position[3]; + int profile; + double gain; + int enabled; + int special_lfe; + std::vector current; + std::vector target; + int fade_position; + int fade_total; + double late_current; + double late_start; + double late_target; + int64_t late_fade_position; + int64_t late_fade_total; +}; + +constexpr double kDistanceM[3] = {1.00000465, 2.19327927, 6.40177584}; +constexpr double kMinimumDistance = 0.10; +constexpr double kCoupling = 0.01318359375; +constexpr double kRoomCalibration = 1.4; +constexpr double kLateSend[3] = {0.06, 0.16, 0.28}; +constexpr double kAir = 0.002; + +class Renderer final { +public: + Renderer() noexcept { + reset_state(); + } + + int configure_kernels(const double* qmf_analysis, const double* hybrid_low, + const int16_t* hybrid_indices, const double* hybrid_values, + uint32_t hybrid_count, const double* qmf_basis, + const double* qmf_taps) noexcept { + if (!qmf_analysis || !hybrid_low || !hybrid_indices || !hybrid_values + || !qmf_basis || !qmf_taps || hybrid_count == 0) { + return fail("invalid sofa binaural kernel configuration"); + } + analysis_.configure(qmf_analysis); + hybrid_analysis_.configure(hybrid_low); + hybrid_synthesis_.configure(hybrid_indices, hybrid_values, hybrid_count); + synthesis_.configure(qmf_basis, qmf_taps); + kernels_ready_ = true; + reset(); + return 0; + } + + int configure_field(const double* coefficients, const double* delay_coefficients, + const double* delay_bounds, const double* band_centers, + double measurement_radius_m) noexcept { + if (!coefficients || !delay_coefficients || !delay_bounds || !band_centers + || !std::isfinite(measurement_radius_m) || measurement_radius_m <= 0.0) { + return fail("invalid sofa binaural field configuration"); + } + for (int term = 0; term < kTerms; ++term) { + for (int ear = 0; ear < 2; ++ear) { + for (int band = 0; band < kHybrid; ++band) { + const size_t source = + ((static_cast(term) * 2 + ear) * kHybrid + band) * 2; + field_coeff_[term][ear][band] = { + coefficients[source], coefficients[source + 1]}; + } + delay_coeff_[term][ear] = + delay_coefficients[static_cast(term) * 2 + ear]; + } + } + std::memcpy(delay_bounds_, delay_bounds, sizeof(delay_bounds_)); + std::memcpy(centers_, band_centers, sizeof(centers_)); + measurement_radius_ = measurement_radius_m; + const double maximum = std::max(delay_bounds_[0][1], delay_bounds_[1][1]); + hrtf_slots_ = static_cast( + std::ceil(maximum / static_cast(kHop))); + field_ready_ = true; + reset(); + return 0; + } + + int configure_room(const double* dims, const double* listener, + const double* wall_gains, double speed, + const uint32_t* fdn_delays, const double* fdn_feedback, + double damping, double fdn_gain, + const uint32_t* allpass_delays, const double* allpass_gains, + uint32_t enable_early, uint32_t enable_late) noexcept { + if (!dims || !listener || !wall_gains || !fdn_delays || !fdn_feedback + || !allpass_delays || !allpass_gains || !(speed > 0.0) + || !(damping >= 0.0 && damping < 1.0)) { + return fail("invalid sofa binaural room configuration"); + } + std::memcpy(dims_, dims, sizeof(dims_)); + std::memcpy(listener_, listener, sizeof(listener_)); + std::memcpy(wall_gain_, wall_gains, sizeof(wall_gain_)); + speed_ = speed; + std::memcpy(fdn_delays_, fdn_delays, sizeof(fdn_delays_)); + std::memcpy(fdn_feedback_, fdn_feedback, sizeof(fdn_feedback_)); + damping_ = damping; + fdn_gain_ = fdn_gain; + std::memcpy(allpass_delays_, allpass_delays, sizeof(allpass_delays_)); + std::memcpy(allpass_gains_, allpass_gains, sizeof(allpass_gains_)); + enable_early_reflections_ = enable_early != 0; + enable_late_room_ = enable_late != 0; + for (int line = 0; line < 4; ++line) { + fdn_buffers_[line].assign(fdn_delays_[line], 0.0); + } + for (int line = 0; line < 2; ++line) { + allpass_buffers_[line].assign(allpass_delays_[line], 0.0); + } + room_ready_ = true; + reset(); + return 0; + } + + int reset() noexcept { + analysis_.reset(); + hybrid_analysis_.reset(); + synthesis_.reset(); + reset_state(); + return 0; + } + + const char* error() const noexcept { + return error_[0] ? error_ : ""; + } + + int set_source(uint32_t source, const double* position, uint32_t profile, + double gain, uint32_t enabled, uint32_t special_lfe, + uint32_t fade) noexcept { + if (!kernels_ready_ || !field_ready_ || !room_ready_) { + return fail("sofa binaural renderer is not configured"); + } + if (source >= kChannels || !position || profile > 2) { + return fail("invalid sofa binaural source update"); + } + SourceState& state = sources_[source]; + state.position[0] = position[0]; + state.position[1] = position[1]; + state.position[2] = position[2]; + state.profile = static_cast(profile); + state.gain = gain; + state.enabled = enabled != 0; + state.special_lfe = special_lfe != 0; + + const double effective = enabled ? gain : 0.0; + std::vector paths; + double late_send = 0.0; + double direction[3]; + double radius; + normalize_adm(position, direction, radius); + const double distance = + std::max(kMinimumDistance, radius * kDistanceM[state.profile]); + if (state.special_lfe) { + paths.push_back(lfe_path(effective)); + } else { + paths = ordinary_paths(direction, distance, state.profile, effective); + if (enabled && enable_late_room_) { + const double base = kLateSend[state.profile]; + const double radial = std::min( + std::max(std::sqrt(std::max(radius, 0.0)), 0.25), 1.5); + late_send = effective * kRoomCalibration * base * radial; + } + } + set_late_target(source, late_send, fade != 0); + + const int fade_slots = (fade && processed_slots_ > 0) ? kTransitionSlots : 0; + if (!state.target.empty()) { + state.current = state.target; + state.target.clear(); + } + if (processed_slots_ == 0 || fade_slots == 0) { + state.current = paths; + state.target.clear(); + state.fade_position = 0; + state.fade_total = 0; + } else { + state.target = paths; + state.fade_position = 0; + state.fade_total = fade_slots; + } + return 0; + } + + int process(const double* input, uint32_t sample_count, double output_gain, + double* output) noexcept { + if (!kernels_ready_ || !field_ready_ || !room_ready_) { + return fail("sofa binaural renderer is not configured"); + } + if (sample_count == 0 || sample_count > kMaxBlockSamples + || sample_count % kHop != 0 || !output) { + return fail("sofa binaural process requires 64-sample alignment"); + } + const int slots = static_cast(sample_count / kHop); + process_internal(input, slots, output_gain); + uint32_t skip = std::min(latency_to_discard_, sample_count); + latency_to_discard_ -= skip; + processed_input_samples_ += sample_count; + std::memcpy(output, output_.data() + static_cast(skip) * 2, + static_cast(sample_count - skip) * 2 * sizeof(double)); + return static_cast(sample_count - skip); + } + + int finish(uint32_t flush_samples, double* output, uint32_t capacity) noexcept { + if (!kernels_ready_ || !field_ready_ || !room_ready_) { + return fail("sofa binaural renderer is not configured"); + } + if (flush_samples == 0 || flush_samples > kMaxBlockSamples + || flush_samples % kHop != 0 || !output || capacity < flush_samples) { + return fail("invalid sofa binaural finish request"); + } + const int slots = static_cast(flush_samples / kHop); + process_internal(nullptr, slots, 1.0); + uint32_t skip = std::min(latency_to_discard_, flush_samples); + latency_to_discard_ -= skip; + processed_input_samples_ += flush_samples; + std::memcpy(output, output_.data() + static_cast(skip) * 2, + static_cast(flush_samples - skip) * 2 * sizeof(double)); + return static_cast(flush_samples - skip); + } + +private: + static void normalize_adm(const double* position, double* direction, + double& radius) noexcept { + const double r = std::sqrt(position[0] * position[0] + + position[1] * position[1] + + position[2] * position[2]); + radius = r; + if (r > 1.0e-15) { + direction[0] = position[0] / r; + direction[1] = position[1] / r; + direction[2] = position[2] / r; + } else { + direction[0] = 0.0; + direction[1] = 1.0; + direction[2] = 0.0; + } + } + + static double direct_level_gain(int profile, double distance) noexcept { + if (profile == 0) { + return 1.0; + } + return 1.0 / std::sqrt(1.0 + kCoupling * distance * distance); + } + + double inverse_distance_gain(double path_distance) const noexcept { + return measurement_radius_ / path_distance; + } + + Path make_path(const double* direction, double extra_delay, + double amplitude) noexcept { + double basis[kTerms]; + // ADM (+X right, +Y front, +Z up) -> SOFA listener (+X front, +Y left): + // (x, y, z)_sofa = (y_adm, -x_adm, z_adm) + const double listener_direction[3] = { + direction[1], -direction[0], direction[2]}; + RealSh::evaluate(listener_direction, basis); + Complex aligned[2][kHybrid]; + double delay[2]; + for (int ear = 0; ear < 2; ++ear) { + double delay_value = 0.0; + for (int band = 0; band < kHybrid; ++band) { + aligned[ear][band] = {0.0, 0.0}; + } + for (int term = 0; term < kTerms; ++term) { + delay_value += basis[term] * delay_coeff_[term][ear]; + for (int band = 0; band < kHybrid; ++band) { + aligned[ear][band].re += + field_coeff_[term][ear][band].re * basis[term]; + aligned[ear][band].im += + field_coeff_[term][ear][band].im * basis[term]; + } + } + delay[ear] = std::min(std::max(delay_value, delay_bounds_[ear][0]), + delay_bounds_[ear][1]); + } + Path path; + for (int ear = 0; ear < 2; ++ear) { + const double total = delay[ear] + extra_delay; + const double slots = std::floor(total / kHop); + path.delay_slots[ear] = static_cast(slots); + const double residual = total - slots * kHop; + for (int band = 0; band < kHybrid; ++band) { + // Python transfer = raw aligned gains x residual-delay phase x + // amplitude (the integer-slot part is the delay_slots history). + const double phase = -2.0 * kPi * centers_[band] + * residual / kSampleRate; + const Complex rotated = { + aligned[ear][band].re * std::cos(phase) + - aligned[ear][band].im * std::sin(phase), + aligned[ear][band].re * std::sin(phase) + + aligned[ear][band].im * std::cos(phase)}; + path.transfer[ear][band] = mulr(rotated, amplitude); + } + } + return path; + } + + Path lfe_path(double gain) noexcept { + Path path; + path.delay_slots[0] = 0; + path.delay_slots[1] = 0; + for (int band = 0; band < kHybrid; ++band) { + const double frequency = centers_[band]; + double lowpass = 1.0; + if (frequency >= 180.0) { + lowpass = 0.0; + } else if (frequency > 120.0) { + const double amount = (frequency - 120.0) / 60.0; + const double value = std::cos(0.5 * kPi * amount); + lowpass = value * value; + } + path.transfer[0][band] = {gain * lowpass / std::sqrt(2.0), 0.0}; + path.transfer[1][band] = {gain * lowpass / std::sqrt(2.0), 0.0}; + } + return path; + } + + std::vector ordinary_paths(const double* direction, double distance, + int profile, double gain) noexcept { + std::vector paths; + paths.push_back( + make_path(direction, 0.0, gain * direct_level_gain(profile, distance))); + if (enable_early_reflections_) { + double source[3] = { + listener_[0] + direction[0] * distance, + listener_[1] + direction[1] * distance, + listener_[2] + direction[2] * distance, + }; + for (int axis = 0; axis < 3; ++axis) { + for (int side = 0; side < 2; ++side) { + double image[3] = {source[0], source[1], source[2]}; + image[axis] = (side == 0) ? -image[axis] + : 2.0 * dims_[axis] - image[axis]; + double vector[3] = { + image[0] - listener_[0], + image[1] - listener_[1], + image[2] - listener_[2], + }; + const double path_distance = std::sqrt( + vector[0] * vector[0] + vector[1] * vector[1] + + vector[2] * vector[2]); + double path_direction[3] = { + vector[0] / path_distance, + vector[1] / path_distance, + vector[2] / path_distance, + }; + const double extra = std::max( + 0.0, (path_distance - distance) * kSampleRate / speed_); + const double air = + std::exp(-kAir * std::max(path_distance - distance, 0.0)); + const double amplitude = gain * kRoomCalibration + * wall_gain_[axis * 2 + side] * air + * inverse_distance_gain(path_distance); + paths.push_back(make_path(path_direction, extra, amplitude)); + maximum_early_delay_ = std::max(maximum_early_delay_, extra); + } + } + } + return paths; + } + + void set_late_target(int source, double value, int fade) noexcept { + const int64_t fade_samples = + (fade && processed_input_samples_ > 0) ? kTransitionSlots * kHop : 0; + SourceState& state = sources_[source]; + if (fade_samples == 0) { + state.late_current = value; + state.late_start = value; + state.late_target = value; + state.late_fade_position = 0; + state.late_fade_total = 0; + } else { + state.late_start = state.late_current; + state.late_target = value; + state.late_fade_position = 0; + state.late_fade_total = fade_samples; + } + } + + void process_internal(const double* input, int slots, double output_gain) noexcept { + const uint32_t sample_count = static_cast(slots) * kHop; + // 1) late send envelopes -> mono + std::vector mono(sample_count, 0.0); + for (int source = 0; source < kChannels; ++source) { + SourceState& state = sources_[source]; + const int64_t total = state.late_fade_total; + if (total == 0) { + if (input && state.late_current != 0.0) { + for (uint32_t s = 0; s < sample_count; ++s) { + mono[s] += input[static_cast(s) * kChannels + source] + * state.late_current; + } + } + continue; + } + int64_t position = state.late_fade_position; + for (uint32_t s = 0; s < sample_count; ++s) { + position += 1; + double amount = static_cast(position) + / static_cast(total); + amount = std::min(std::max(amount, 0.0), 1.0); + const double value = state.late_start * (1.0 - amount) + + state.late_target * amount; + if (input) { + mono[s] += input[static_cast(s) * kChannels + source] + * value; + } + } + if (position >= total) { + state.late_current = state.late_target; + state.late_start = state.late_target; + state.late_fade_position = 0; + state.late_fade_total = 0; + } else { + double amount = static_cast(position) + / static_cast(total); + amount = std::min(std::max(amount, 0.0), 1.0); + state.late_current = + state.late_start * (1.0 - amount) + state.late_target * amount; + state.late_fade_position = position; + } + } + + // 2) FDN + 961-sample stereo delay + std::vector late_pcm(static_cast(sample_count) * 2, 0.0); + if (enable_late_room_) { + std::vector diffused = mono; + for (int line = 0; line < 2; ++line) { + diffused = allpass(line, diffused); + } + for (uint32_t s = 0; s < sample_count; ++s) { + const double value = diffused[s]; + double delayed[4]; + for (int line = 0; line < 4; ++line) { + delayed[line] = fdn_buffers_[line][fdn_positions_[line]]; + } + double damping_state[4]; + for (int line = 0; line < 4; ++line) { + damping_state[line] = damping_ * damping_state_[line] + + (1.0 - damping_) * delayed[line]; + damping_state_[line] = damping_state[line]; + } + const double fdn_left = damping_state[0] + damping_state[1] + - damping_state[2] - damping_state[3]; + const double fdn_right = damping_state[0] - damping_state[1] + + damping_state[2] - damping_state[3]; + const double out_left = + late_ring_[static_cast(late_pos_) * 2]; + const double out_right = + late_ring_[static_cast(late_pos_) * 2 + 1]; + late_pcm[s * 2] = out_left; + late_pcm[s * 2 + 1] = out_right; + late_ring_[static_cast(late_pos_) * 2] = + fdn_gain_ * 0.5 * fdn_left; + late_ring_[static_cast(late_pos_) * 2 + 1] = + fdn_gain_ * 0.5 * fdn_right; + late_pos_ = (late_pos_ + 1) % kLatency; + // feedback = hadamard4/2 @ (damping_state * feedback_gain) + // H4[i][j] = -1 when popcount(i & j) is odd + double feedback[4]; + for (int target = 0; target < 4; ++target) { + double acc = 0.0; + for (int source_line = 0; source_line < 4; ++source_line) { + const bool odd = (static_cast(target & source_line) + ? (std::popcount(static_cast( + target & source_line)) & 1u) != 0u + : false); + const double sign = odd ? -1.0 : 1.0; + acc += sign * damping_state[source_line] + * fdn_feedback_[source_line]; + } + feedback[target] = 0.5 * acc; + } + const double input_vector[4] = {0.5, -0.5, 0.5, 0.5}; + for (int line = 0; line < 4; ++line) { + const double write = + input_vector[line] * value + feedback[line]; + fdn_buffers_[line][fdn_positions_[line]] = write; + fdn_positions_[line] = + (fdn_positions_[line] + 1) % fdn_delays_[line]; + } + } + } + + // 3) analysis chain + analysis_.process(input ? input : zeros_.data(), slots, qmf_work_.data()); + hybrid_analysis_.process(qmf_work_.data(), slots, hybrid_work_.data()); + // 4) per-object direct/early with crossfade + std::vector direct_and_early( + static_cast(slots) * 2 * kHybrid, Complex{0.0, 0.0}); + for (int slot = 0; slot < slots; ++slot) { + const Complex* hybrid_slot = + hybrid_work_.data() + static_cast(slot) * kChannels * kHybrid; + for (int source = 0; source < kChannels; ++source) { + std::memcpy(history_[source].data() + + static_cast(position_) * kHybrid, + hybrid_slot + static_cast(source) * kHybrid, + kHybrid * sizeof(Complex)); + } + Complex* out_slot = + direct_and_early.data() + static_cast(slot) * 2 * kHybrid; + for (int source = kChannels - 1; source >= 0; --source) { + SourceState& state = sources_[source]; + if (state.target.empty()) { + render_paths(source, state.current, 1.0, out_slot); + continue; + } + state.fade_position += 1; + double amount = static_cast(state.fade_position) + / static_cast(state.fade_total); + amount = std::min(std::max(amount, 0.0), 1.0); + render_paths(source, state.current, 1.0 - amount, out_slot); + render_paths(source, state.target, amount, out_slot); + if (state.fade_position >= state.fade_total) { + state.current = state.target; + state.target.clear(); + state.fade_position = 0; + state.fade_total = 0; + } + } + position_ = (position_ + 1) % history_slots_; + processed_slots_ += 1; + } + // 5) synthesis chain + hybrid_synthesis_.process(direct_and_early.data(), slots, + qmf_synth_work_.data()); + synthesis_.process(qmf_synth_work_.data(), slots, pcm_work_.data()); + // 6) mix, gain, interleave [samples][2] + for (uint32_t s = 0; s < sample_count; ++s) { + const int slot = static_cast(s / kHop); + const int b = static_cast(s % kHop); + const double direct_left = + pcm_work_[(static_cast(slot) * 2) * 64 + b]; + const double direct_right = + pcm_work_[(static_cast(slot) * 2 + 1) * 64 + b]; + output_[static_cast(s) * 2] = + (direct_left + late_pcm[static_cast(s) * 2]) * output_gain; + output_[static_cast(s) * 2 + 1] = + (direct_right + late_pcm[static_cast(s) * 2 + 1]) * output_gain; + } + } + + void render_paths(int source, const std::vector& paths, double scale, + Complex* out) const noexcept { + for (const Path& path : paths) { + const int slots = static_cast(history_slots_); + const int index0 = (static_cast(position_) + slots + - path.delay_slots[0]) % slots; + const int index1 = (static_cast(position_) + slots + - path.delay_slots[1]) % slots; + const Complex* history = history_[source].data(); + for (int band = 0; band < kHybrid; ++band) { + const Complex source0 = history[index0 * kHybrid + band]; + const Complex source1 = history[index1 * kHybrid + band]; + out[band].re += source0.re * path.transfer[0][band].re * scale + - source0.im * path.transfer[0][band].im * scale; + out[band].im += source0.re * path.transfer[0][band].im * scale + + source0.im * path.transfer[0][band].re * scale; + out[kHybrid + band].re += + source1.re * path.transfer[1][band].re * scale + - source1.im * path.transfer[1][band].im * scale; + out[kHybrid + band].im += + source1.re * path.transfer[1][band].im * scale + + source1.im * path.transfer[1][band].re * scale; + } + } + } + + std::vector allpass(int line, const std::vector& source) noexcept { + std::vector output(source.size(), 0.0); + const double gain = allpass_gains_[line]; + const uint32_t delay = allpass_delays_[line]; + for (size_t i = 0; i < source.size(); ++i) { + const double delayed = allpass_buffers_[line][allpass_positions_[line]]; + const double result = delayed - gain * source[i]; + allpass_buffers_[line][allpass_positions_[line]] = + source[i] + gain * result; + allpass_positions_[line] = (allpass_positions_[line] + 1) % delay; + output[i] = result; + } + return output; + } + + int fail(const char* message) noexcept { + std::snprintf(error_, sizeof(error_), "%s", message); + return -1; + } + + void reset_state() noexcept { + latency_to_discard_ = kLatency; + processed_input_samples_ = 0; + processed_slots_ = 0; + position_ = 0; + maximum_early_delay_ = 0.0; + history_slots_ = kEarlyHistory + hrtf_slots_; + history_.assign(kChannels, + std::vector( + static_cast(history_slots_) * kHybrid, + Complex{0.0, 0.0})); + sources_.assign(kChannels, SourceState{}); + for (int source = 0; source < kChannels; ++source) { + sources_[source].position[0] = 0.0; + sources_[source].position[1] = 1.0; + sources_[source].position[2] = 0.0; + sources_[source].profile = 1; + sources_[source].gain = 1.0; + sources_[source].enabled = 1; + sources_[source].special_lfe = 0; + sources_[source].fade_position = 0; + sources_[source].fade_total = 0; + sources_[source].late_current = 0.0; + sources_[source].late_start = 0.0; + sources_[source].late_target = 0.0; + sources_[source].late_fade_position = 0; + sources_[source].late_fade_total = 0; + } + for (int line = 0; line < 4; ++line) { + fdn_positions_[line] = 0; + damping_state_[line] = 0.0; + if (!fdn_buffers_[line].empty()) { + std::fill(fdn_buffers_[line].begin(), fdn_buffers_[line].end(), 0.0); + } + } + for (int line = 0; line < 2; ++line) { + allpass_positions_[line] = 0; + if (!allpass_buffers_[line].empty()) { + std::fill(allpass_buffers_[line].begin(), + allpass_buffers_[line].end(), 0.0); + } + } + late_pos_ = 0; + late_ring_.fill(0.0); + error_[0] = '\0'; + } + + QmfAnalysis analysis_; + HybridAnalysis hybrid_analysis_; + HybridSynthesis hybrid_synthesis_; + QmfSynthesis synthesis_; + bool kernels_ready_ = false; + bool field_ready_ = false; + bool room_ready_ = false; + + Complex field_coeff_[kTerms][2][kHybrid]; + double delay_coeff_[kTerms][2]; + double delay_bounds_[2][2]; + double centers_[kHybrid]; + double measurement_radius_ = 1.0; + uint32_t hrtf_slots_ = 0; + + double dims_[3]; + double listener_[3]; + double wall_gain_[6]; + double speed_ = 343.3; + uint32_t fdn_delays_[4]; + double fdn_feedback_[4]; + double damping_ = 0.32; + double fdn_gain_ = 0.22; + uint32_t allpass_delays_[2]; + double allpass_gains_[2]; + + uint32_t latency_to_discard_ = kLatency; + uint64_t processed_input_samples_ = 0; + int64_t processed_slots_ = 0; + uint32_t history_slots_ = kEarlyHistory; + uint32_t position_ = 0; + double maximum_early_delay_ = 0.0; + std::vector> history_; + std::vector sources_; + + std::vector fdn_buffers_[4]; + uint32_t fdn_positions_[4]; + double damping_state_[4]; + std::vector allpass_buffers_[2]; + uint32_t allpass_positions_[2]; + std::array late_ring_{}; + uint32_t late_pos_ = 0; + + std::array qmf_work_{}; + std::array hybrid_work_{}; + std::array qmf_synth_work_{}; + std::array pcm_work_{}; + std::array output_{}; + std::array zeros_{}; + + char error_[256]; + bool enable_early_reflections_ = true; + bool enable_late_room_ = true; +}; + +} // namespace ejoc::sofa_binaural + +using ejoc::sofa_binaural::Renderer; + +extern "C" { + +ejoc_sofa_binaural_handle EJOC_CALL ejoc_sofa_binaural_create(void) { + try { + return new (std::nothrow) Renderer(); + } catch (...) { + return nullptr; + } +} + +void EJOC_CALL ejoc_sofa_binaural_destroy(ejoc_sofa_binaural_handle handle) { + delete static_cast(handle); +} + +int EJOC_CALL ejoc_sofa_binaural_reset(ejoc_sofa_binaural_handle handle) { + return handle ? static_cast(handle)->reset() : -1; +} + +const char* EJOC_CALL ejoc_sofa_binaural_last_error( + ejoc_sofa_binaural_handle handle) { + return handle ? static_cast(handle)->error() : "null handle"; +} + +int EJOC_CALL ejoc_sofa_binaural_configure_kernels( + ejoc_sofa_binaural_handle handle, const double* qmf_analysis, + const double* hybrid_low, const int16_t* hybrid_indices, + const double* hybrid_values, uint32_t hybrid_count, const double* qmf_basis, + const double* qmf_taps) { + return handle ? static_cast(handle)->configure_kernels( + qmf_analysis, hybrid_low, hybrid_indices, hybrid_values, hybrid_count, + qmf_basis, qmf_taps) : -1; +} + +int EJOC_CALL ejoc_sofa_binaural_configure_field( + ejoc_sofa_binaural_handle handle, const double* coefficients, + const double* delay_coefficients, const double* delay_bounds, + const double* band_centers, double measurement_radius_m) { + return handle ? static_cast(handle)->configure_field( + coefficients, delay_coefficients, delay_bounds, band_centers, + measurement_radius_m) : -1; +} + +int EJOC_CALL ejoc_sofa_binaural_configure_room( + ejoc_sofa_binaural_handle handle, const double* room_dims, + const double* listener_pos, const double* wall_gains, double speed_of_sound, + const uint32_t* fdn_delays, const double* fdn_feedback, double damping, + double fdn_output_gain, const uint32_t* allpass_delays, + const double* allpass_gains, uint32_t enable_early, uint32_t enable_late) { + return handle ? static_cast(handle)->configure_room( + room_dims, listener_pos, wall_gains, speed_of_sound, fdn_delays, + fdn_feedback, damping, fdn_output_gain, allpass_delays, allpass_gains, + enable_early, enable_late) : -1; +} + +int EJOC_CALL ejoc_sofa_binaural_set_source( + ejoc_sofa_binaural_handle handle, uint32_t source, const double* position_adm, + uint32_t profile, double gain, uint32_t enabled, uint32_t special_lfe, + uint32_t fade) { + return handle ? static_cast(handle)->set_source( + source, position_adm, profile, gain, enabled, special_lfe, fade) : -1; +} + +int EJOC_CALL ejoc_sofa_binaural_process( + ejoc_sofa_binaural_handle handle, const double* input16_interleaved, + uint32_t sample_count, double output_gain, double* output_stereo_interleaved) { + return handle ? static_cast(handle)->process( + input16_interleaved, sample_count, output_gain, output_stereo_interleaved) + : -1; +} + +int EJOC_CALL ejoc_sofa_binaural_finish( + ejoc_sofa_binaural_handle handle, uint32_t flush_samples, + double* output_stereo_interleaved, uint32_t capacity) { + return handle ? static_cast(handle)->finish( + flush_samples, output_stereo_interleaved, capacity) : -1; +} + + + + + + + + +} // extern "C" diff --git a/requirements.txt b/requirements.txt index 9f161ac..083bb2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ numpy>=1.24 +scipy>=1.10 +h5py>=3.8 diff --git a/src/adm_atmos.py b/src/adm_atmos.py index ea282ed..a98eec1 100644 --- a/src/adm_atmos.py +++ b/src/adm_atmos.py @@ -234,11 +234,21 @@ def build_dbmd(object_count=25, joc_binaural_mode=4): return bytes(out) class Sink25: + """RF64 ADM BWF writer. + + Header layout is fixed so that sizes can be patched without rereading the + file: RF64+size+WAVE (12) + ds64 chunk (8+28) + fmt chunk (8+16) + data + chunk header (8). Sizes beyond 32 bits follow the RF64 convention: the + chunk size field holds 0xFFFFFFFF and the true value lives in ds64. + """ + _DS64_BODY_OFFSET = 20 + _DATA_SIZE_OFFSET = 76 + def __init__(self, path, channels, rate): self.ch = channels; self.rate = rate; self.frames = 0 self.fp = open(path, "wb+") self.fp.write(b"RF64" + struct.pack("= 0: - self.fp.seek(m + 4); self.fp.write(struct.pack("= 0: - self.fp.seek(m + 8) - self.fp.write(struct.pack(" 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) diff --git a/src/binaural_native_renderer.py b/src/binaural_native_renderer.py new file mode 100644 index 0000000..3075a1e --- /dev/null +++ b/src/binaural_native_renderer.py @@ -0,0 +1,214 @@ +"""ctypes bridge for the native float64 binaural DSP.""" +from __future__ import annotations + +import ctypes +from pathlib import Path + +import numpy as np + +from native_renderer import ABI_VERSION, find_native_library +from rosella_filterbank import DEFAULT_KERNEL_DATA, load_kernel_tables +from rosella_model import RosellaModel + +BLOCK_SAMPLES = 512 +INPUT_CHANNELS = 16 +OUTPUT_CHANNELS = 2 +HYBRID_BANDS = 77 + + +class NativeBinauralDsp: + def __init__(self, model: RosellaModel, *, library_path=None, + kernel_data: str | Path = DEFAULT_KERNEL_DATA): + self.library_path = find_native_library(library_path) + self._lib = ctypes.CDLL(str(self.library_path)) + self._bind() + version = int(self._lib.ejoc_abi_version()) + if version != ABI_VERSION: + raise RuntimeError( + f"native ABI mismatch: expected {ABI_VERSION}, got {version}") + self._handle = self._lib.ejoc_binaural_renderer_create() + if not self._handle: + raise RuntimeError("native binaural renderer creation failed") + try: + self._configure_kernels(kernel_data) + self._configure_room(model) + except Exception: + self.close() + raise + + def _bind(self): + void_p = ctypes.c_void_p + f64_p = ctypes.POINTER(ctypes.c_double) + i16_p = ctypes.POINTER(ctypes.c_int16) + u32_p = ctypes.POINTER(ctypes.c_uint32) + self._lib.ejoc_abi_version.argtypes = [] + self._lib.ejoc_abi_version.restype = ctypes.c_uint32 + self._lib.ejoc_binaural_renderer_create.argtypes = [] + self._lib.ejoc_binaural_renderer_create.restype = void_p + self._lib.ejoc_binaural_renderer_destroy.argtypes = [void_p] + self._lib.ejoc_binaural_renderer_destroy.restype = None + self._lib.ejoc_binaural_renderer_reset.argtypes = [void_p] + self._lib.ejoc_binaural_renderer_reset.restype = ctypes.c_int + self._lib.ejoc_binaural_renderer_last_error.argtypes = [void_p] + self._lib.ejoc_binaural_renderer_last_error.restype = ctypes.c_char_p + self._lib.ejoc_binaural_renderer_configure_kernels.argtypes = [ + void_p, f64_p, f64_p, i16_p, f64_p, ctypes.c_uint32, f64_p, f64_p] + self._lib.ejoc_binaural_renderer_configure_kernels.restype = ctypes.c_int + self._lib.ejoc_binaural_renderer_configure_room.argtypes = [ + void_p, ctypes.c_uint32, ctypes.c_uint32, u32_p, f64_p, + u32_p, f64_p, ctypes.c_uint32, f64_p, f64_p, f64_p, + ctypes.c_uint32, u32_p, f64_p, f64_p] + self._lib.ejoc_binaural_renderer_configure_room.restype = ctypes.c_int + self._lib.ejoc_binaural_renderer_process.argtypes = [ + void_p, f64_p, f64_p, f64_p, ctypes.c_double, f64_p] + self._lib.ejoc_binaural_renderer_process.restype = ctypes.c_int + + def _raise(self, operation, status): + message = self._lib.ejoc_binaural_renderer_last_error(self._handle) + detail = (message or b"").decode("utf-8", "replace") + raise RuntimeError( + f"native binaural renderer {operation} failed ({status}): {detail}") + + @staticmethod + def _f64_pointer(values): + return values.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) + + def _configure_kernels(self, kernel_data): + tables = load_kernel_tables(kernel_data) + qmf_analysis = np.ascontiguousarray( + tables["qmf_analysis_coefficients"], dtype=np.float64) + hybrid_low = np.ascontiguousarray( + tables["hybrid_analysis_low_kernel"], dtype=np.float64) + hybrid_indices = np.ascontiguousarray( + tables["hybrid_synthesis_indices"], dtype=np.int16) + hybrid_values = np.ascontiguousarray( + tables["hybrid_synthesis_values"], dtype=np.float64) + qmf_basis = np.ascontiguousarray( + tables["qmf_synthesis_basis"], dtype=np.float64) + qmf_taps = np.ascontiguousarray( + tables["qmf_synthesis_taps"], dtype=np.float64) + status = self._lib.ejoc_binaural_renderer_configure_kernels( + self._handle, + self._f64_pointer(qmf_analysis), + self._f64_pointer(hybrid_low), + hybrid_indices.ctypes.data_as(ctypes.POINTER(ctypes.c_int16)), + self._f64_pointer(hybrid_values), + len(hybrid_values), + self._f64_pointer(qmf_basis), + self._f64_pointer(qmf_taps), + ) + if status: + self._raise("configure_kernels", status) + + def _configure_room(self, model: RosellaModel): + if float(model.table_a_scalar) >= 0.5: + raise NotImplementedError("alternate table-A room mode") + bands = min(64, model.table_a_dimension) + allpass_delays = np.ascontiguousarray( + model.table_a_option_ids, dtype=np.uint32) + allpass_gains = np.ascontiguousarray( + model.table_a_option_values, dtype=np.float64) + fdn_delays = np.ascontiguousarray( + model.table_a_four_integers, dtype=np.uint32) + fdn_matrix = np.ascontiguousarray( + np.asarray(model.table_a_vector16, dtype=np.float64).reshape( + 4, 4, order="F")) + + filter8 = np.asarray( + model.table_a_filter_8x64_padded, dtype=np.float64).reshape(20, 4, 2, 4) + filter4 = np.asarray( + model.table_a_filter_4x64_padded, dtype=np.float64).reshape(20, 4, 4) + filter16 = np.asarray( + model.table_a_filter_16x64_padded, dtype=np.float64).reshape(20, 4, 4, 4) + feedback = np.empty((64, 4, 2), dtype=np.float64) + output_taps = np.empty((64, 4), dtype=np.float64) + output_matrix = np.empty((2, 64, 4, 2), dtype=np.float64) + for band in range(64): + group, lane = divmod(band, 4) + feedback[band, :, 0] = filter8[group, :, 0, lane] + feedback[band, :, 1] = filter8[group, :, 1, lane] + output_taps[band] = filter4[group, :, lane] + output_matrix[0, band, :, 0] = filter16[group, :, 0, lane] + output_matrix[0, band, :, 1] = filter16[group, :, 1, lane] + output_matrix[1, band, :, 0] = filter16[group, :, 2, lane] + output_matrix[1, band, :, 1] = filter16[group, :, 3, lane] + + extra_count = int(model.table_a_extra) + extra_delays = np.ascontiguousarray( + model.table_a_extra_indices, dtype=np.uint32) + extra_fields = np.empty((extra_count, 64, 2), dtype=np.float64) + extra_source = np.asarray( + model.table_a_extra_fields_padded, dtype=np.float64).reshape( + extra_count, 20, 2, 4) + for extra in range(extra_count): + for band in range(64): + group, lane = divmod(band, 4) + extra_fields[extra, band] = extra_source[extra, group, :, lane] + extra_matrices = np.empty((extra_count, 4, 4), dtype=np.float64) + for extra in range(extra_count): + extra_matrices[extra] = np.asarray( + model.table_a_extra_vectors[extra], dtype=np.float64).reshape( + 4, 4, order="F") + + null_u32 = ctypes.POINTER(ctypes.c_uint32)() + null_f64 = ctypes.POINTER(ctypes.c_double)() + status = self._lib.ejoc_binaural_renderer_configure_room( + self._handle, + bands, + len(allpass_delays), + allpass_delays.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)), + self._f64_pointer(allpass_gains), + fdn_delays.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)), + self._f64_pointer(fdn_matrix), + int(model.table_a_integer), + self._f64_pointer(feedback), + self._f64_pointer(output_taps), + self._f64_pointer(output_matrix), + extra_count, + (extra_delays.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)) + if extra_count else null_u32), + self._f64_pointer(extra_fields) if extra_count else null_f64, + self._f64_pointer(extra_matrices) if extra_count else null_f64, + ) + if status: + self._raise("configure_room", status) + + def reset(self): + if not self._handle: + raise RuntimeError("native binaural renderer is closed") + status = self._lib.ejoc_binaural_renderer_reset(self._handle) + if status: + self._raise("reset", status) + + def process_block(self, pcm16, gains, room_sends, output_gain=1.0): + if not self._handle: + raise RuntimeError("native binaural renderer is closed") + source = np.ascontiguousarray(pcm16, dtype=np.float64) + gain_values = np.asarray(gains) + sends = np.ascontiguousarray(room_sends, dtype=np.float64) + if source.shape != (BLOCK_SAMPLES, INPUT_CHANNELS): + raise ValueError(f"pcm16 block must be (512,16), got {source.shape}") + if gain_values.shape != (INPUT_CHANNELS, OUTPUT_CHANNELS, HYBRID_BANDS): + raise ValueError(f"gains must be (16,2,77), got {gain_values.shape}") + direct = np.ascontiguousarray( + gain_values, dtype=np.complex128).view(np.float64) + if sends.shape != (INPUT_CHANNELS,): + raise ValueError(f"room_sends must be (16,), got {sends.shape}") + output = np.empty((BLOCK_SAMPLES, OUTPUT_CHANNELS), dtype=np.float64) + status = self._lib.ejoc_binaural_renderer_process( + self._handle, + self._f64_pointer(source), + self._f64_pointer(direct), + self._f64_pointer(sends), + float(output_gain), + self._f64_pointer(output), + ) + if status: + self._raise("process", status) + return output + + def close(self): + handle = getattr(self, "_handle", None) + if handle: + self._lib.ejoc_binaural_renderer_destroy(handle) + self._handle = None diff --git a/src/binaural_renderer.py b/src/binaural_renderer.py new file mode 100644 index 0000000..ed13a44 --- /dev/null +++ b/src/binaural_renderer.py @@ -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 diff --git a/src/public_filterbank.py b/src/public_filterbank.py new file mode 100644 index 0000000..7ced393 --- /dev/null +++ b/src/public_filterbank.py @@ -0,0 +1,692 @@ +"""Public 64-QMF and 77-band hybrid filterbank for binaural rendering. + +The fixed resource is ``data/rosella_kernels.npz``: the fixed 64-QMF / +``3 -> 8+4+4`` 77-hybrid analysis tables and the causal synthesis tables +computed from that analysis bank. The filter bank is publicly standardized: +the 64-QMF → 77-hybrid structure, the 13-tap low-band prototypes and their +half-bin complex modulation follow 3GPP TS 26.405 / ETSI TS 126 405 (Section +5.2.2, Table 1, ``Q=8``/``Q=4``); the 64-band QMF analysis is the MPEG-4 +AAC/SBR 64 complex QMF analysis bank (ISO/IEC 14496-3/AMD1:2003, subclause +4.B.18.2), stored here as the polyphase form +``A[r,t] = ((-1)**t / 128) * c[63 - r + 64*t]`` of the public 640-tap SBR +prototype. The QMF synthesis table is the causal left inverse of that +analysis polyphase matrix (``A @ W = P`` with the 577-sample delay +permutation; total latency ``961 = 577 + 6*64``), stored as the rank-4 +factorization ``W[b,l] = sum_r taps[b,l,r] * basis[b,r,:]``; the hybrid +synthesis table is the 77->64 recombination (identity for the high bands, +signed summation of each 8+4+4 child group for the low bands), stored as a +154-entry sparse map. The archive and every array inside it are +hash-validated before use, and those hashes participate in every +compiled-HRTF cache key. Provenance and rights boundaries are documented in +``data/README.md`` and ``THIRD_PARTY_NOTICES.md``. +""" +from __future__ import annotations + +from functools import lru_cache +import hashlib +import os +from pathlib import Path +import zipfile + +import numpy as np + + +PROJECT_DIR = Path(__file__).resolve().parent.parent +DEFAULT_FILTERBANK_DATA = PROJECT_DIR / "data" / "rosella_kernels.npz" +FILTERBANK_TABLE_VERSION = "joc-public-64qmf-77hybrid-v1" +SAMPLE_RATE = 48000 +QMF_HOP = 64 +QMF_BANDS = 64 +HYBRID_BANDS = 77 +ANALYSIS_SYNTHESIS_LATENCY_SAMPLES = 961 + +_ARCHIVE_SHA256 = "C05BEF4D26E96ECBD4694E2572F05DA400255C777BA5047300B9D3B1F81081CD" +_TABLE_SPECS = { + "format_version": (np.dtype(" str: + return hashlib.sha256(values).hexdigest().upper() + + +def _validate_npy_member_header( + archive: zipfile.ZipFile, member: zipfile.ZipInfo, + *, name: str, dtype: np.dtype, shape: tuple[int, ...], + allow_fortran: bool) -> None: + try: + with archive.open(member, "r") as payload: + version = np.lib.format.read_magic(payload) + if version == (1, 0): + actual_shape, actual_fortran_order, actual_dtype = ( + np.lib.format.read_array_header_1_0( + payload, max_header_size=4096)) + elif version == (2, 0): + actual_shape, actual_fortran_order, actual_dtype = ( + np.lib.format.read_array_header_2_0( + payload, max_header_size=4096)) + else: + raise ValueError(f"unsupported .npy version {version!r}") + header_size = payload.tell() + except (EOFError, OSError, ValueError) as exc: + raise ValueError( + f"invalid public filterbank table .npy header: {member.filename}: " + f"{exc}") from exc + + actual_shape = tuple(actual_shape) + actual_dtype = np.dtype(actual_dtype) + if (actual_shape != shape or actual_dtype != dtype + or (bool(actual_fortran_order) and not allow_fortran)): + expected_order = "C-order" if not allow_fortran else "C- or Fortran-order" + actual_order = "Fortran-order" if actual_fortran_order else "C-order" + raise ValueError( + f"invalid public filterbank table .npy header for {name}: expected " + f"{dtype}{shape} {expected_order}, got " + f"{actual_dtype}{actual_shape} {actual_order}") + expected_size = header_size + dtype.itemsize * int(np.prod(shape)) + if member.file_size != expected_size: + raise ValueError( + f"invalid public filterbank table .npy payload size for {name}: " + f"expected {expected_size} bytes including the header, " + f"got {member.file_size}") + + +def _validate_table_members(stream) -> None: + expected = {name + ".npy" for name in _TABLE_SPECS} + limits = { + name + ".npy": dtype.itemsize * int(np.prod(shape)) + 4096 + for name, (dtype, shape, _, _, _) in _TABLE_SPECS.items() + } + try: + stream.seek(0) + with zipfile.ZipFile(stream, "r") as archive: + members = archive.infolist() + if (len(members) != len(expected) + or {member.filename for member in members} != expected): + raise ValueError( + "public filterbank table archive has an invalid member set") + for member in members: + if member.flag_bits & 0x1: + raise ValueError("encrypted public filterbank tables are unsupported") + if member.compress_type not in ( + zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise ValueError("unsupported public filterbank table compression") + if member.file_size > limits[member.filename]: + raise ValueError( + f"public filterbank table member is unexpectedly large: " + f"{member.filename}") + if sum(member.file_size for member in members) > sum(limits.values()): + raise ValueError("public filterbank tables expand beyond their size limit") + for member in members: + name = member.filename[:-4] + dtype, shape, _, allow_fortran, _ = _TABLE_SPECS[name] + _validate_npy_member_header( + archive, member, name=name, dtype=dtype, shape=shape, + allow_fortran=allow_fortran) + except zipfile.BadZipFile as exc: + raise ValueError(f"invalid public filterbank table archive: {exc}") from exc + + +@lru_cache(maxsize=2) +def _load_tables(path_string: str) -> dict[str, np.ndarray]: + path = Path(path_string) + if not path.is_file(): + raise FileNotFoundError(f"public filterbank table resource not found: {path}") + with path.open("rb") as stream: + archive_size = os.fstat(stream.fileno()).st_size + if archive_size <= 0 or archive_size > 8 << 20: + raise ValueError( + f"public filterbank table resource is unexpectedly large: {path}") + if path.resolve() == DEFAULT_FILTERBANK_DATA.resolve(): + digest = hashlib.sha256() + for block in iter(lambda: stream.read(4 << 20), b""): + digest.update(block) + actual_archive_hash = digest.hexdigest().upper() + if actual_archive_hash != _ARCHIVE_SHA256: + raise ValueError( + "public filterbank table archive hash mismatch: " + f"expected {_ARCHIVE_SHA256}, got {actual_archive_hash}") + _validate_table_members(stream) + stream.seek(0) + with np.load(stream, allow_pickle=False) as archive: + if set(archive.files) != set(_TABLE_SPECS): + raise ValueError("public filterbank table archive has an invalid key set") + result: dict[str, np.ndarray] = {} + for name, (dtype, shape, expected_hash, _, _) in _TABLE_SPECS.items(): + value = np.asarray(archive[name]) + if value.dtype != dtype or value.shape != shape: + raise ValueError( + f"invalid public filterbank table {name}: " + f"expected {dtype}{shape}, got {value.dtype}{value.shape}") + actual_hash = _sha256_bytes(value.tobytes(order="C")) + if actual_hash != expected_hash: + raise ValueError(f"public filterbank table hash mismatch: {name}") + result[name] = np.ascontiguousarray(value) + result[name].setflags(write=False) + if int(result["format_version"][0]) != 1: + raise ValueError("unsupported public filterbank table format version") + return result + + +def load_filterbank_tables( + path: str | Path = DEFAULT_FILTERBANK_DATA) -> dict[str, np.ndarray]: + """Load the validated project resource used by the public filterbank.""" + cached = _load_tables(str(Path(path).expanduser().resolve())) + result = {name: value.copy() for name, value in cached.items()} + for value in result.values(): + value.setflags(write=False) + return result + + +def filterbank_fingerprint() -> dict: + """Return stable identifiers used in compiled-HRTF cache keys.""" + centers = np.ascontiguousarray(_BAND_CENTER_FREQUENCIES_HZ, dtype=" None: + self.history.fill(0.0) + + def process_chunk(self, hops) -> np.ndarray: + values = np.asarray(hops, dtype=np.float64) + if values.ndim != 3 or values.shape[1:] != (self.channels, 64): + raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}") + if not np.isfinite(values).all(): + raise ValueError("QMF input contains non-finite values") + count = values.shape[0] + joined = np.concatenate((self.history, values), axis=0) + even = np.zeros_like(values) + odd = np.zeros_like(values) + for lag in range(10): + source = joined[9 - lag:9 - lag + count] + target = even if lag % 2 == 0 else odd + target += source * self.coefficients[:, lag][None, None, :] + self.history[:] = joined[-9:] + + def transform(block): + prepared = block.astype(np.complex128, copy=False) * self.premod + transformed = np.fft.fft(prepared, n=128, axis=-1)[..., :64] + return transformed * self.post + + return np.asarray(transform(odd) + transform(even) * self.even_post, + dtype=np.complex128) + + +class HybridAnalysis: + """Sparse 64-QMF to 77-hybrid analysis in float64/complex128.""" + + def __init__(self, channels: int, + table_data: str | Path = DEFAULT_FILTERBANK_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_filterbank_tables(table_data) + self.low_kernel = np.asarray( + tables["hybrid_analysis_low_kernel"], dtype=np.float64) + self.channels = int(channels) + self.history = np.zeros((12, self.channels, 3, 2), dtype=np.float64) + self.high_history = np.zeros( + (6, self.channels, 61), dtype=np.complex128) + + def reset(self) -> None: + self.history.fill(0.0) + self.high_history.fill(0.0) + + def process_chunk(self, qmf) -> np.ndarray: + values = np.asarray(qmf, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, 64): + raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}") + if not np.isfinite(values).all(): + raise ValueError("hybrid-analysis input contains non-finite values") + count = values.shape[0] + low = np.stack((values[:, :, :3].real, values[:, :, :3].imag), axis=-1) + joined = np.concatenate((self.history, low), axis=0) + output = np.zeros((count, self.channels, 77, 2), dtype=np.float64) + for lag in range(13): + source = joined[12 - lag:12 - lag + count] + output[:, :, :16] += np.einsum( + "tcpi,pibo->tcbo", source, self.low_kernel[:, :, lag], + dtype=np.float64, optimize=False) + self.history[:] = joined[-12:] + + high_joined = np.concatenate((self.high_history, values[:, :, 3:]), axis=0) + high = high_joined[:count] + output[:, :, 16:, 0] = high.real + output[:, :, 16:, 1] = high.imag + self.high_history[:] = high_joined[-6:] + return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128) + + +class HybridSynthesis: + """Instantaneous sparse 77-hybrid to 64-QMF synthesis map.""" + + def __init__(self, channels: int, + table_data: str | Path = DEFAULT_FILTERBANK_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_filterbank_tables(table_data) + indices = np.asarray(tables["hybrid_synthesis_indices"], dtype=np.int64) + values = np.asarray(tables["hybrid_synthesis_values"], dtype=np.float64) + if indices.ndim != 2 or indices.shape[1] != 4 or len(indices) != len(values): + raise ValueError("invalid hybrid synthesis sparse table") + self.mapping = [ + (int(index[0]), int(index[1]), int(index[2]), int(index[3]), float(value)) + for index, value in zip(indices, values) + ] + self.channels = int(channels) + + def reset(self) -> None: + return None + + def process_chunk(self, hybrid) -> np.ndarray: + values = np.asarray(hybrid, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, 77): + raise ValueError(f"expected [slots,{self.channels},77], got {values.shape}") + if not np.isfinite(values).all(): + raise ValueError("hybrid-synthesis input contains non-finite values") + source = np.stack((values.real, values.imag), axis=-1) + output = np.zeros((values.shape[0], self.channels, 64, 2), dtype=np.float64) + for input_band, input_component, output_band, output_component, gain in self.mapping: + output[:, :, output_band, output_component] += ( + source[:, :, input_band, input_component] * gain) + return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128) + + +class QmfSynthesis: + """Rank-4 64-band synthesis with float64 state and accumulation.""" + + def __init__(self, channels: int, + table_data: str | Path = DEFAULT_FILTERBANK_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_filterbank_tables(table_data) + self.basis = np.asarray(tables["qmf_synthesis_basis"], dtype=np.float64) + self.taps = np.asarray(tables["qmf_synthesis_taps"], dtype=np.float64) + if self.basis.shape != (64, 4, 128) or self.taps.shape != (64, 10, 4): + raise ValueError("invalid QMF synthesis factorization") + self.channels = int(channels) + self.rank = 4 + self.history = np.zeros( + (9, self.channels, 64, self.rank), dtype=np.float64) + + def reset(self) -> None: + self.history.fill(0.0) + + def process_chunk(self, qmf) -> np.ndarray: + values = np.asarray(qmf, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, 64): + raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}") + if not np.isfinite(values).all(): + raise ValueError("QMF-synthesis input contains non-finite values") + count = values.shape[0] + flat = np.stack((values.real, values.imag), axis=-1).reshape( + count * self.channels, 128) + modulation = self.basis.reshape(64 * self.rank, 128) + features = (flat @ modulation.T).reshape( + count, self.channels, 64, self.rank) + joined = np.concatenate((self.history, features), axis=0) + output = np.zeros((count, self.channels, 64), dtype=np.float64) + for lag in range(10): + output += np.sum( + joined[9 - lag:9 - lag + count] + * self.taps[:, lag, :][None, None, :, :], + axis=-1, dtype=np.float64) + self.history[:] = joined[-9:] + return output + + +class PublicAnalysis77: + """Full-rate PCM to the public 77-band hybrid representation.""" + + def __init__(self, channels: int): + self.channels = int(channels) + if self.channels <= 0: + raise ValueError("channels must be positive") + self.qmf = QmfAnalysis(self.channels) + self.hybrid = HybridAnalysis(self.channels) + + def reset(self) -> None: + self.qmf.reset() + self.hybrid.reset() + + def process(self, samples) -> np.ndarray: + values = np.asarray(samples, dtype=np.float64) + if values.ndim == 1 and self.channels == 1: + values = values[:, None] + if values.ndim != 2 or values.shape[1] != self.channels: + raise ValueError(f"samples must have shape [N,{self.channels}]") + if len(values) % QMF_HOP: + raise ValueError("sample count must be divisible by the 64-sample QMF hop") + if not np.isfinite(values).all(): + raise ValueError("samples contain non-finite values") + hops = values.reshape(-1, QMF_HOP, self.channels).transpose(0, 2, 1) + return np.asarray( + self.hybrid.process_chunk(self.qmf.process_chunk(hops)), + dtype=np.complex128) + + +class PublicSynthesis77: + """Public 77-band hybrid representation to full-rate PCM.""" + + def __init__(self, channels: int): + self.channels = int(channels) + if self.channels <= 0: + raise ValueError("channels must be positive") + self.hybrid = HybridSynthesis(self.channels) + self.qmf = QmfSynthesis(self.channels) + + def reset(self) -> None: + self.hybrid.reset() + self.qmf.reset() + + def process(self, hybrid) -> np.ndarray: + values = np.asarray(hybrid, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, HYBRID_BANDS): + raise ValueError( + f"hybrid must have shape [slots,{self.channels},{HYBRID_BANDS}]") + if not np.isfinite(values).all(): + raise ValueError("hybrid input contains non-finite values") + qmf = self.hybrid.process_chunk(values) + time = self.qmf.process_chunk(qmf) + return np.asarray(time.transpose(0, 2, 1).reshape(-1, self.channels), + dtype=np.float64) + + +def identity_impulse_response(sample_count: int = 4096) -> np.ndarray: + sample_count = int(sample_count) + if sample_count <= 0: + raise ValueError("sample_count must be positive") + total = ((sample_count + QMF_HOP - 1) // QMF_HOP) * QMF_HOP + impulse = np.zeros((total, 1), dtype=np.float64) + impulse[0, 0] = 1.0 + analysis = PublicAnalysis77(1) + synthesis = PublicSynthesis77(1) + return synthesis.process(analysis.process(impulse))[:, 0] + + +@lru_cache(maxsize=2) +def _hybrid_band_center_frequencies_hz_cached(rate: float) -> np.ndarray: + centers = np.asarray( + _BAND_CENTER_FREQUENCIES_HZ * (rate / SAMPLE_RATE), dtype=np.float64) + centers.setflags(write=False) + return centers + + +def hybrid_band_center_frequencies_hz( + sample_rate_hz: float = SAMPLE_RATE) -> np.ndarray: + """Return the 77 hybrid-band reference center frequencies.""" + rate = float(sample_rate_hz) + if not np.isfinite(rate) or rate <= 0.0: + raise ValueError("sample rate must be positive and finite") + centers = _hybrid_band_center_frequencies_hz_cached(rate).copy() + centers.setflags(write=False) + return centers + + +def table_info() -> dict: + return { + "resource": DEFAULT_FILTERBANK_DATA.name, + "sample_rate_hz": SAMPLE_RATE, + "qmf_bands": QMF_BANDS, + "hybrid_bands": HYBRID_BANDS, + "hop_samples": QMF_HOP, + "analysis_synthesis_latency_samples": ANALYSIS_SYNTHESIS_LATENCY_SAMPLES, + "precision": "float64/complex128", + "fingerprint": filterbank_fingerprint(), + "provenance": { + "qmf": ( + "MPEG-4 AAC/SBR 64 complex QMF analysis (ISO/IEC " + "14496-3/AMD1:2003 4.B.18.2), polyphase form of the public " + "640-tap SBR prototype"), + "hybrid": ( + "3GPP TS 26.405 / ETSI TS 126 405 5.2.2 Table 1 (Q=8/Q=4) " + "with standard half-bin complex modulation"), + "synthesis": ( + "causal left inverse of the public analysis bank " + "(A·W = P, 577-sample QMF delay); 77→64 sparse recombination"), + "resource": "data/rosella_kernels.npz", + }, + } + + +@lru_cache(maxsize=8) +def _hybrid_gain_synthesis_dictionary_cached(count: int) -> np.ndarray: + total = int(np.ceil( + (ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + count + 512) / QMF_HOP) * QMF_HOP) + impulse = np.zeros((total, 1), dtype=np.float64) + impulse[0, 0] = 1.0 + base = PublicAnalysis77(1).process(impulse)[:, 0, :] + parameter_count = 2 * HYBRID_BANDS + hybrid = np.zeros( + (len(base), parameter_count, HYBRID_BANDS), dtype=np.complex128) + for band in range(HYBRID_BANDS): + hybrid[:, 2 * band, band] = base[:, band] + hybrid[:, 2 * band + 1, band] = 1j * base[:, band] + rendered = PublicSynthesis77(parameter_count).process(hybrid) + start = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + dictionary = np.asarray( + rendered[start:start + count], dtype=np.float64).copy() + dictionary.setflags(write=False) + return dictionary + + +def hybrid_gain_synthesis_dictionary(sample_count: int) -> np.ndarray: + """Return the 154-real-parameter analysis/gain/synthesis dictionary. + + Each hybrid band contributes one real-gain and one imaginary-gain column. + The common 961-sample filterbank latency is removed from every column. + """ + count = int(sample_count) + if count <= 0: + raise ValueError("sample_count must be positive") + dictionary = _hybrid_gain_synthesis_dictionary_cached(count).copy() + dictionary.setflags(write=False) + return dictionary + + +def project_hrir_to_hybrid_gains( + hrir, *, embedded_delay_samples=None, + sample_rate_hz: float = SAMPLE_RATE, ridge: float = 1.0e-3, + ) -> tuple[np.ndarray, dict]: + """Project FIRs and remove only a known embedded arrival delay. + + Non-zero SOFA ``Data.Delay`` is external and must be passed as zero here. + A positive onset separated from ``Data.IR`` is de-rotated once, then restored + once by the runtime field. A zero-origin FIR keeps its authored complex + phase and therefore also passes zero. + """ + values = np.asarray(hrir, dtype=np.float64) + if values.ndim != 3 or values.shape[1] != 2 or values.shape[2] <= 0: + raise ValueError("hrir must have shape [M,2,N]") + if not np.isfinite(values).all(): + raise ValueError("hrir contains non-finite values") + regularization = float(ridge) + if not np.isfinite(regularization) or regularization < 0.0: + raise ValueError("projection ridge must be finite and non-negative") + if embedded_delay_samples is None: + delay = np.zeros(values.shape[:2], dtype=np.float64) + else: + delay = np.asarray(embedded_delay_samples, dtype=np.float64) + if delay.shape != values.shape[:2] or not np.isfinite(delay).all(): + raise ValueError("embedded_delay_samples must have finite shape [M,2]") + rate = float(sample_rate_hz) + if not np.isfinite(rate) or rate <= 0.0: + raise ValueError("sample_rate_hz must be positive and finite") + + dictionary = _hybrid_gain_synthesis_dictionary_cached(values.shape[2]) + gram = dictionary.T @ dictionary + scale = float(np.trace(gram)) / gram.shape[0] + system = gram + regularization * scale * np.eye(gram.shape[0], dtype=np.float64) + target = values.reshape(-1, values.shape[2]).T + parameters = np.linalg.solve(system, dictionary.T @ target).T + parts = parameters.reshape(values.shape[0], 2, 2 * HYBRID_BANDS) + transfer = np.asarray(parts[..., 0::2] + 1j * parts[..., 1::2], + dtype=np.complex128) + centers = hybrid_band_center_frequencies_hz(rate) + removal_phase = np.exp( + 2j * np.pi * delay[..., None] * centers[None, None, :] / rate) + aligned = np.asarray(transfer * removal_phase, dtype=np.complex128) + + reconstructed = dictionary @ parameters.T + error = target - reconstructed + reference_energy = np.sum(target * target, axis=0, dtype=np.float64) + error_energy = np.sum(error * error, axis=0, dtype=np.float64) + snr = 10.0 * np.log10( + np.maximum(reference_energy, 1.0e-300) + / np.maximum(error_energy, 1.0e-300)) + report = { + "method": "regularized public analysis/gain/synthesis dictionary", + "dictionary_shape": list(dictionary.shape), + "real_parameters": 2 * HYBRID_BANDS, + "ridge": regularization, + "embedded_delay_samples_min": float(np.min(delay)), + "embedded_delay_samples_max": float(np.max(delay)), + "fir_reconstruction_snr_db_median": float(np.median(snr)), + "fir_reconstruction_snr_db_p05": float(np.percentile(snr, 5.0)), + "fir_reconstruction_snr_db_min": float(np.min(snr)), + "maximum_absolute_hybrid_gain": float(np.max(np.abs(aligned))), + "precision": "float64/complex128", + } + return aligned, report + + +def project_aligned_hrir_to_hybrid_gains( + aligned_hrir, *, ridge: float = 1.0e-3) -> tuple[np.ndarray, dict]: + return project_hrir_to_hybrid_gains(aligned_hrir, ridge=ridge) diff --git a/src/public_room.py b/src/public_room.py new file mode 100644 index 0000000..3e5ce71 --- /dev/null +++ b/src/public_room.py @@ -0,0 +1,253 @@ +"""Project-owned image-source early reflections and shared unitary FDN.""" +from __future__ import annotations + +from dataclasses import dataclass +import math +import numpy as np + + +@dataclass(frozen=True) +class ShoeboxRoomConfig: + dimensions_m: tuple[float, float, float] = (18.0, 18.0, 14.0) + listener_position_m: tuple[float, float, float] = (9.0, 9.0, 7.0) + wall_reflection_gain: tuple[float, float, float, float, float, float] = ( + 0.62, 0.60, 0.58, 0.61, 0.52, 0.56) + speed_of_sound_m_s: float = 343.3 + + def validate(self) -> None: + dimensions = np.asarray(self.dimensions_m, dtype=np.float64) + listener = np.asarray(self.listener_position_m, dtype=np.float64) + gains = np.asarray(self.wall_reflection_gain, dtype=np.float64) + if (dimensions.shape != (3,) or not np.isfinite(dimensions).all() + or np.any(dimensions <= 0.0)): + raise ValueError("room dimensions must be three positive finite values") + if (listener.shape != (3,) or not np.isfinite(listener).all() + or np.any(listener <= 0.0) or np.any(listener >= dimensions)): + raise ValueError("listener must be strictly inside the shoebox") + if (gains.shape != (6,) or not np.isfinite(gains).all() + or np.any(np.abs(gains) >= 1.0)): + raise ValueError("six finite wall gains must have magnitude below one") + if not math.isfinite(self.speed_of_sound_m_s) or self.speed_of_sound_m_s <= 0.0: + raise ValueError("speed of sound must be positive") + + +@dataclass(frozen=True) +class EarlyReflection: + wall: str + direction_adm: np.ndarray + path_distance_m: float + extra_delay_samples: float + reflection_gain: float + + +_WALL_NAMES = ("left", "right", "back", "front", "floor", "ceiling") + + +def first_order_image_sources(direction_adm, source_distance_m: float, + sample_rate_hz: float, + config: ShoeboxRoomConfig = ShoeboxRoomConfig() + ) -> tuple[EarlyReflection, ...]: + """Return six first-order image-source paths for one object.""" + config.validate() + direction = np.asarray(direction_adm, dtype=np.float64) + if direction.shape != (3,) or not np.isfinite(direction).all(): + raise ValueError("reflection direction must contain three finite ADM values") + norm = float(np.linalg.norm(direction)) + if norm <= 1.0e-15: + direction = np.asarray([0.0, 1.0, 0.0], dtype=np.float64) + else: + direction = direction / norm + distance = float(source_distance_m) + rate = float(sample_rate_hz) + if not math.isfinite(distance) or distance <= 0.0 or not math.isfinite(rate) or rate <= 0.0: + raise ValueError("source distance and sample rate must be positive") + dimensions = np.asarray(config.dimensions_m, dtype=np.float64) + listener = np.asarray(config.listener_position_m, dtype=np.float64) + source = listener + direction * distance + if np.any(source <= 0.0) or np.any(source >= dimensions): + raise ValueError( + "source lies outside the configured public shoebox; enlarge the room") + images = [] + for axis in range(3): + low = source.copy() + low[axis] = -source[axis] + high = source.copy() + high[axis] = 2.0 * dimensions[axis] - source[axis] + images.extend((low, high)) + result = [] + for wall, image, gain in zip(_WALL_NAMES, images, config.wall_reflection_gain): + vector = image - listener + path_distance = float(np.linalg.norm(vector)) + path_direction = vector / path_distance + extra = max(0.0, (path_distance - distance) + * rate / config.speed_of_sound_m_s) + air = math.exp(-0.002 * max(path_distance - distance, 0.0)) + result.append(EarlyReflection( + wall=wall, + direction_adm=np.asarray(path_direction, dtype=np.float64), + path_distance_m=path_distance, + extra_delay_samples=extra, + reflection_gain=float(gain) * air, + )) + return tuple(result) + + +def normalized_hadamard4() -> np.ndarray: + return 0.5 * np.asarray([ + [1.0, 1.0, 1.0, 1.0], + [1.0, -1.0, 1.0, -1.0], + [1.0, 1.0, -1.0, -1.0], + [1.0, -1.0, -1.0, 1.0], + ], dtype=np.float64) + + +def _is_prime(value: int) -> bool: + if value < 2: + return False + if value % 2 == 0: + return value == 2 + limit = int(math.sqrt(value)) + return all(value % divisor for divisor in range(3, limit + 1, 2)) + + +def _next_prime(value: int) -> int: + candidate = max(2, int(value)) + while not _is_prime(candidate): + candidate += 1 + return candidate + + +class SchroederAllpass: + def __init__(self, delay_samples: int, gain: float): + self.delay_samples = int(delay_samples) + self.gain = float(gain) + if self.delay_samples <= 0 or not 0.0 <= abs(self.gain) < 1.0: + raise ValueError("all-pass delay must be positive and |gain| < 1") + self.buffer = np.zeros(self.delay_samples, dtype=np.float64) + self.position = 0 + + def reset(self) -> None: + self.buffer.fill(0.0) + self.position = 0 + + def process(self, values) -> np.ndarray: + source = np.asarray(values, dtype=np.float64) + output = np.empty_like(source) + for index, value in enumerate(source): + delayed = self.buffer[self.position] + result = delayed - self.gain * value + self.buffer[self.position] = value + self.gain * result + self.position = (self.position + 1) % self.delay_samples + output[index] = result + return output + + +@dataclass(frozen=True) +class LateFdnConfig: + sample_rate_hz: float = 48000.0 + rt60_seconds: float = 0.85 + damping: float = 0.32 + output_gain: float = 0.22 + delay_seconds: tuple[float, float, float, float] = ( + 0.0297, 0.0371, 0.0411, 0.0437) + allpass_seconds: tuple[float, float] = (0.0023, 0.0067) + allpass_gain: tuple[float, float] = (0.63, 0.51) + + +class SharedUnitaryFdn: + """One shared late room driven by the sum of all object room sends.""" + + def __init__(self, config: LateFdnConfig = LateFdnConfig()): + self.config = config + self.sample_rate_hz = float(config.sample_rate_hz) + self.rt60_seconds = float(config.rt60_seconds) + self.damping = float(config.damping) + self.output_gain = float(config.output_gain) + if (not math.isfinite(self.sample_rate_hz) or self.sample_rate_hz <= 0.0 + or not math.isfinite(self.rt60_seconds) or self.rt60_seconds <= 0.0): + raise ValueError("FDN sample rate and RT60 must be positive and finite") + if (not math.isfinite(self.damping) or not 0.0 <= self.damping < 1.0 + or not math.isfinite(self.output_gain)): + raise ValueError("invalid FDN damping/output gain") + delay_seconds = np.asarray(config.delay_seconds, dtype=np.float64) + allpass_seconds = np.asarray(config.allpass_seconds, dtype=np.float64) + allpass_gain = np.asarray(config.allpass_gain, dtype=np.float64) + if (delay_seconds.shape != (4,) or not np.isfinite(delay_seconds).all() + or np.any(delay_seconds <= 0.0)): + raise ValueError("FDN requires four positive finite delay times") + if (allpass_seconds.shape != (2,) or not np.isfinite(allpass_seconds).all() + or np.any(allpass_seconds <= 0.0)): + raise ValueError("FDN requires two positive finite all-pass delay times") + if (allpass_gain.shape != (2,) or not np.isfinite(allpass_gain).all() + or np.any(np.abs(allpass_gain) >= 1.0)): + raise ValueError("FDN requires two finite all-pass gains with magnitude below one") + self.matrix = normalized_hadamard4() + self.delays = np.asarray([ + _next_prime(round(seconds * self.sample_rate_hz)) + for seconds in delay_seconds + ], dtype=np.int32) + self.feedback_gain = np.power( + 10.0, -3.0 * self.delays / (self.rt60_seconds * self.sample_rate_hz) + ).astype(np.float64) + self.buffers = [np.zeros(int(delay), dtype=np.float64) for delay in self.delays] + self.positions = np.zeros(4, dtype=np.int32) + self.damping_state = np.zeros(4, dtype=np.float64) + self.input_vector = 0.5 * np.asarray([1.0, -1.0, 1.0, 1.0], dtype=np.float64) + self.output_matrix = 0.5 * np.asarray([ + [1.0, 1.0, -1.0, -1.0], + [1.0, -1.0, 1.0, -1.0], + ], dtype=np.float64) + self.diffusers = [ + SchroederAllpass( + _next_prime(round(seconds * self.sample_rate_hz)), gain) + for seconds, gain in zip(allpass_seconds, allpass_gain) + ] + + @property + def tail_samples(self) -> int: + return int(math.ceil(1.5 * self.rt60_seconds * self.sample_rate_hz)) + + def reset(self) -> None: + for buffer in self.buffers: + buffer.fill(0.0) + self.positions.fill(0) + self.damping_state.fill(0.0) + for diffuser in self.diffusers: + diffuser.reset() + + def process(self, mono) -> np.ndarray: + values = np.asarray(mono, dtype=np.float64) + if values.ndim != 1 or not np.isfinite(values).all(): + raise ValueError("FDN input must be one finite mono vector") + diffused = values + for diffuser in self.diffusers: + diffused = diffuser.process(diffused) + output = np.empty((len(values), 2), dtype=np.float64) + for sample, value in enumerate(diffused): + delayed = np.asarray([ + self.buffers[line][int(self.positions[line])] + for line in range(4) + ], dtype=np.float64) + self.damping_state = ( + self.damping * self.damping_state + (1.0 - self.damping) * delayed) + output[sample] = self.output_gain * (self.output_matrix @ self.damping_state) + feedback = self.matrix @ (self.damping_state * self.feedback_gain) + write = self.input_vector * value + feedback + for line in range(4): + position = int(self.positions[line]) + self.buffers[line][position] = write[line] + self.positions[line] = (position + 1) % int(self.delays[line]) + return output + + def info(self) -> dict: + return { + "name": "SharedUnitaryFdn", + "sample_rate_hz": self.sample_rate_hz, + "rt60_seconds": self.rt60_seconds, + "delay_samples": [int(value) for value in self.delays], + "feedback_gain": [float(value) for value in self.feedback_gain], + "matrix_unitarity_max_error": float( + np.max(np.abs(self.matrix.T @ self.matrix - np.eye(4)))), + "allpass_delay_samples": [value.delay_samples for value in self.diffusers], + "precision": "float64", + } diff --git a/src/reference_distance.py b/src/reference_distance.py new file mode 100644 index 0000000..c45daa2 --- /dev/null +++ b/src/reference_distance.py @@ -0,0 +1,115 @@ +"""Project-owned distance policy for the public SOFA renderer.""" +from __future__ import annotations + +from dataclasses import dataclass +import math +import numpy as np + + +@dataclass(frozen=True) +class DistanceState: + profile: str + normalized_radius: float + reference_distance_m: float + physical_distance_m: float + direction_adm: np.ndarray + + +class ReferenceDistanceProfileV1: + """Reference behavior, not a claim about any external public standard.""" + + DISTANCE_M = { + "near": 1.00000465, + "mid": 2.19327927, + "far": 6.40177584, + } + MINIMUM_DISTANCE_M = 0.10 + # Distance profiles in the reference renderer are presentation presets, + # not an instruction to attenuate already-authored programme PCM by 1/r. + # Use an energy-normalized dry/room crossfade instead. The coefficient is + # an explicit project calibration target. + ROOM_ENERGY_COUPLING_PER_M2 = 0.01318359375 + PUBLIC_ROOM_CALIBRATION_GAIN = 1.4 + # Public, project-owned room coupling; it is not a SOFA or Dolby constant. + LATE_SEND = { + "near": 0.06, + "mid": 0.16, + "far": 0.28, + } + + @classmethod + def validate_profile(cls, profile: str) -> str: + value = str(profile).strip().lower() + if value not in cls.DISTANCE_M: + raise ValueError("distance profile must be near, mid, or far") + return value + + @classmethod + def map_adm_position(cls, position, profile: str) -> DistanceState: + name = cls.validate_profile(profile) + values = np.asarray(position, dtype=np.float64) + if values.shape != (3,) or not np.isfinite(values).all(): + raise ValueError("ADM position must contain three finite Cartesian values") + radius = float(np.linalg.norm(values)) + direction = (values / radius if radius > 1.0e-15 + else np.asarray([0.0, 1.0, 0.0], dtype=np.float64)) + reference = float(cls.DISTANCE_M[name]) + distance = max(float(cls.MINIMUM_DISTANCE_M), radius * reference) + return DistanceState( + profile=name, + normalized_radius=radius, + reference_distance_m=reference, + physical_distance_m=distance, + direction_adm=np.asarray(direction, dtype=np.float64), + ) + + @staticmethod + def inverse_distance_gain(measurement_radius_m: float, + path_distance_m: float) -> float: + radius = float(measurement_radius_m) + distance = float(path_distance_m) + if not (math.isfinite(radius) and math.isfinite(distance)): + raise ValueError("measurement and path distances must be finite") + if radius <= 0.0 or distance <= 0.0: + raise ValueError("measurement and path distances must be positive") + return radius / distance + + @classmethod + def direct_level_gain(cls, state: DistanceState) -> float: + """Programme-normalized direct level for a distance presentation. + + Near is the SOFA reference response. Mid/Far use an equal-power dry + coefficient rather than a physical free-field 1/r attenuation. Room + distance still changes through image-path lengths and late send. + """ + if state.profile == "near": + return 1.0 + distance = float(state.physical_distance_m) + return 1.0 / math.sqrt( + 1.0 + cls.ROOM_ENERGY_COUPLING_PER_M2 * distance * distance) + + @classmethod + def room_calibration_gain(cls, state: DistanceState) -> float: + del state + return float(cls.PUBLIC_ROOM_CALIBRATION_GAIN) + + @classmethod + def late_send(cls, state: DistanceState) -> float: + base = float(cls.LATE_SEND[state.profile]) + radial = math.sqrt(max(state.normalized_radius, 0.0)) + return base * min(max(radial, 0.25), 1.5) + + @classmethod + def info(cls) -> dict: + return { + "name": "ReferenceDistanceProfileV1", + "reference_distance_m": dict(cls.DISTANCE_M), + "minimum_distance_m": cls.MINIMUM_DISTANCE_M, + "direct_level_policy": ( + "Near unity; Mid/Far equal-power dry coefficient, never raw 1/r " + "programme attenuation"), + "room_energy_coupling_per_m2": cls.ROOM_ENERGY_COUPLING_PER_M2, + "public_room_calibration_gain": cls.PUBLIC_ROOM_CALIBRATION_GAIN, + "late_send": dict(cls.LATE_SEND), + "standard_claim": False, + } diff --git a/src/rosella_binaural_renderer.py b/src/rosella_binaural_renderer.py new file mode 100644 index 0000000..5701228 --- /dev/null +++ b/src/rosella_binaural_renderer.py @@ -0,0 +1,308 @@ +"""Rosella .personalized_headphone binaural renderer. + +Rosella JSON 解析由本项目自行实现(src/rosella_model.py),不调用任何 Dolby +软件;.personalized_headphone 是用户经官方软件个性化扫描得到的模型文件。 +该路径与 SOFA 路径各自独立完成 HRTF/room 参数求值,只在最外层的 JOC 调度 +(1536-sample 帧缓冲、sample-timed OAMD timeline、512-sample 参数更新、输出 +包装)处汇合。 +""" +from __future__ import annotations + +import hashlib +import math +from pathlib import Path + +import numpy as np + +from binaural_metadata import OamdPositionTimeline +from binaural_native_renderer import NativeBinauralDsp +from rosella_core import RosellaRenderer +from rosella_direct import BINAURAL_PROFILE_NAMES +from rosella_filterbank import ( + DEFAULT_KERNEL_DATA, + HybridAnalysis, + HybridSynthesis, + QmfAnalysis, + QmfSynthesis, +) +from rosella_model import RosellaModel, load_personalized_headphone + +SAMPLE_RATE = 48000 +FRAME_SAMPLES = 1536 +ROSSELLA_BLOCK_SAMPLES = 512 +QMF_HOP_SAMPLES = 64 +ROSSELLA_LATENCY_SAMPLES = 961 +SOURCE_CHANNELS = 16 +OUTPUT_CHANNELS = 2 +PROJECT_DIR = Path(__file__).resolve().parent.parent +DEFAULT_PERSONALIZED_HEADPHONE = ( + PROJECT_DIR / "HRTF" / "binaural.personalized_headphone") + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def resolve_personalized_headphone(path: str | Path | None = None) -> Path: + target = (DEFAULT_PERSONALIZED_HEADPHONE if path is None + else Path(path).expanduser().resolve()) + if not target.is_file(): + raise FileNotFoundError( + f"未找到双耳模型:{target}\n" + "请将兼容模型保存为 HRTF/binaural.personalized_headphone," + "或通过参数指定文件。" + ) + return target + + +class RosellaBinauralRenderer: + """Render interleaved LFE plus fifteen objects to stereo.""" + + def __init__( + self, + personalized_headphone: str | Path | RosellaModel, + *, + mode: str = "mid", + kernel_data: str | Path = DEFAULT_KERNEL_DATA, + object_delay_samples: int = 1473, + tail_seconds: float = 5.0, + output_gain: float = 1.0, + chunk_frames: int = 64, + room_impulse_slots: int = 4096, + backend: str = "python", + native_library=None): + if mode not in BINAURAL_PROFILE_NAMES: + raise ValueError("binaural mode must be near, mid, or far") + if int(object_delay_samples) < 0: + raise ValueError("object_delay_samples must be non-negative") + if float(tail_seconds) < 0.0: + raise ValueError("tail_seconds must be non-negative") + if int(chunk_frames) <= 0: + raise ValueError("chunk_frames must be positive") + if not math.isfinite(float(output_gain)): + raise ValueError("output_gain must be finite") + if backend not in ("auto", "native", "python"): + raise ValueError("backend must be auto, native, or python") + + if isinstance(personalized_headphone, RosellaModel): + self.model = personalized_headphone + self.model_path = Path(self.model.source_path) + else: + self.model_path = resolve_personalized_headphone(personalized_headphone) + self.model = load_personalized_headphone(self.model_path) + if self.model.sample_rate != SAMPLE_RATE: + raise ValueError( + f"Rosella model sample rate must be {SAMPLE_RATE}, got {self.model.sample_rate}") + + self.mode = mode + self.profile_index = BINAURAL_PROFILE_NAMES[mode] + self.kernel_data = Path(kernel_data).expanduser().resolve() + self.kernel_data_sha256 = _sha256_file(self.kernel_data) + self.object_delay_samples = int(object_delay_samples) + self.tail_seconds = float(tail_seconds) + self.output_gain = np.float64(output_gain) + self.chunk_frames = int(chunk_frames) + self.chunk_samples = self.chunk_frames * FRAME_SAMPLES + + self.native_dsp = None + self.backend_fallback = None + if backend in ("auto", "native"): + try: + self.native_dsp = NativeBinauralDsp( + self.model, library_path=native_library, + kernel_data=self.kernel_data) + except (AttributeError, OSError, RuntimeError) as exc: + if backend == "native": + raise RuntimeError(f"native binaural backend unavailable: {exc}") from exc + self.backend_fallback = str(exc) + if self.native_dsp is not None: + self.dsp_backend = "native" + self.qmf_analysis = None + self.hybrid_analysis = None + self.hybrid_synthesis = None + self.qmf_synthesis = None + self.core = RosellaRenderer( + self.model, SOURCE_CHANNELS, create_room=False) + else: + self.dsp_backend = "python" + self.qmf_analysis = QmfAnalysis(SOURCE_CHANNELS, self.kernel_data) + self.hybrid_analysis = HybridAnalysis(SOURCE_CHANNELS, self.kernel_data) + self.core = RosellaRenderer( + self.model, SOURCE_CHANNELS, + room_impulse_slots=room_impulse_slots) + self.hybrid_synthesis = HybridSynthesis(OUTPUT_CHANNELS, self.kernel_data) + self.qmf_synthesis = QmfSynthesis(OUTPUT_CHANNELS, self.kernel_data) + 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.raw_output_samples = 0 + self.output_samples = 0 + self.finished = False + self.metadata_block_updates = 0 + + 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): + positions = self.timeline.positions_at(sample) + self.core.set_source(0, (0.0, 1.0, 0.0), special_lfe=True) + for object_index in range(15): + self.core.set_source( + object_index + 1, positions[object_index], self.profile_index) + + 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) % ROSSELLA_BLOCK_SAMPLES: + raise ValueError("binaural input must be divisible by 512 samples") + blocks = len(values) // ROSSELLA_BLOCK_SAMPLES + block_base = self.processed_input_samples + + if self.native_dsp is not None: + stereo = np.empty((len(values), OUTPUT_CHANNELS), dtype=np.float64) + for block in range(blocks): + sample = block_base + block * ROSSELLA_BLOCK_SAMPLES + self._set_block_parameters(sample) + start = block * ROSSELLA_BLOCK_SAMPLES + stop = start + ROSSELLA_BLOCK_SAMPLES + stereo[start:stop] = self.native_dsp.process_block( + values[start:stop], self.core.gains, self.core.room_sends, + self.output_gain) + else: + hops = values.reshape( + blocks, ROSSELLA_BLOCK_SAMPLES // QMF_HOP_SAMPLES, + QMF_HOP_SAMPLES, SOURCE_CHANNELS, + ).transpose(0, 1, 3, 2).reshape( + blocks * (ROSSELLA_BLOCK_SAMPLES // QMF_HOP_SAMPLES), + SOURCE_CHANNELS, QMF_HOP_SAMPLES) + hybrid = self.hybrid_analysis.process_chunk( + self.qmf_analysis.process_chunk(hops)) + direct = np.empty((blocks * 8, OUTPUT_CHANNELS, 77), dtype=np.complex128) + room_send = np.empty((blocks * 8, 77), dtype=np.complex128) + for block in range(blocks): + sample = block_base + block * ROSSELLA_BLOCK_SAMPLES + self._set_block_parameters(sample) + start = block * 8 + stop = start + 8 + direct[start:stop], room_send[start:stop] = ( + self.core.direct_and_send_static(hybrid[start:stop])) + rendered = direct + self.core.room.process_chunk(room_send) + time_bands = self.qmf_synthesis.process_chunk( + self.hybrid_synthesis.process_chunk(rendered)) + stereo = time_bands.transpose(0, 2, 1).reshape( + blocks * ROSSELLA_BLOCK_SAMPLES, OUTPUT_CHANNELS) + stereo *= self.output_gain + + skip = max(0, min( + len(stereo), ROSSELLA_LATENCY_SAMPLES - self.raw_output_samples)) + self.raw_output_samples += len(stereo) + self.processed_input_samples += len(values) + output = stereo[skip:] + self.output_samples += len(output) + return output + + def finish(self) -> np.ndarray: + """Process pending source samples and preserve the configured room tail.""" + 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 + flush_samples = math.ceil( + (self.tail_seconds * SAMPLE_RATE + + ROSSELLA_LATENCY_SAMPLES + ROSSELLA_BLOCK_SAMPLES) + / ROSSELLA_BLOCK_SAMPLES) * ROSSELLA_BLOCK_SAMPLES + while flush_samples: + count = min(flush_samples, self.chunk_samples) + zero = np.zeros((count, SOURCE_CHANNELS), dtype=np.float64) + outputs.append(self._process_samples(zero)) + flush_samples -= count + self.finished = True + nonempty = [value for value in outputs if len(value)] + if not nonempty: + return np.empty((0, OUTPUT_CHANNELS), dtype=np.float64) + return np.concatenate(nonempty, axis=0) + + def close(self): + if self.native_dsp is not None: + self.native_dsp.close() + self.finished = True + + @property + def backend_info(self) -> dict: + return { + "name": self.dsp_backend, + "precision": "float64/complex128", + "fallback_reason": self.backend_fallback, + "library": (str(self.native_dsp.library_path) + if self.native_dsp is not None else None), + "model": str(self.model_path.resolve()), + "model_coefficients": int(len(self.model.coefficients)), + "model_coefficient_sha256": self.model.coefficient_sha256, + "model_version": self.model.coefficient_version, + "kernel_data": str(self.kernel_data), + "kernel_data_sha256": self.kernel_data_sha256, + "mode": self.mode, + "latency_compensated_samples": ROSSELLA_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, + "processed_samples_including_flush": self.processed_input_samples, + "output_samples_before_tail_trim": self.output_samples, + } diff --git a/src/rosella_core.py b/src/rosella_core.py new file mode 100644 index 0000000..e809638 --- /dev/null +++ b/src/rosella_core.py @@ -0,0 +1,81 @@ +"""Stateful float64/complex128 Rosella hybrid-band renderer.""" +from __future__ import annotations + +import numpy as np + +from rosella_direct import ( + PROFILE_MID, + direct_and_room_send, + special_lfe_direct, +) +from rosella_model import RosellaModel +from rosella_room import RosellaRoomFir + + +class RosellaRenderer: + """Hold per-source direct parameters and the cross-block room state.""" + + def __init__(self, model: RosellaModel, source_count: int, + room_impulse_slots: int = 4096, *, create_room: bool = True): + if source_count <= 0: + raise ValueError("source_count must be positive") + self.model = model + self.source_count = int(source_count) + self.room = (RosellaRoomFir(model, impulse_slots=room_impulse_slots) + if create_room else None) + self.positions = np.zeros((self.source_count, 3), dtype=np.float64) + self.positions[:, 1] = 1.0 + self.profiles = np.full(self.source_count, PROFILE_MID, dtype=np.int32) + self.special_lfe = np.zeros(self.source_count, dtype=bool) + self.gains = np.empty( + (self.source_count, 2, 77), dtype=np.complex128) + self.room_sends = np.empty(self.source_count, dtype=np.float64) + self._parameter_keys = [None] * self.source_count + for source in range(self.source_count): + self.set_source(source, self.positions[source], PROFILE_MID) + + def reset(self): + if self.room is not None: + self.room.reset() + + def set_source(self, source: int, position, profile: int = PROFILE_MID, + *, special_lfe: bool = False): + source = int(source) + if not 0 <= source < self.source_count: + raise IndexError(source) + coordinates = np.asarray(position, dtype=np.float64) + if coordinates.shape != (3,) or not np.all(np.isfinite(coordinates)): + raise ValueError(f"source position must be three finite values, got {position!r}") + effective_profile = 0 if special_lfe else int(profile) + key = ((bool(special_lfe), effective_profile) + + tuple(float(value) for value in coordinates)) + if self._parameter_keys[source] == key: + return + self.positions[source] = coordinates + self.profiles[source] = effective_profile + self.special_lfe[source] = bool(special_lfe) + parameters = (special_lfe_direct() if special_lfe else + direct_and_room_send(self.model, coordinates, effective_profile)) + self.gains[source] = parameters.gains + self.room_sends[source] = parameters.room_send + self._parameter_keys[source] = key + + def direct_and_send_static(self, sources): + """Mix one static-parameter slot chunk without advancing room state.""" + values = np.asarray(sources, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.source_count, 77): + raise ValueError( + f"expected [slots,{self.source_count},77], got {values.shape}") + direct = np.zeros((values.shape[0], 2, 77), dtype=np.complex128) + room_send = np.zeros((values.shape[0], 77), dtype=np.complex128) + for source in range(self.source_count - 1, -1, -1): + direct += values[:, source, None, :] * self.gains[source][None, :, :] + room_send += values[:, source, :] * self.room_sends[source] + return direct, room_send + + def process_static_chunk(self, sources) -> np.ndarray: + direct, room_send = self.direct_and_send_static(sources) + if self.room is None: + raise RuntimeError("room renderer is not configured") + direct += self.room.process_chunk(room_send) + return direct diff --git a/src/rosella_direct.py b/src/rosella_direct.py new file mode 100644 index 0000000..6dcee26 --- /dev/null +++ b/src/rosella_direct.py @@ -0,0 +1,302 @@ +"""Float64 Rosella direction, distance, HRTF, and room-send calculations.""" +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy as np + +from rosella_model import RosellaModel, direction_basis + +PROFILE_NEAR = 1 +PROFILE_FAR = 2 +PROFILE_MID = 3 +BINAURAL_PROFILE_NAMES = { + "near": PROFILE_NEAR, + "far": PROFILE_FAR, + "mid": PROFILE_MID, +} + +_SPECIAL_LFE_LOW_16 = np.asarray([ + 0x402695EA, 0x3FE75979, 0x3F28CAAA, 0xBCE1FB2E, + 0xBDD8AF65, 0xBD8F426E, 0x3D996821, 0xBC16B3A0, + 0x3B64BAF1, 0xBC81ECFD, 0xBA3D892F, 0x3AF6A9F0, + 0xB9DD1C5F, 0x380A193F, 0x38052059, 0x351BCB34, +], dtype=np.uint32).view(np.float32).astype(np.float64) +_CENTRE_EQUAL = 0.9998489618301392 +_CENTRE_ALTERNATE = 0.7070000171661377 + +_FIELD_CACHE: dict[int, tuple[np.ndarray, np.ndarray]] = {} + + +@dataclass(frozen=True) +class DirectResult: + gains: np.ndarray # complex128 [ear=2, hybrid_band=77] + room_send: np.float64 + physical_radius_m: np.float64 + normalized_radius: np.float64 + clamped_radius: np.float64 + delay_samples: np.float64 + delayed_ear: int | None + + +def special_lfe_direct() -> DirectResult: + """Return the fixed 16-band low-pass used by a special/LFE source.""" + mono = np.zeros(77, dtype=np.complex128) + mono[:16] = _SPECIAL_LFE_LOW_16 + return DirectResult( + gains=np.repeat(mono[None, :], 2, axis=0), + room_send=np.float64(0.0), + physical_radius_m=np.float64(0.0), + normalized_radius=np.float64(0.0), + clamped_radius=np.float64(0.0), + delay_samples=np.float64(0.0), + delayed_ear=None, + ) + + +def _round_away_from_zero(value: float) -> int: + return math.floor(value + 0.5) if value >= 0.0 else math.ceil(value - 0.5) + + +def _q15_position(position) -> np.ndarray: + """Quantize ADM Cartesian coordinates to the Rosella metadata grid. + + Quantization is metadata decoding. The returned integer lanes are promoted + to float64 before any geometry is evaluated. + """ + x, y, z = (float(value) for value in position) + encoded = ( + min(max((x + 1.0) * 0.5, 0.0), 1.0), + min(max((1.0 - y) * 0.5, 0.0), 1.0), + min(max(z, -1.0), 1.0), + ) + return np.asarray([ + min(_round_away_from_zero(value * 32768.0), 32767) + for value in encoded + ], dtype=np.int32) + + +def _profile_geometry(model: RosellaModel, position, profile_index: int): + if profile_index not in (PROFILE_NEAR, PROFILE_FAR, PROFILE_MID): + raise ValueError("binaural object profile must be near, mid, or far") + profile = model.profiles[profile_index] + encoded = _q15_position(position) + q_front = 1.0 - 2.0 * float(encoded[1]) / 32768.0 + q_x = 2.0 * float(encoded[0]) / 32768.0 - 1.0 + q_vertical = float(encoded[2]) / 32768.0 + + if int(model.header_integer_fields[0]) != 0: + if q_x == 0.0 and q_front == 0.0: + mapped_front = 0.0 + mapped_lateral = 0.0 + mapped_vertical = q_vertical + else: + horizontal_max = max(abs(q_x), abs(q_front)) + horizontal_norm = ((q_x / horizontal_max) ** 2 + + (q_front / horizontal_max) ** 2) + if q_vertical == 0.0: + vertical_norm = 1.0 + else: + smaller = min(abs(q_vertical), horizontal_max) + larger = max(abs(q_vertical), horizontal_max) + vertical_norm = 1.0 + (smaller / larger) ** 2 + horizontal_factor = 1.0 / math.sqrt(horizontal_norm * vertical_norm) + vertical_factor = 1.0 / math.sqrt(vertical_norm) + mapped_front = q_front * horizontal_factor + mapped_lateral = -q_x * horizontal_factor + mapped_vertical = q_vertical * vertical_factor + else: + mapped_front = q_front + mapped_lateral = -q_x + mapped_vertical = q_vertical + + scales = np.asarray(profile.axis_scales_internal, dtype=np.float64) + scaled = np.asarray([ + mapped_front * scales[2], + mapped_lateral * scales[0], + mapped_vertical * scales[1], + ], dtype=np.float64) + bounds = np.asarray(profile.bounds, dtype=np.float64) + ray = 1.0 + for axis in range(3): + value = scaled[axis] + lower, upper = bounds[axis * 2:axis * 2 + 2] + if value < lower: + ray = min(ray, lower / value) + elif value > upper: + ray = min(ray, upper / value) + if ray < 1.0: + scaled *= ray + + radius = float(np.linalg.norm(scaled)) + clamped = max(radius, float(profile.minimum_normalized_radius)) + alpha = radius / clamped + direction = (scaled / radius if radius > 1.0e-30 + else np.asarray([1.0, 0.0, 0.0], dtype=np.float64)) + return profile, direction, radius, clamped, alpha + + +def _logical_field(padded: np.ndarray) -> np.ndarray: + result = np.empty((77, 36, 2), dtype=np.float64) + source = np.asarray(padded, dtype=np.float64) + for band in range(77): + block, lane = divmod(band, 4) + for term in range(36): + for component in range(2): + result[band, term, component] = source[ + lane + 4 * (term * 2 + component + 72 * block)] + return result + + +def _model_fields(model: RosellaModel) -> tuple[np.ndarray, np.ndarray]: + key = id(model) + fields = _FIELD_CACHE.get(key) + if fields is None: + fields = (_logical_field(model.field_left_padded), + _logical_field(model.field_right_padded)) + _FIELD_CACHE[key] = fields + return fields + + +def _ear_geometry(model: RosellaModel, profile, direction, clamped: float, + offset: float, correction: float): + x, y, z = (float(value) for value in direction) + inverse_distance = float(profile.inverse_distance_per_m) + ear = float(offset) * inverse_distance / clamped + y_minus = y - ear + y_plus = y + ear + common = x * x + z * z + length_minus = math.sqrt(y_minus * y_minus + common) + length_plus = math.sqrt(y_plus * y_plus + common) + basis_minus = direction_basis( + x / length_minus, y_minus / length_minus, z / length_minus, + dtype=np.float64) + basis_plus = direction_basis( + x / length_plus, y_plus / length_plus, z / length_plus, + dtype=np.float64) + path_minus = length_minus * clamped + path_plus = length_plus * clamped + if correction != 0.0: + multiplier = 2.0 * float(correction) * inverse_distance + path_minus += max(float(np.dot( + np.asarray(model.vector_left, dtype=np.float64), basis_minus)), 0.0) * multiplier + path_plus += max(float(np.dot( + np.asarray(model.vector_right, dtype=np.float64), basis_plus)), 0.0) * multiplier + return basis_minus, basis_plus, path_minus, path_plus + + +def _phase_groups(model: RosellaModel, delay_samples: float) -> np.ndarray: + result = np.ones(77, dtype=np.complex128) + current = 1.0 + 0.0j + step = 1.0 + 0.0j + value_index = 0 + for band, flag in enumerate(model.hybrid_flags): + if flag != 2: + if flag == 1: + angle = float(model.hybrid_values[value_index]) * delay_samples + value_index += 1 + step = complex(math.cos(angle), math.sin(angle)) + current *= step + result[band] = current + return result + + +def direct_and_room_send(model: RosellaModel, position, + profile_index: int) -> DirectResult: + """Evaluate one ordinary source using float64/complex128 throughout.""" + profile, direction, radius, clamped, alpha = _profile_geometry( + model, position, profile_index) + + _, _, path_minus, path_plus = _ear_geometry( + model, profile, direction, clamped, + float(model.model_scalars[1]), float(model.model_scalars[2])) + delay = (abs(path_plus - path_minus) + * float(profile.distance_scale_m) + * (float(model.sample_rate) / 343.3) * alpha) + delayed_ear = 0 if path_minus > path_plus else ( + 1 if path_plus > path_minus else None) + + _, _, weight_minus_path, weight_plus_path = _ear_geometry( + model, profile, direction, clamped, + float(model.model_scalars[3]), float(model.model_scalars[4])) + weight_norm = math.sqrt( + weight_minus_path * weight_minus_path + + weight_plus_path * weight_plus_path) + weight_left = weight_plus_path / weight_norm + weight_right = weight_minus_path / weight_norm + + final_offset = float(model.model_scalars[0]) + if final_offset == 0.0: + basis_minus = direction_basis(*direction, dtype=np.float64) + basis_plus = basis_minus.copy() + else: + x, y, z = (float(value) for value in direction) + ear = final_offset * float(profile.inverse_distance_per_m) / clamped + y_minus = y - ear + y_plus = y + ear + common_length = x * x + z * z + length_minus = math.sqrt(y_minus * y_minus + common_length) + length_plus = math.sqrt(y_plus * y_plus + common_length) + basis_minus = direction_basis( + x / length_minus, y_minus / length_minus, z / length_minus, + dtype=np.float64) + basis_plus = direction_basis( + x / length_plus, y_plus / length_plus, z / length_plus, + dtype=np.float64) + + field_left, field_right = _model_fields(model) + left_components = np.einsum( + "bjc,j->bc", field_left, basis_minus, + dtype=np.float64, optimize=False) + right_components = np.einsum( + "bjc,j->bc", field_right, basis_plus, + dtype=np.float64, optimize=False) + left = left_components[:, 0] + 1j * left_components[:, 1] + right = right_components[:, 0] + 1j * right_components[:, 1] + if delayed_ear is not None: + phase = _phase_groups(model, delay) + if delayed_ear == 0: + left *= phase + else: + right *= phase + + effective_radius = (radius * float(model.header_float_scalars[0]) + * float(profile.distance_scale_m)) + if profile_index in (PROFILE_FAR, PROFILE_MID): + common = 1.0 / math.sqrt( + 1.0 + float(model.header_float_scalars[1]) + * effective_radius * effective_radius) + room_send = effective_radius * common + else: + common = 1.0 + room_send = 0.0 + + left_term0 = field_left[:, 0, 0] + 1j * field_left[:, 0, 1] + right_term0 = field_right[:, 0, 0] + 1j * field_right[:, 0, 1] + weights_are_default_equal = ( + float(model.model_scalars[3]) == 0.0 + and float(model.model_scalars[4]) == 0.0) + if weights_are_default_equal: + centre_left = weight_left * (1.0 - alpha) * _CENTRE_EQUAL + centre_right = centre_left + right_direction_weight = weight_left + else: + centre_left = (1.0 - alpha) * _CENTRE_ALTERNATE + centre_right = centre_left + right_direction_weight = weight_right + + gains = np.empty((2, 77), dtype=np.complex128) + gains[0] = common * ( + left * (weight_left * alpha) + left_term0 * centre_left) + gains[1] = common * ( + right * (right_direction_weight * alpha) + right_term0 * centre_right) + return DirectResult( + gains=gains, + room_send=np.float64(room_send), + physical_radius_m=np.float64(float(profile.distance_scale_m) * radius), + normalized_radius=np.float64(radius), + clamped_radius=np.float64(clamped), + delay_samples=np.float64(delay), + delayed_ear=delayed_ear, + ) diff --git a/src/rosella_filterbank.py b/src/rosella_filterbank.py new file mode 100644 index 0000000..262c917 --- /dev/null +++ b/src/rosella_filterbank.py @@ -0,0 +1,190 @@ +"""Float64/complex128 Rosella QMF and hybrid filterbanks.""" +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +import numpy as np + +PROJECT_DIR = Path(__file__).resolve().parent.parent +DEFAULT_KERNEL_DATA = PROJECT_DIR / "data" / "rosella_kernels.npz" + + +@lru_cache(maxsize=4) +def _load_tables(path_string: str) -> dict[str, np.ndarray]: + path = Path(path_string) + if not path.is_file(): + raise FileNotFoundError(f"Rosella kernel data not found: {path}") + with np.load(path, allow_pickle=False) as archive: + version = archive["format_version"] + if version.shape != (1,) or int(version[0]) != 1: + raise ValueError(f"unsupported Rosella kernel data version in {path}") + return {name: archive[name].copy() for name in archive.files} + + +def load_kernel_tables(path: str | Path = DEFAULT_KERNEL_DATA) -> dict[str, np.ndarray]: + """Load and cache the compact, production Rosella kernel tables.""" + return _load_tables(str(Path(path).expanduser().resolve())) + + +class QmfAnalysis: + """Batchable 64-band analysis with float64 state and complex128 FFTs.""" + + def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_kernel_tables(kernel_data) + self.coefficients = np.asarray( + tables["qmf_analysis_coefficients"], dtype=np.float64) + if self.coefficients.shape != (64, 10): + raise ValueError("invalid qmf_analysis_coefficients shape") + self.channels = int(channels) + self.history = np.zeros((9, self.channels, 64), dtype=np.float64) + phase = np.arange(64, dtype=np.float64) + self.premod = np.exp(-1j * np.pi * phase / 128.0).astype(np.complex128) + self.post = np.exp( + -1j * 3.0 * (np.arange(64, dtype=np.float64) + 0.5) * np.pi / 128.0 + ).astype(np.complex128) + self.even_post = ( + 1j * ((-1.0) ** np.arange(64, dtype=np.float64)) + ).astype(np.complex128) + + def reset(self): + self.history.fill(0.0) + + def process_chunk(self, hops) -> np.ndarray: + values = np.asarray(hops, dtype=np.float64) + if values.ndim != 3 or values.shape[1:] != (self.channels, 64): + raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}") + count = values.shape[0] + joined = np.concatenate((self.history, values), axis=0) + even = np.zeros_like(values) + odd = np.zeros_like(values) + for lag in range(10): + source = joined[9 - lag:9 - lag + count] + target = even if lag % 2 == 0 else odd + target += source * self.coefficients[:, lag][None, None, :] + self.history[:] = joined[-9:] + + def transform(block): + prepared = block.astype(np.complex128, copy=False) * self.premod + transformed = np.fft.fft(prepared, n=128, axis=-1)[..., :64] + return transformed * self.post + + return np.asarray(transform(odd) + transform(even) * self.even_post, + dtype=np.complex128) + + +class HybridAnalysis: + """Sparse 64-QMF to 77-hybrid analysis in float64/complex128.""" + + def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_kernel_tables(kernel_data) + self.low_kernel = np.asarray( + tables["hybrid_analysis_low_kernel"], dtype=np.float64) + if self.low_kernel.shape != (3, 2, 13, 16, 2): + raise ValueError("invalid hybrid_analysis_low_kernel shape") + self.channels = int(channels) + self.history = np.zeros((12, self.channels, 3, 2), dtype=np.float64) + self.high_history = np.zeros( + (6, self.channels, 61), dtype=np.complex128) + + def reset(self): + self.history.fill(0.0) + self.high_history.fill(0.0) + + def process_chunk(self, qmf) -> np.ndarray: + values = np.asarray(qmf, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, 64): + raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}") + count = values.shape[0] + low = np.stack((values[:, :, :3].real, values[:, :, :3].imag), axis=-1) + joined = np.concatenate((self.history, low), axis=0) + output = np.zeros((count, self.channels, 77, 2), dtype=np.float64) + for lag in range(13): + source = joined[12 - lag:12 - lag + count] + output[:, :, :16] += np.einsum( + "tcpi,pibo->tcbo", source, self.low_kernel[:, :, lag], + dtype=np.float64, optimize=False) + self.history[:] = joined[-12:] + + high_joined = np.concatenate((self.high_history, values[:, :, 3:]), axis=0) + high = high_joined[:count] + output[:, :, 16:, 0] = high.real + output[:, :, 16:, 1] = high.imag + self.high_history[:] = high_joined[-6:] + return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128) + + +class HybridSynthesis: + """Instantaneous sparse 77-hybrid to 64-QMF synthesis map.""" + + def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_kernel_tables(kernel_data) + indices = np.asarray(tables["hybrid_synthesis_indices"], dtype=np.int64) + values = np.asarray(tables["hybrid_synthesis_values"], dtype=np.float64) + if indices.ndim != 2 or indices.shape[1] != 4 or len(indices) != len(values): + raise ValueError("invalid hybrid synthesis sparse table") + self.mapping = [ + (int(index[0]), int(index[1]), int(index[2]), int(index[3]), float(value)) + for index, value in zip(indices, values) + ] + self.channels = int(channels) + + def reset(self): + return None + + def process_chunk(self, hybrid) -> np.ndarray: + values = np.asarray(hybrid, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, 77): + raise ValueError(f"expected [slots,{self.channels},77], got {values.shape}") + source = np.stack((values.real, values.imag), axis=-1) + output = np.zeros((values.shape[0], self.channels, 64, 2), dtype=np.float64) + for input_band, input_component, output_band, output_component, gain in self.mapping: + output[:, :, output_band, output_component] += ( + source[:, :, input_band, input_component] * gain) + return np.asarray(output[..., 0] + 1j * output[..., 1], dtype=np.complex128) + + +class QmfSynthesis: + """Rank-4 64-band synthesis with float64 state and accumulation.""" + + def __init__(self, channels: int, kernel_data: str | Path = DEFAULT_KERNEL_DATA): + if channels <= 0: + raise ValueError("channels must be positive") + tables = load_kernel_tables(kernel_data) + self.basis = np.asarray(tables["qmf_synthesis_basis"], dtype=np.float64) + self.taps = np.asarray(tables["qmf_synthesis_taps"], dtype=np.float64) + if self.basis.shape != (64, 4, 128) or self.taps.shape != (64, 10, 4): + raise ValueError("invalid QMF synthesis factorization") + self.channels = int(channels) + self.rank = 4 + self.history = np.zeros( + (9, self.channels, 64, self.rank), dtype=np.float64) + + def reset(self): + self.history.fill(0.0) + + def process_chunk(self, qmf) -> np.ndarray: + values = np.asarray(qmf, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.channels, 64): + raise ValueError(f"expected [slots,{self.channels},64], got {values.shape}") + count = values.shape[0] + flat = np.stack((values.real, values.imag), axis=-1).reshape( + count * self.channels, 128) + modulation = self.basis.reshape(64 * self.rank, 128) + features = (flat @ modulation.T).reshape( + count, self.channels, 64, self.rank) + joined = np.concatenate((self.history, features), axis=0) + output = np.zeros((count, self.channels, 64), dtype=np.float64) + for lag in range(10): + output += np.sum( + joined[9 - lag:9 - lag + count] + * self.taps[:, lag, :][None, None, :, :], + axis=-1, dtype=np.float64) + self.history[:] = joined[-9:] + return output diff --git a/src/rosella_model.py b/src/rosella_model.py new file mode 100644 index 0000000..efcbe2f --- /dev/null +++ b/src/rosella_model.py @@ -0,0 +1,517 @@ +"""Parser for ``.personalized_headphone`` and raw ``rp`` models.""" + +from __future__ import annotations + +import hashlib +import json +import math +from numbers import Real +import struct +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + + +Q15 = np.float32(1.0 / 32768.0) + + +def _f32(value) -> np.float32: + return np.float32(value) + + +def _q15(value: int) -> np.float32: + return _f32(_f32(value) * Q15) + + +def _q15_exp(value: int, exponent: int) -> np.float32: + return _f32(_q15(value) * _f32(np.ldexp(1.0, exponent))) + + +@dataclass(frozen=True) +class DistanceProfile: + bounds: np.ndarray + distance_scale_m: np.float32 + inverse_distance_per_m: np.float32 + axis_scales_internal: np.ndarray + minimum_normalized_radius: np.float32 + + @property + def floats(self) -> np.ndarray: + return np.concatenate(( + self.bounds, + np.asarray([self.distance_scale_m, + self.inverse_distance_per_m], dtype=np.float32), + self.axis_scales_internal, + np.asarray([self.minimum_normalized_radius], dtype=np.float32), + )) + + +@dataclass(frozen=True) +class RosellaModel: + source_path: str + coefficients: np.ndarray + coefficient_sha256: str + coefficient_version: str | None + room_model: str | None + table_a_dimension: int + table_a_option: int + table_a_extra: int + table_a_header_field: int + table_a_header_25: int + table_a_control: int + table_a_option_ids: np.ndarray + table_a_option_values: np.ndarray + table_a_scalar: np.float32 + table_a_filter_16x64_padded: np.ndarray + table_a_four_integers: np.ndarray + table_a_integer: int + table_a_filter_8x64_padded: np.ndarray + table_a_vector16: np.ndarray + table_a_filter_4x64_padded: np.ndarray + table_a_extra_indices: np.ndarray + table_a_extra_fields_padded: np.ndarray + table_a_extra_vectors: np.ndarray + sample_rate: int + matrix_exponent: int + field_exponent: int + matrix_left: np.ndarray + matrix_right: np.ndarray + vector_left: np.ndarray + vector_right: np.ndarray + field_left_padded: np.ndarray + field_right_padded: np.ndarray + field_left_odd_serialized_zero: bool + hybrid_flags: np.ndarray + hybrid_values: np.ndarray + model_scalars: np.ndarray + header_float_scalars: np.ndarray + header_integer_fields: np.ndarray + profiles: tuple[DistanceProfile, ...] + profile_tail: np.ndarray + post_fields: np.ndarray + table_a_main_serialized: np.ndarray + + +def _lane(data: bytes, index: int) -> int: + if (index + 1) * 4 > len(data): + raise ValueError(f"Rosella rp truncated before int32 lane {index}") + return struct.unpack_from(" dict: + """Return the active-lane layout and checksum status for one raw rp image.""" + if len(data) < 20 or len(data) % 4: + raise ValueError("Rosella rp must contain whole little-endian int32 lanes") + if _lane(data, 0) != 0x7072: + raise ValueError(f"bad Rosella rp magic: 0x{_lane(data, 0):08X}") + + low16 = lambda value: value & 0xFFFF + checksum = low16(_lane(data, 1)) + table_a_present = low16(_lane(data, 2)) + table_b_present = low16(_lane(data, 3)) + table_c_present = low16(_lane(data, 4)) + index = 5 + if table_a_present: + table_a_dimension = low16(_lane(data, index)) + table_a_option = low16(_lane(data, index + 1)) + table_a_extra = low16(_lane(data, index + 2)) + index += 5 + else: + table_a_dimension, table_a_option, table_a_extra = 77, 0, 0 + if table_b_present: + if not table_a_present: + raise ValueError("Rosella rp table B cannot be present without table A") + table_b_dimension = low16(_lane(data, index)) + table_b_extra = low16(_lane(data, index + 1)) + table_b_groups = low16(_lane(data, index + 2)) + index += 3 + else: + table_b_dimension = table_b_extra = table_b_groups = 0 + if table_c_present: + table_c_dimension = low16(_lane(data, index)) + index += 1 + else: + table_c_dimension = 0 + + payload_words = ( + (index - 2) + + table_b_present * ( + table_b_dimension + 380 * table_b_groups + table_b_extra + 79) + + table_a_present * ( + 171 * table_a_extra + 79 + 2 * (table_a_option + 14 * table_a_dimension)) + + 11 + + table_c_present * (314 * table_c_dimension + 1) + ) + total_lanes = 2 + payload_words + if len(data) < total_lanes * 4: + raise ValueError( + f"Rosella rp truncated: need {total_lanes * 4} bytes, have {len(data)}") + computed = 0xA569 + for lane_index in range(2, total_lanes): + computed ^= low16(_lane(data, lane_index)) + computed &= 0xFFFF + return { + "stored_checksum": checksum, + "computed_checksum": computed, + "checksum_valid": computed == checksum, + "table_a_present": table_a_present, + "table_b_present": table_b_present, + "table_c_present": table_c_present, + "table_a_dimension": table_a_dimension, + "table_a_option": table_a_option, + "table_a_extra": table_a_extra, + "table_b_dimension": table_b_dimension, + "table_b_extra": table_b_extra, + "table_b_groups": table_b_groups, + "table_c_dimension": table_c_dimension, + "active_int32_lanes": total_lanes, + } + + +def _json_int32(values) -> np.ndarray: + if not isinstance(values, list): + raise ValueError("rosella_coefficients must be a JSON array") + result = np.empty(len(values), dtype=np.int32) + for index, value in enumerate(values): + if isinstance(value, bool) or not isinstance(value, Real): + raise ValueError(f"rosella_coefficients[{index}] is not a number") + numeric = float(value) + if not math.isfinite(numeric) or numeric != math.trunc(numeric): + raise ValueError( + f"rosella_coefficients[{index}] is not an exact integer: {value!r}") + integer = int(value) + if integer < -(1 << 31) or integer > (1 << 31) - 1: + raise ValueError( + f"rosella_coefficients[{index}] is outside signed int32: {integer}") + result[index] = integer + return result + + +def _load_coefficients(path: Path) -> tuple[np.ndarray, str | None, str | None]: + if not path.is_file(): + raise FileNotFoundError(path) + source = path.read_bytes() + stripped = source.lstrip() + if stripped.startswith(b"{"): + try: + document = json.loads(source.decode("utf-8")) + virtualizer = document["personalized_hrtf"]["virtualizer_parameters"] + coefficients = _json_int32(virtualizer["rosella_coefficients"]) + except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as exc: + raise ValueError(f"invalid personalized_headphone JSON: {exc}") from exc + version = virtualizer.get("rosella_coefficients_version") + room = virtualizer.get("room_model") + else: + if len(source) % 4: + raise ValueError("raw rp payload must contain complete int32 lanes") + coefficients = np.frombuffer(source, dtype=" np.ndarray: + expected = 154 * directions + if serialized.size != expected: + raise ValueError(f"expected {expected} field lanes, got {serialized.size}") + padded = np.zeros(160 * directions, dtype=np.float32) + stride8 = 8 * directions + stride2 = 2 * directions + for source_index, value in enumerate(serialized): + group4 = (source_index % stride8) // stride2 + destination = ((group4 & 3) + 4 * ( + source_index % stride2 + + 2 * directions * (source_index // stride8 + (group4 >> 2)))) + padded[destination] = _q15_exp(int(value), exponent) + return padded + + +def _unpack_table_a_grid(serialized: np.ndarray, dimension: int, + serialized_rows: int, padded_rows: int, + lane_group: int) -> np.ndarray: + if serialized.size != serialized_rows * dimension: + raise ValueError("unexpected table-A grid size") + padded = np.zeros(padded_rows * dimension, dtype=np.float32) + group_width = lane_group * 4 + for source_index, value in enumerate(serialized): + remainder = source_index % group_width + destination = ((remainder // lane_group) + 4 * ( + remainder % lane_group + + group_width // 4 * (source_index // group_width))) + padded[destination] = _q15(int(value)) + return padded + + +def _unpack_table_a_extra(serialized: np.ndarray) -> np.ndarray: + if serialized.size != 154: + raise ValueError("table-A extra field must contain 154 serialized values") + padded = np.zeros(160, dtype=np.float32) + for source_index, value in enumerate(serialized): + remainder = source_index & 7 + destination = ((remainder >> 1) + 4 * ( + (source_index & 1) + 2 * (source_index >> 3))) + padded[destination] = _q15(int(value)) + return padded + + +def _parse_profile(values: np.ndarray, position: int) -> tuple[DistanceProfile, int]: + bounds = np.asarray([_q15(int(value)) for value in values[position:position + 6]], + dtype=np.float32) + position += 6 + distance = _q15_exp(int(values[position]), int(values[position + 1])) + position += 2 + remaining = np.asarray( + [_q15(int(value)) for value in values[position:position + 5]], + dtype=np.float32) + position += 5 + return DistanceProfile( + bounds=bounds, + distance_scale_m=distance, + inverse_distance_per_m=remaining[0], + axis_scales_internal=remaining[1:4], + minimum_normalized_radius=remaining[4], + ), position + + +def load_personalized_headphone(path: str | Path) -> RosellaModel: + path = Path(path).resolve() + coefficients, version, room = _load_coefficients(path) + raw = coefficients.astype(" np.float32(1e-6)) + position += serialized_count + field_right = _unpack_field( + values[position:position + serialized_count], 36, field_exponent) + position += serialized_count + + hybrid_flags = (values[position:position + 20].astype(np.int64) & 0xFFFF).astype(np.int32) + position += 20 + active_hybrid_values = int(np.count_nonzero(hybrid_flags == 1)) + if active_hybrid_values != header["table_b_extra"]: + raise ValueError( + f"hybrid value count {active_hybrid_values} != header {header['table_b_extra']}") + hybrid_values = np.asarray( + [_q15(int(value)) for value in values[position:position + active_hybrid_values]], + dtype=np.float32) + position += active_hybrid_values + model_scalars = np.asarray( + [_q15(int(value)) for value in values[position:position + 5]], + dtype=np.float32) + position += 5 + + expected_table_a_tail = table_b_start + ( + header["table_b_dimension"] + + 380 * header["table_b_groups"] + + header["table_b_extra"] + 79) + if position != expected_table_a_tail: + raise AssertionError(f"table-B parser ended at {position}, expected {expected_table_a_tail}") + + header_float_scalars = np.asarray([ + _q15(int(values[position])), + _f32(_q15(int(values[position + 1])) * _f32(16.0)), + ], dtype=np.float32) + header_integer_fields = np.asarray([ + int(values[position + 2]), + int(values[position + 3]) & 0xFFFF, + ], dtype=np.int32) + position += 4 + profiles = [] + for _ in range(4): + profile, position = _parse_profile(values, position) + profiles.append(profile) + profile_tail = np.asarray( + [_q15(int(value)) for value in values[position:position + 8]], + dtype=np.float32) + position += 8 + post_fields = values[position:position + 3].astype(np.int32, copy=True) + position += 3 + if position != values.size: + raise AssertionError(f"unparsed coefficient lanes: {values.size - position}") + + return RosellaModel( + source_path=str(path), + coefficients=coefficients, + coefficient_sha256=hashlib.sha256(raw).hexdigest(), + coefficient_version=version, + room_model=room, + table_a_dimension=header["table_a_dimension"], + table_a_option=header["table_a_option"], + table_a_extra=extra, + table_a_header_field=int(values[8]) & 0xFFFF, + table_a_header_25=int(values[9]) & 0xFFFF, + table_a_control=table_a_control, + table_a_option_ids=option_ids, + table_a_option_values=option_values, + table_a_scalar=table_a_scalar, + table_a_filter_16x64_padded=table_a_filter_16x64, + table_a_four_integers=table_a_four_integers, + table_a_integer=table_a_integer, + table_a_filter_8x64_padded=table_a_filter_8x64, + table_a_vector16=table_a_vector16, + table_a_filter_4x64_padded=table_a_filter_4x64, + table_a_extra_indices=extra_indices, + table_a_extra_fields_padded=extra_fields, + table_a_extra_vectors=extra_vectors, + sample_rate=sample_rate, + matrix_exponent=matrix_exponent, + field_exponent=field_exponent, + matrix_left=matrix_left, + matrix_right=matrix_right, + vector_left=vector_left, + vector_right=vector_right, + field_left_padded=field_left, + field_right_padded=field_right, + field_left_odd_serialized_zero=field_left_odd_zero, + hybrid_flags=hybrid_flags, + hybrid_values=hybrid_values, + model_scalars=model_scalars, + header_float_scalars=header_float_scalars, + header_integer_fields=header_integer_fields, + profiles=tuple(profiles), + profile_tail=profile_tail, + post_fields=post_fields, + table_a_main_serialized=table_a_main, + ) + + +def direction_basis(x: float, y: float, z: float, + dtype=np.float64) -> np.ndarray: + """Return the observed 36-term Rosella direction basis.""" + f = dtype + x, y, z = f(x), f(y), f(z) + out = np.empty(36, dtype=dtype) + yz = f(y * z) + x2 = f(x * x) + y2 = f(y * y) + x2m02 = f(x2 - f(0.2)) + xy = f(x * y) + out[0:4] = (f(1.0), x, y, z) + out[4] = f(x2 - f(1.0 / 3.0)) + out[5] = xy + out[6] = f(x * z) + out[7] = f(y2 - f(1.0 / 3.0)) + out[8] = yz + out[9] = f(f(x2 - f(0.6)) * x) + out[10] = f(x2m02 * y) + out[11] = f(x2m02 * z) + out[12] = f(f(y2 - f(0.2)) * x) + out[13] = f(yz * x) + out[14] = f(f(y2 - f(0.6)) * y) + out[15] = f(f(y2 - f(0.2)) * z) + out[16] = f(f(x2 * x2) - f(0.2)) + out[17] = f(xy * x2) + out[18] = f(f(x * z) * x2) + out[19] = f(f(y2 * x2) - f(1.0 / 15.0)) + out[20] = f(yz * x2) + out[21] = f(x * y2 * y) + out[22] = f(x * y2 * z) + out[23] = f(f(y2 * y2) - f(0.2)) + x4 = f(x2 * x2) + x2y2 = f(y2 * x2) + y4 = f(y2 * y2) + out[24] = f(yz * y2) + out[25] = f(f(x4 - f(3.0 / 7.0)) * x) + out[26] = f(f(x4 - f(3.0 / 35.0)) * y) + out[27] = f(f(x4 - f(3.0 / 35.0)) * z) + out[28] = f(f(x2y2 - f(3.0 / 35.0)) * x) + out[29] = f(f(x2 * z) * xy) + out[30] = f(f(x2y2 - f(3.0 / 35.0)) * y) + out[31] = f(f(x2y2 - f(1.0 / 35.0)) * z) + out[32] = f(f(y4 - f(3.0 / 35.0)) * x) + out[33] = f(f(y2 * z) * xy) + out[34] = f(f(y4 - f(3.0 / 7.0)) * y) + out[35] = f(f(y4 - f(3.0 / 35.0)) * z) + return out diff --git a/src/rosella_room.py b/src/rosella_room.py new file mode 100644 index 0000000..36558f3 --- /dev/null +++ b/src/rosella_room.py @@ -0,0 +1,198 @@ +"""Float64 Rosella table-A room model and overlap-add realization.""" +from __future__ import annotations + +import numpy as np + +from rosella_model import RosellaModel + + +class _RosellaRoomState: + """Recursive table-A state used to generate the stable FIR realization.""" + + def __init__(self, model: RosellaModel): + self.model = model + self.bands = min(64, model.table_a_dimension) + self.delays = model.table_a_four_integers.astype(np.int32) + self.capacity = int(np.max(self.delays)) + self.matrix = np.asarray(model.table_a_vector16, dtype=np.float64).reshape( + 4, 4, order="F") + f8 = np.asarray(model.table_a_filter_8x64_padded, dtype=np.float64).reshape( + 20, 4, 2, 4) + f4 = np.asarray(model.table_a_filter_4x64_padded, dtype=np.float64).reshape( + 20, 4, 4) + f16 = np.asarray(model.table_a_filter_16x64_padded, dtype=np.float64).reshape( + 20, 4, 4, 4) + self.feedback_real = np.empty((self.bands, 4), dtype=np.float64) + self.feedback_imag = np.empty_like(self.feedback_real) + self.output_tap = np.empty_like(self.feedback_real) + self.left_real = np.empty_like(self.feedback_real) + self.left_imag = np.empty_like(self.feedback_real) + self.right_real = np.empty_like(self.feedback_real) + self.right_imag = np.empty_like(self.feedback_real) + for band in range(self.bands): + group, lane = divmod(band, 4) + self.feedback_real[band] = f8[group, :, 0, lane] + self.feedback_imag[band] = f8[group, :, 1, lane] + self.output_tap[band] = f4[group, :, lane] + self.left_real[band] = f16[group, :, 0, lane] + self.left_imag[band] = f16[group, :, 1, lane] + self.right_real[band] = f16[group, :, 2, lane] + self.right_imag[band] = f16[group, :, 3, lane] + + self.allpass_gain = np.asarray(model.table_a_option_values, dtype=np.float64) + self.allpass_delay = model.table_a_option_ids.astype(np.int32) + self.allpass_real = [ + np.zeros((int(delay), self.bands), dtype=np.float64) + for delay in self.allpass_delay + ] + self.allpass_imag = [np.zeros_like(value) for value in self.allpass_real] + self.allpass_position = np.zeros(len(self.allpass_real), dtype=np.int32) + self.memory_real = np.zeros( + (self.capacity, self.bands, 4), dtype=np.float64) + self.memory_imag = np.zeros_like(self.memory_real) + self.position = 0 + self.extra_fields = np.asarray( + model.table_a_extra_fields_padded, dtype=np.float64).reshape(-1, 20, 2, 4) + self.extra_matrices = [ + np.asarray(value, dtype=np.float64).reshape(4, 4, order="F") + for value in model.table_a_extra_vectors + ] + + def reset(self): + for value in self.allpass_real + self.allpass_imag: + value.fill(0.0) + self.allpass_position.fill(0) + self.memory_real.fill(0.0) + self.memory_imag.fill(0.0) + self.position = 0 + + def process_slot(self, room_send) -> np.ndarray: + values = np.asarray(room_send, dtype=np.complex128) + input_real = values[:self.bands].real * 0.70710677 + input_imag = values[:self.bands].imag * 0.70710677 + if float(self.model.table_a_scalar) >= 0.5: + raise NotImplementedError("alternate Rosella table-A room mode") + + for index, gain in enumerate(self.allpass_gain): + position = int(self.allpass_position[index]) + previous_real = self.allpass_real[index][position].copy() + previous_imag = self.allpass_imag[index][position].copy() + residual_real = input_real - previous_real * gain + residual_imag = input_imag - previous_imag * gain + input_real = residual_real * gain + previous_real + input_imag = residual_imag * gain + previous_imag + self.allpass_real[index][position] = residual_real + self.allpass_imag[index][position] = residual_imag + self.allpass_position[index] = ( + position + 1) % len(self.allpass_real[index]) + + branch_real = np.repeat(input_real[:, None], 4, axis=1) + branch_imag = np.repeat(input_imag[:, None], 4, axis=1) + delayed_real = np.empty_like(branch_real) + delayed_imag = np.empty_like(branch_imag) + for branch, delay in enumerate(self.delays): + delayed_real[:, branch] = self.memory_real[ + (self.position - int(delay)) % self.capacity, :, branch] + delayed_imag[:, branch] = self.memory_imag[ + (self.position - int(delay)) % self.capacity, :, branch] + branch_real += np.einsum( + "bj,ij->bi", delayed_real, self.matrix, + dtype=np.float64, optimize=False) + branch_imag += np.einsum( + "bj,ij->bi", delayed_imag, self.matrix, + dtype=np.float64, optimize=False) + + tap_index = (self.position - self.model.table_a_integer) % self.capacity + tap_real = self.memory_real[tap_index].copy() + tap_imag = self.memory_imag[tap_index].copy() + next_real = branch_real * self.feedback_real - branch_imag * self.feedback_imag + next_imag = branch_imag * self.feedback_real + branch_real * self.feedback_imag + self.memory_real[self.position] = next_real + self.memory_imag[self.position] = next_imag + self.position = (self.position + 1) % self.capacity + + extra_real = np.zeros_like(branch_real) + extra_imag = np.zeros_like(branch_imag) + for index, delay in enumerate(self.model.table_a_extra_indices): + source_real = self.memory_real[ + (self.position - (int(delay) + 1)) % self.capacity] + source_imag = self.memory_imag[ + (self.position - (int(delay) + 1)) % self.capacity] + matrix = self.extra_matrices[index] + mixed_real = np.einsum( + "bj,ij->bi", source_real, matrix, + dtype=np.float64, optimize=False) + mixed_imag = np.einsum( + "bj,ij->bi", source_imag, matrix, + dtype=np.float64, optimize=False) + coefficient_real = np.empty(self.bands, dtype=np.float64) + coefficient_imag = np.empty(self.bands, dtype=np.float64) + for band in range(self.bands): + group, lane = divmod(band, 4) + coefficient_real[band] = self.extra_fields[index, group, 0, lane] + coefficient_imag[band] = self.extra_fields[index, group, 1, lane] + extra_real += (mixed_real * coefficient_real[:, None] + - mixed_imag * coefficient_imag[:, None]) + extra_imag += (mixed_imag * coefficient_real[:, None] + + mixed_real * coefficient_imag[:, None]) + + output_real = tap_real * self.output_tap + extra_real + output_imag = tap_imag * self.output_tap + extra_imag + left = np.sum( + self.left_real * output_real - self.left_imag * output_imag, + axis=1, dtype=np.float64) + left_imag = np.sum( + self.left_imag * output_real + self.left_real * output_imag, + axis=1, dtype=np.float64) + right = np.sum( + self.right_real * output_real - self.right_imag * output_imag, + axis=1, dtype=np.float64) + right_imag = np.sum( + self.right_imag * output_real + self.right_real * output_imag, + axis=1, dtype=np.float64) + result = np.zeros((2, 77), dtype=np.complex128) + result[0, :self.bands] = left + 1j * left_imag + result[1, :self.bands] = right + 1j * right_imag + return result + + +class RosellaRoomFir: + """Complex128 overlap-add room FIR generated locally from table-A.""" + + def __init__(self, model: RosellaModel, impulse_slots: int = 4096): + if impulse_slots <= 0: + raise ValueError("impulse_slots must be positive") + reference = _RosellaRoomState(model) + self.length = int(impulse_slots) + self.kernel = np.empty((self.length, 2, 64), dtype=np.complex128) + for slot in range(self.length): + impulse = np.zeros(77, dtype=np.complex128) + if slot == 0: + impulse[:64] = 1.0 + self.kernel[slot] = reference.process_slot(impulse)[:, :64] + self.tail = np.zeros((self.length - 1, 2, 64), dtype=np.complex128) + self._fft_cache: dict[int, np.ndarray] = {} + + def reset(self): + self.tail.fill(0.0) + + def process_chunk(self, room_send) -> np.ndarray: + values = np.asarray(room_send, dtype=np.complex128) + if values.ndim != 2 or values.shape[1] != 77: + raise ValueError("room_send must have shape [slots,77]") + count = len(values) + if count == 0: + return np.zeros((0, 2, 77), dtype=np.complex128) + needed = count + self.length - 1 + fft_size = 1 << (needed - 1).bit_length() + kernel_fft = self._fft_cache.get(fft_size) + if kernel_fft is None: + kernel_fft = np.fft.fft(self.kernel, fft_size, axis=0) + self._fft_cache[fft_size] = kernel_fft + input_fft = np.fft.fft(values[:, :64], fft_size, axis=0) + block = np.fft.ifft(input_fft[:, None, :] * kernel_fft, axis=0)[:needed] + block[:len(self.tail)] += self.tail + result = np.zeros((count, 2, 77), dtype=np.complex128) + result[:, :, :64] = block[:count] + self.tail = block[count:count + self.length - 1].copy() + return result diff --git a/src/sofa_binaural_backend.py b/src/sofa_binaural_backend.py new file mode 100644 index 0000000..b681be3 --- /dev/null +++ b/src/sofa_binaural_backend.py @@ -0,0 +1,552 @@ +"""Stateful public SOFA binaural renderer. + +The runtime topology mirrors the existing multi-object binaural path: +64-QMF -> 77 hybrid -> per-object directional transfer -> stereo synthesis. +The HRTF parameter source is a SOFA-derived fifth-order field. Early +reflections and the late room use project-owned behavior. +""" +from __future__ import annotations + +from dataclasses import dataclass +import math +from pathlib import Path + +import numpy as np + +from public_filterbank import ( + ANALYSIS_SYNTHESIS_LATENCY_SAMPLES, + HYBRID_BANDS, + QMF_HOP, + PublicAnalysis77, + PublicSynthesis77, + table_info as filterbank_table_info, +) +from public_room import ( + LateFdnConfig, + SharedUnitaryFdn, + ShoeboxRoomConfig, + first_order_image_sources, +) +from reference_distance import DistanceState, ReferenceDistanceProfileV1 +from sofa_canonical import CanonicalHrtf +from sofa_hrtf_field import ( + DEFAULT_ORDER, + DEFAULT_PROJECTION_RIDGE, + DEFAULT_SH_RIDGE, + SofaHrtfField, + compile_sofa_hrtf, +) + + +@dataclass(frozen=True) +class HybridPath: + label: str + delay_slots: np.ndarray # whole-QMF delay per ear, [2] + transfer: np.ndarray # [ear,77], includes residual delay and HRTF delay + + def __post_init__(self): + slots = np.asarray(self.delay_slots) + if slots.shape == (): + slots = np.repeat(slots, 2) + if slots.shape != (2,) or slots.dtype.kind not in "iu": + raise ValueError("hybrid path delay_slots must contain two integers") + slots = np.asarray(slots, dtype=np.int64) + transfer = np.asarray(self.transfer, dtype=np.complex128) + if transfer.shape != (2, HYBRID_BANDS) or not np.isfinite(transfer).all(): + raise ValueError("hybrid path transfer must have finite shape [2,77]") + if np.any(slots < 0): + raise ValueError("hybrid path delay_slots must be non-negative") + slots.setflags(write=False) + transfer.setflags(write=False) + object.__setattr__(self, "delay_slots", slots) + object.__setattr__(self, "transfer", transfer) + + +class HybridObjectPathRenderer: + """Per-object hybrid histories for direct and image-source paths.""" + + def __init__(self, source_count: int, *, history_slots: int = 256, + transition_slots: int = 8): + self.source_count = int(source_count) + self.history_slots = int(history_slots) + self.transition_slots = int(transition_slots) + if min(self.source_count, self.history_slots) <= 0 or self.transition_slots < 0: + raise ValueError("invalid hybrid path renderer dimensions") + self.history = np.zeros( + (self.source_count, self.history_slots, HYBRID_BANDS), dtype=np.complex128) + self.position = 0 + self.current: list[tuple[HybridPath, ...]] = [tuple() for _ in range(self.source_count)] + self.target: list[tuple[HybridPath, ...] | None] = [None] * self.source_count + self.fade_position = np.zeros(self.source_count, dtype=np.int32) + self.fade_total = np.zeros(self.source_count, dtype=np.int32) + self.processed_slots = 0 + + def reset(self) -> None: + self.history.fill(0.0) + self.position = 0 + self.target = [None] * self.source_count + self.fade_position.fill(0) + self.fade_total.fill(0) + self.processed_slots = 0 + + def set_paths(self, source: int, paths, *, fade_slots: int | None = None) -> None: + source = int(source) + if not 0 <= source < self.source_count: + raise IndexError(source) + values = tuple(paths) + for path in values: + if np.any(path.delay_slots >= self.history_slots): + raise ValueError( + f"path {path.label!r} needs {path.delay_slots.tolist()} slots, " + f"history capacity is {self.history_slots}") + fade = self.transition_slots if fade_slots is None else int(fade_slots) + if fade < 0: + raise ValueError("path fade must be non-negative") + if self.target[source] is not None: + # Normal 512-sample updates complete an 8-slot transition exactly. + # If a caller updates faster, use the previous target as the new + # stable side rather than resetting signal history. + self.current[source] = self.target[source] + self.target[source] = None + if not self.processed_slots or fade == 0: + self.current[source] = values + self.target[source] = None + self.fade_position[source] = 0 + self.fade_total[source] = 0 + else: + self.target[source] = values + self.fade_position[source] = 0 + self.fade_total[source] = fade + + def _render_paths(self, source: int, paths: tuple[HybridPath, ...]) -> np.ndarray: + result = np.zeros((2, HYBRID_BANDS), dtype=np.complex128) + for path in paths: + indices = (self.position - path.delay_slots) % self.history_slots + delayed = self.history[source, indices, :] + result += delayed * path.transfer + return result + + def process(self, hybrid) -> np.ndarray: + values = np.asarray(hybrid, dtype=np.complex128) + if values.ndim != 3 or values.shape[1:] != (self.source_count, HYBRID_BANDS): + raise ValueError( + f"hybrid input must have shape [slots,{self.source_count},77]") + if not np.isfinite(values).all(): + raise ValueError("hybrid input contains non-finite values") + output = np.zeros((len(values), 2, HYBRID_BANDS), dtype=np.complex128) + for slot in range(len(values)): + self.history[:, self.position, :] = values[slot] + for source in range(self.source_count - 1, -1, -1): + current = self._render_paths(source, self.current[source]) + target_paths = self.target[source] + if target_paths is None: + output[slot] += current + continue + target = self._render_paths(source, target_paths) + self.fade_position[source] += 1 + amount = min( + 1.0, self.fade_position[source] / float(self.fade_total[source])) + output[slot] += current * (1.0 - amount) + target * amount + if self.fade_position[source] >= self.fade_total[source]: + self.current[source] = target_paths + self.target[source] = None + self.fade_position[source] = 0 + self.fade_total[source] = 0 + self.position = (self.position + 1) % self.history_slots + self.processed_slots += 1 + return output + + +class _StereoDelay: + def __init__(self, delay_samples: int): + self.delay_samples = int(delay_samples) + if self.delay_samples < 0: + raise ValueError("delay must be non-negative") + self.state = np.zeros((self.delay_samples, 2), dtype=np.float64) + + def reset(self) -> None: + self.state.fill(0.0) + + def process(self, values) -> np.ndarray: + source = np.asarray(values, dtype=np.float64) + if source.ndim != 2 or source.shape[1] != 2: + raise ValueError("stereo delay input must have shape [samples,2]") + if self.delay_samples == 0: + return source.copy() + joined = np.concatenate((self.state, source), axis=0) + output = joined[:len(source)].copy() + self.state = joined[len(source):len(source) + self.delay_samples].copy() + return output + + +class SofaBinauralBackend: + """SOFA-derived public 77-band/SH renderer with public room processing.""" + + def __init__( + self, + field: SofaHrtfField, + *, + source_count: int = 16, + sample_rate_hz: float = 48000.0, + default_profile: str = "mid", + enable_early_reflections: bool = True, + enable_late_room: bool = True, + room_config: ShoeboxRoomConfig = ShoeboxRoomConfig(), + fdn_config: LateFdnConfig | None = None, + transition_slots: int = 8, + history_slots: int = 256, + output_gain: float = 1.0): + self.source_count = int(source_count) + self.sample_rate_hz = float(sample_rate_hz) + self.default_profile = ReferenceDistanceProfileV1.validate_profile(default_profile) + self.enable_early_reflections = bool(enable_early_reflections) + self.enable_late_room = bool(enable_late_room) + self.room_config = room_config + self.room_config.validate() + self.output_gain = float(output_gain) + if (self.source_count <= 0 or not math.isfinite(self.sample_rate_hz) + or self.sample_rate_hz <= 0.0): + raise ValueError("source_count and sample rate must be positive") + if not math.isfinite(self.output_gain): + raise ValueError("output gain must be finite") + if abs(self.sample_rate_hz - 48000.0) > 1.0e-9: + raise ValueError("the public binaural runtime requires 48 kHz") + + if not isinstance(field, SofaHrtfField): + raise TypeError( + "field must be SofaHrtfField; use from_sofa() or " + "from_compiled_cache() for file inputs") + self.field = field + self.hrtf_input_kind = "field" + self.hrtf_input_path: str | None = None + self.cache_policy: str | None = None + if abs(self.field.sample_rate_hz - self.sample_rate_hz) > 1.0e-9: + raise ValueError("HRTF field sample rate does not match the renderer") + + self.early_history_slots = int(history_slots) + if self.early_history_slots <= 0: + raise ValueError("history_slots must be positive") + maximum_hrtf_delay = float(np.max(self.field.delay_bounds[:, 1], initial=0.0)) + self.maximum_hrtf_delay_samples = maximum_hrtf_delay + self.hrtf_history_slots = int(math.ceil(maximum_hrtf_delay / QMF_HOP)) + self.analysis = PublicAnalysis77(self.source_count) + self.paths = HybridObjectPathRenderer( + self.source_count, + history_slots=self.early_history_slots + self.hrtf_history_slots, + transition_slots=transition_slots) + self.synthesis = PublicSynthesis77(2) + actual_fdn_config = fdn_config or LateFdnConfig(sample_rate_hz=self.sample_rate_hz) + if abs(actual_fdn_config.sample_rate_hz - self.sample_rate_hz) > 1.0e-9: + raise ValueError("FDN sample rate does not match the renderer") + self.fdn = SharedUnitaryFdn(actual_fdn_config) + self.late_delay = _StereoDelay(ANALYSIS_SYNTHESIS_LATENCY_SAMPLES) + + self.positions = np.zeros((self.source_count, 3), dtype=np.float64) + self.positions[:, 1] = 1.0 + self.profiles = [self.default_profile] * self.source_count + self.user_gain = np.ones(self.source_count, dtype=np.float64) + self.special_lfe = np.zeros(self.source_count, dtype=bool) + self.distance_state: list[DistanceState | None] = [None] * self.source_count + self.late_current = np.zeros(self.source_count, dtype=np.float64) + self.late_start = np.zeros(self.source_count, dtype=np.float64) + self.late_target = np.zeros(self.source_count, dtype=np.float64) + self.late_fade_position = np.zeros(self.source_count, dtype=np.int64) + self.late_fade_total = np.zeros(self.source_count, dtype=np.int64) + self.maximum_early_delay_samples = 0.0 + self.latency_to_discard = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + self.processed_input_samples = 0 + self.output_samples = 0 + self.parameter_updates = 0 + self.finished = False + for source in range(self.source_count): + self.set_source( + source, self.positions[source], profile=self.default_profile, + fade=False) + + @classmethod + def from_sofa( + cls, sofa: str | Path | CanonicalHrtf, *, + cache_policy: str = "memory", + cache_dir: str | Path | None = None, + shell_radius_m: float = 1.0, + order: int = DEFAULT_ORDER, + projection_ridge: float = DEFAULT_PROJECTION_RIDGE, + sh_ridge: float = DEFAULT_SH_RIDGE, + **renderer_options) -> "SofaBinauralBackend": + """Compile a SOFA source once and construct the runtime renderer.""" + field = compile_sofa_hrtf( + sofa, + target_sample_rate_hz=float( + renderer_options.get("sample_rate_hz", 48000.0)), + shell_radius_m=shell_radius_m, + order=order, + projection_ridge=projection_ridge, + sh_ridge=sh_ridge, + cache_policy=cache_policy, + cache_dir=cache_dir) + result = cls(field, **renderer_options) + result.hrtf_input_kind = "sofa" + result.hrtf_input_path = ( + str(Path(sofa).expanduser().resolve()) + if not isinstance(sofa, CanonicalHrtf) else sofa.source_path) + result.cache_policy = str(cache_policy).lower() + return result + + @classmethod + def from_compiled_cache( + cls, cache: str | Path, **renderer_options + ) -> "SofaBinauralBackend": + """Load an explicitly selected validated JOC compiled HRTF cache.""" + path = Path(cache).expanduser().resolve() + field = SofaHrtfField.load(path) + result = cls(field, **renderer_options) + result.hrtf_input_kind = "compiled_cache" + result.hrtf_input_path = str(path) + result.cache_policy = None + return result + + def _make_path(self, label: str, direction_adm, path_distance_m: float, + extra_delay_samples: float, amplitude: float) -> HybridPath: + del path_distance_m + evaluation = self.field.evaluate_adm(direction_adm) + extra_delay = float(extra_delay_samples) + if not math.isfinite(extra_delay) or extra_delay < 0.0: + raise ValueError("path delay must be finite and non-negative") + early_delay_slots = int(math.floor(extra_delay / QMF_HOP)) + if early_delay_slots >= self.early_history_slots: + raise ValueError( + f"path {label!r} needs {early_delay_slots} early-delay slots, " + f"early history capacity is {self.early_history_slots}") + total_delay = np.asarray(evaluation.delay_samples, dtype=np.float64) + extra_delay + delay_slots = np.floor(total_delay / QMF_HOP).astype(np.int64) + residual = total_delay - delay_slots * QMF_HOP + propagation_phase = np.exp( + -2j * np.pi * self.field.band_center_frequencies_hz[None, :] + * residual[:, None] + / self.sample_rate_hz) + transfer = np.asarray( + evaluation.aligned_gains * propagation_phase * float(amplitude), + dtype=np.complex128) + return HybridPath(label, delay_slots, transfer) + + def _ordinary_paths(self, state: DistanceState, gain: float) -> tuple[HybridPath, ...]: + # Object PCM is programme-normalized. Physical distance controls room + # geometry, while the project profile supplies the direct presentation + # coefficient instead of applying a second free-field 1/r attenuation. + direct_amplitude = gain * ReferenceDistanceProfileV1.direct_level_gain(state) + room_gain = ReferenceDistanceProfileV1.room_calibration_gain(state) + paths = [self._make_path( + "direct", state.direction_adm, state.physical_distance_m, 0.0, + direct_amplitude)] + if self.enable_early_reflections: + reflections = first_order_image_sources( + state.direction_adm, state.physical_distance_m, + self.sample_rate_hz, self.room_config) + for reflection in reflections: + amplitude = ( + gain * room_gain * reflection.reflection_gain + * ReferenceDistanceProfileV1.inverse_distance_gain( + self.field.measurement_radius_m, + reflection.path_distance_m)) + paths.append(self._make_path( + f"early:{reflection.wall}", reflection.direction_adm, + reflection.path_distance_m, reflection.extra_delay_samples, + amplitude)) + self.maximum_early_delay_samples = max( + self.maximum_early_delay_samples, + reflection.extra_delay_samples) + return tuple(paths) + + def _lfe_paths(self, gain: float) -> tuple[HybridPath, ...]: + frequency = self.field.band_center_frequencies_hz + lowpass = np.ones(HYBRID_BANDS, dtype=np.float64) + lowpass[frequency >= 180.0] = 0.0 + transition = (frequency > 120.0) & (frequency < 180.0) + amount = (frequency[transition] - 120.0) / 60.0 + lowpass[transition] = np.cos(0.5 * np.pi * amount) ** 2 + transfer = np.repeat( + (gain * lowpass / math.sqrt(2.0))[None, :], 2, axis=0 + ).astype(np.complex128) + return (HybridPath( + "public_lfe_lowpass", np.zeros(2, dtype=np.int64), transfer),) + + def _set_late_target(self, source: int, value: float, fade: bool) -> None: + value = float(value) + fade_samples = (self.paths.transition_slots * QMF_HOP + if fade and self.processed_input_samples else 0) + if fade_samples == 0: + self.late_current[source] = value + self.late_start[source] = value + self.late_target[source] = value + self.late_fade_position[source] = 0 + self.late_fade_total[source] = 0 + else: + self.late_start[source] = self.late_current[source] + self.late_target[source] = value + self.late_fade_position[source] = 0 + self.late_fade_total[source] = fade_samples + + def set_source(self, source: int, position_adm, *, profile: str | None = None, + gain: float = 1.0, enabled: bool = True, + special_lfe: bool = False, fade: bool = True) -> None: + if self.finished: + raise RuntimeError("SOFA renderer is finished") + source = int(source) + if not 0 <= source < self.source_count: + raise IndexError(source) + gain = float(gain) + if not math.isfinite(gain): + raise ValueError("source gain must be finite") + effective_gain = gain if enabled else 0.0 + name = self.default_profile if profile is None else profile + state = ReferenceDistanceProfileV1.map_adm_position(position_adm, name) + path_set = (self._lfe_paths(effective_gain) if special_lfe + else self._ordinary_paths(state, effective_gain)) + self.paths.set_paths( + source, path_set, + fade_slots=(self.paths.transition_slots if fade else 0)) + late_send = (0.0 if special_lfe or not self.enable_late_room or not enabled + else effective_gain + * ReferenceDistanceProfileV1.room_calibration_gain(state) + * ReferenceDistanceProfileV1.late_send(state)) + self._set_late_target(source, late_send, fade) + self.positions[source] = np.asarray(position_adm, dtype=np.float64) + self.profiles[source] = state.profile + self.user_gain[source] = gain + self.special_lfe[source] = bool(special_lfe) + self.distance_state[source] = state + self.parameter_updates += 1 + + def _late_send_envelope(self, sample_count: int) -> np.ndarray: + envelope = np.empty((sample_count, self.source_count), dtype=np.float64) + for source in range(self.source_count): + total = int(self.late_fade_total[source]) + if total == 0: + envelope[:, source] = self.late_current[source] + continue + start_position = int(self.late_fade_position[source]) + position = start_position + np.arange(1, sample_count + 1) + amount = np.clip(position / float(total), 0.0, 1.0) + envelope[:, source] = ( + self.late_start[source] * (1.0 - amount) + + self.late_target[source] * amount) + new_position = start_position + sample_count + if new_position >= total: + self.late_current[source] = self.late_target[source] + self.late_start[source] = self.late_target[source] + self.late_fade_position[source] = 0 + self.late_fade_total[source] = 0 + else: + self.late_current[source] = float(envelope[-1, source]) + self.late_fade_position[source] = new_position + return envelope + + def _process(self, sources) -> np.ndarray: + values = np.asarray(sources, dtype=np.float64) + if values.ndim != 2 or values.shape[1] != self.source_count: + raise ValueError(f"sources must have shape [samples,{self.source_count}]") + if len(values) % QMF_HOP: + raise ValueError("SOFA backend input must be divisible by 64 samples") + if not np.isfinite(values).all(): + raise ValueError("SOFA backend input contains non-finite values") + hybrid = self.analysis.process(values) + direct_and_early = self.paths.process(hybrid) + direct_pcm = self.synthesis.process(direct_and_early) + if self.enable_late_room: + sends = self._late_send_envelope(len(values)) + mono = np.sum(values * sends, axis=1, dtype=np.float64) + late_pcm = self.late_delay.process(self.fdn.process(mono)) + else: + # Still advance any pending send fade deterministically. + self._late_send_envelope(len(values)) + late_pcm = np.zeros_like(direct_pcm) + mixed = np.asarray((direct_pcm + late_pcm) * self.output_gain, dtype=np.float64) + skip = min(self.latency_to_discard, len(mixed)) + self.latency_to_discard -= skip + self.processed_input_samples += len(values) + output = mixed[skip:] + self.output_samples += len(output) + return output + + def process(self, sources) -> np.ndarray: + if self.finished: + raise RuntimeError("SOFA renderer is finished") + return self._process(sources) + + def finish(self, *, tail_seconds: float | None = None) -> np.ndarray: + if self.finished: + return np.zeros((0, 2), dtype=np.float64) + if tail_seconds is not None and ( + not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0): + raise ValueError("tail_seconds must be finite and non-negative") + requested = (self.fdn.tail_samples if tail_seconds is None + else int(math.ceil(float(tail_seconds) * self.sample_rate_hz))) + drain = max( + requested if self.enable_late_room else 0, + int(math.ceil( + self.maximum_hrtf_delay_samples + + self.maximum_early_delay_samples)) + 2048, + ) + ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + drain = int(math.ceil(drain / QMF_HOP) * QMF_HOP) + output = self._process(np.zeros((drain, self.source_count), dtype=np.float64)) + self.finished = True + return output + + def finish_output_capacity(self, tail_seconds: float | None = None) -> int: + """Return a conservative bound for one future :meth:`finish` output.""" + if tail_seconds is not None and ( + not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0): + raise ValueError("tail_seconds must be finite and non-negative") + requested = (self.fdn.tail_samples if tail_seconds is None + else int(math.ceil(float(tail_seconds) * self.sample_rate_hz))) + hrtf_bound = self.hrtf_history_slots * QMF_HOP + early_bound = hrtf_bound + 2048 + if self.enable_early_reflections: + early_bound += self.early_history_slots * QMF_HOP + drain = max(requested if self.enable_late_room else 0, early_bound) + drain += ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + return int(math.ceil(drain / QMF_HOP) * QMF_HOP) + + def reset(self) -> None: + self.analysis.reset() + self.paths.reset() + self.synthesis.reset() + self.fdn.reset() + self.late_delay.reset() + self.latency_to_discard = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + self.processed_input_samples = 0 + self.output_samples = 0 + self.finished = False + for source in range(self.source_count): + self.set_source( + source, self.positions[source], profile=self.profiles[source], + gain=float(self.user_gain[source]), + special_lfe=bool(self.special_lfe[source]), fade=False) + + def info(self) -> dict: + return { + "name": "SofaBinauralBackend", + "source_count": self.source_count, + "sample_rate_hz": self.sample_rate_hz, + "precision": "float64/complex128", + "signal_path": ( + "public 64-QMF -> public 77-hybrid -> SOFA order-5 real-SH " + "direct/early -> public synthesis + shared unitary FDN"), + "hrtf_input_kind": self.hrtf_input_kind, + "hrtf_input_path": self.hrtf_input_path, + "cache_policy": self.cache_policy, + "latency_compensated_samples": ANALYSIS_SYNTHESIS_LATENCY_SAMPLES, + "enable_early_reflections": self.enable_early_reflections, + "enable_late_room": self.enable_late_room, + "early_history_slots": self.early_history_slots, + "hrtf_history_slots": self.hrtf_history_slots, + "maximum_hrtf_delay_samples": self.maximum_hrtf_delay_samples, + "maximum_early_delay_samples": self.maximum_early_delay_samples, + "parameter_updates": self.parameter_updates, + "processed_input_samples_including_flush": self.processed_input_samples, + "output_samples_before_trim": self.output_samples, + "distance": ReferenceDistanceProfileV1.info(), + "filterbank": filterbank_table_info(), + "field": self.field.info(), + "late_room": self.fdn.info(), + } diff --git a/src/sofa_canonical.py b/src/sofa_canonical.py new file mode 100644 index 0000000..894d9ab --- /dev/null +++ b/src/sofa_canonical.py @@ -0,0 +1,637 @@ +"""Strict SimpleFreeFieldHRIR to canonical HRTF import. + +The canonical representation keeps ``Data.IR`` and ``Data.Delay`` separate. +No importer operation silently bakes the SOFA delay into the stored FIRs. A +caller must explicitly request :meth:`CanonicalHrtf.materialized_measurement` +when a time-domain FIR with ``Data.Delay`` applied exactly once is required. +""" +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, replace +from fractions import Fraction +import hashlib +import math +from pathlib import Path +from typing import Any + +import h5py +import numpy as np +from scipy import signal +from scipy.fft import next_fast_len + + +_SUPPORTED_VERSIONS = {"0.4", "1.0", "1.1"} +_FREE_FIELD_ROOM_TYPES = {"free field", "free-field", "anechoic", "hemi-anechoic"} +_LENGTH_UNITS = { + "m": 1.0, + "metre": 1.0, + "metres": 1.0, + "meter": 1.0, + "meters": 1.0, + "cm": 1.0e-2, + "centimetre": 1.0e-2, + "centimetres": 1.0e-2, + "centimeter": 1.0e-2, + "centimeters": 1.0e-2, + "mm": 1.0e-3, + "millimetre": 1.0e-3, + "millimetres": 1.0e-3, + "millimeter": 1.0e-3, + "millimeters": 1.0e-3, +} +_ANGLE_UNITS = { + "degree": np.deg2rad, + "degrees": np.deg2rad, + "radian": lambda value: np.asarray(value, dtype=np.float64), + "radians": lambda value: np.asarray(value, dtype=np.float64), +} + + +class SofaImportError(ValueError): + """The file is outside the deliberately narrow public SOFA contract.""" + + +def _text(value: Any) -> str: + if isinstance(value, np.ndarray) and value.shape == (): + value = value.item() + if isinstance(value, (bytes, np.bytes_)): + return value.decode("utf-8", "strict") + return str(value) + + +def _sha256_stream(stream) -> str: + digest = hashlib.sha256() + stream.seek(0) + for block in iter(lambda: stream.read(4 << 20), b""): + digest.update(block) + stream.seek(0) + return digest.hexdigest().upper() + + +@contextmanager +def _stable_hdf5_source(path: Path): + """Read arrays and content identity from one stable open-file snapshot.""" + with path.open("rb") as stream: + before = _sha256_stream(stream) + with h5py.File(stream, "r") as file: + yield file, before + after = _sha256_stream(stream) + if after != before: + raise SofaImportError("SOFA file changed while it was being imported") + + +def _tokens(units: str) -> list[str]: + return [token.strip().lower() for token in units.split(",") if token.strip()] + + +def _coordinate_attributes(dataset: h5py.Dataset, *, inherit=None) -> tuple[str, str]: + source = dataset.attrs + if "Type" not in source or "Units" not in source: + if inherit is None or "Type" not in inherit.attrs or "Units" not in inherit.attrs: + raise SofaImportError(f"{dataset.name} must declare Type and Units") + source = inherit.attrs + return _text(source["Type"]).strip().lower(), _text(source["Units"]).strip() + + +def coordinates_to_cartesian_m(values, coordinate_type: str, units: str, + *, variable: str) -> np.ndarray: + """Convert a SOFA coordinate array to Cartesian metres without reshaping it.""" + data = np.asarray(values, dtype=np.float64) + if data.shape[-1] != 3 or not np.isfinite(data).all(): + raise SofaImportError(f"{variable} must contain finite C=3 coordinates") + kind = coordinate_type.strip().lower() + unit_tokens = _tokens(units) + if kind == "cartesian": + if len(unit_tokens) == 1: + factors = [_LENGTH_UNITS.get(unit_tokens[0])] * 3 + elif len(unit_tokens) == 3: + factors = [_LENGTH_UNITS.get(token) for token in unit_tokens] + else: + factors = [] + if len(factors) != 3 or any(value is None for value in factors): + raise SofaImportError(f"unsupported Cartesian units for {variable}: {units!r}") + return data * np.asarray(factors, dtype=np.float64) + if kind != "spherical" or len(unit_tokens) != 3: + raise SofaImportError( + f"unsupported coordinates for {variable}: Type={coordinate_type!r}, Units={units!r}") + if unit_tokens[0] not in _ANGLE_UNITS or unit_tokens[1] not in _ANGLE_UNITS: + raise SofaImportError(f"unsupported spherical angle units for {variable}: {units!r}") + radius_factor = _LENGTH_UNITS.get(unit_tokens[2]) + if radius_factor is None: + raise SofaImportError(f"unsupported spherical radius unit for {variable}: {units!r}") + azimuth = _ANGLE_UNITS[unit_tokens[0]](data[..., 0]) + elevation = _ANGLE_UNITS[unit_tokens[1]](data[..., 1]) + radius = data[..., 2] * radius_factor + if np.any(radius < 0.0): + raise SofaImportError(f"{variable} contains a negative spherical radius") + horizontal = np.cos(elevation) + return np.stack( + (radius * horizontal * np.cos(azimuth), + radius * horizontal * np.sin(azimuth), + radius * np.sin(elevation)), + axis=-1, + ).astype(np.float64, copy=False) + + +def _rows(file: h5py.File, name: str, measurements: int, *, inherit=None) -> np.ndarray: + if name not in file: + raise SofaImportError(f"missing required SOFA variable {name}") + dataset = file[name] + kind, units = _coordinate_attributes(dataset, inherit=inherit) + result = coordinates_to_cartesian_m(dataset[...], kind, units, variable=name) + if result.shape not in ((1, 3), (measurements, 3)): + raise SofaImportError( + f"{name} must have shape [I,C] or [M,C], got {result.shape}") + return np.broadcast_to(result, (measurements, 3)).astype(np.float64, copy=True) + + +def _receiver_rows(file: h5py.File, measurements: int) -> np.ndarray: + if "ReceiverPosition" not in file: + raise SofaImportError("missing required SOFA variable ReceiverPosition") + dataset = file["ReceiverPosition"] + raw = np.asarray(dataset[...], dtype=np.float64) + if raw.shape not in ((2, 3, 1), (2, 3, measurements)): + raise SofaImportError( + "ReceiverPosition must have shape [R=2,C=3,I=1 or M]") + values = np.moveaxis(raw, 1, -1) # [R,I/M,C] + kind, units = _coordinate_attributes(dataset) + cartesian = coordinates_to_cartesian_m( + values, kind, units, variable="ReceiverPosition") + cartesian = np.moveaxis(cartesian, 0, 1) # [I/M,R,C] + return np.broadcast_to(cartesian, (measurements, 2, 3)).astype( + np.float64, copy=True) + + +def _emitter_is_origin(file: h5py.File, measurements: int) -> None: + if "EmitterPosition" not in file: + raise SofaImportError("missing required SOFA variable EmitterPosition") + dataset = file["EmitterPosition"] + raw = np.asarray(dataset[...], dtype=np.float64) + if raw.shape not in ((1, 3, 1), (1, 3, measurements)): + raise SofaImportError("SimpleFreeFieldHRIR v1 requires E=1 EmitterPosition[E,C,I/M]") + values = np.moveaxis(raw, 1, -1) + kind, units = _coordinate_attributes(dataset) + cartesian = coordinates_to_cartesian_m( + values, kind, units, variable="EmitterPosition") + if np.max(np.abs(cartesian), initial=0.0) > 1.0e-9: + raise SofaImportError("non-zero EmitterPosition needs a separate source-pose adapter") + + +def _sampling_rate(file: h5py.File) -> float: + if "Data.SamplingRate" not in file: + raise SofaImportError("missing Data.SamplingRate") + dataset = file["Data.SamplingRate"] + values = np.asarray(dataset[...], dtype=np.float64).reshape(-1) + if values.size != 1 or not math.isfinite(float(values[0])) or values[0] <= 0.0: + raise SofaImportError("Data.SamplingRate must contain one positive finite value") + units = _text(dataset.attrs.get("Units", "")).strip().lower() + if units not in {"hertz", "hz"}: + raise SofaImportError(f"Data.SamplingRate Units must be hertz, got {units!r}") + return float(values[0]) + + +def _processing_label(file: h5py.File) -> str: + parts = [] + for key in ("DatabaseName", "Title", "ListenerShortName", "Comment"): + value = _text(file.attrs.get(key, "")).strip() + if value and value not in parts: + parts.append(value) + return " | ".join(parts) + + +def _read_delay(file: h5py.File, measurements: int) -> np.ndarray: + if "Data.Delay" not in file: + raise SofaImportError("missing Data.Delay") + delay = np.asarray(file["Data.Delay"][...], dtype=np.float64) + if delay.shape not in ((1, 2), (measurements, 2)) or not np.isfinite(delay).all(): + raise SofaImportError("Data.Delay must have finite shape [I=1,R=2] or [M,R=2]") + delay = np.broadcast_to(delay, (measurements, 2)).astype(np.float64, copy=True) + if np.min(delay, initial=0.0) < -1.0e-9: + raise SofaImportError("negative Data.Delay is outside the supported causal contract") + delay[delay < 0.0] = 0.0 + return delay + + +def _readonly(array, dtype) -> np.ndarray: + result = np.asarray(array, dtype=dtype) + result.setflags(write=False) + return result + + +@dataclass(frozen=True) +class CanonicalHrtf: + source_path: str + source_sha256: str + convention: str + convention_version: str + sofa_version: str + source_sample_rate_hz: float + sample_rate_hz: float + source_position_cartesian_m: np.ndarray # listener-local [M,3] + listener_view: np.ndarray # world, normalized [M,3] + listener_up: np.ndarray # world, orthonormal [M,3] + receiver_position_cartesian_m: np.ndarray # listener-local, L/R [M,2,3] + left_receiver_index: int + right_receiver_index: int + hrir: np.ndarray # canonical L/R [M,2,N] + delay_samples: np.ndarray # canonical L/R [M,2], not applied + measurement_radius_m: np.ndarray # [M] + processing_label: str + resampling_label: str = "none" + + def __post_init__(self): + object.__setattr__(self, "source_position_cartesian_m", _readonly( + self.source_position_cartesian_m, np.float64)) + object.__setattr__(self, "listener_view", _readonly(self.listener_view, np.float64)) + object.__setattr__(self, "listener_up", _readonly(self.listener_up, np.float64)) + object.__setattr__(self, "receiver_position_cartesian_m", _readonly( + self.receiver_position_cartesian_m, np.float64)) + object.__setattr__(self, "hrir", _readonly(self.hrir, np.float64)) + object.__setattr__(self, "delay_samples", _readonly(self.delay_samples, np.float64)) + object.__setattr__(self, "measurement_radius_m", _readonly( + self.measurement_radius_m, np.float64)) + + @property + def measurements(self) -> int: + return int(self.hrir.shape[0]) + + @property + def taps(self) -> int: + return int(self.hrir.shape[2]) + + @property + def unit_directions(self) -> np.ndarray: + return self.source_position_cartesian_m / self.measurement_radius_m[:, None] + + @property + def shells_m(self) -> np.ndarray: + return np.unique(np.round(self.measurement_radius_m, 9)) + + def shell_indices(self, radius_m: float) -> np.ndarray: + shell = float(self.shells_m[np.argmin(np.abs(self.shells_m - float(radius_m)))]) + return np.flatnonzero(np.isclose( + self.measurement_radius_m, shell, atol=5.0e-7, rtol=0.0)) + + def nearest_index(self, direction_sofa, radius_m: float = 1.0) -> tuple[int, float]: + direction = np.asarray(direction_sofa, dtype=np.float64) + if direction.shape != (3,) or not np.isfinite(direction).all(): + raise ValueError("direction must contain three finite SOFA Cartesian values") + norm = float(np.linalg.norm(direction)) + if norm <= 1.0e-15: + raise ValueError("direction must be non-zero") + direction = direction / norm + indices = self.shell_indices(radius_m) + dots = self.unit_directions[indices] @ direction + local = int(np.argmax(dots)) + error = math.degrees(math.acos(float(np.clip(dots[local], -1.0, 1.0)))) + return int(indices[local]), float(error) + + def resampled(self, target_sample_rate_hz: float) -> "CanonicalHrtf": + target = float(target_sample_rate_hz) + if not math.isfinite(target) or target <= 0.0: + raise ValueError("target sample rate must be positive and finite") + if abs(target - self.sample_rate_hz) <= 1.0e-9: + return self + ratio = target / self.sample_rate_hz + fraction = Fraction(ratio).limit_denominator(100000) + if abs(float(fraction) - ratio) > 1.0e-10: + raise ValueError("sample-rate ratio cannot be represented safely") + converted = signal.resample_poly( + np.asarray(self.hrir, dtype=np.float64), fraction.numerator, + fraction.denominator, axis=-1, window=("kaiser", 8.6), padtype="constant") + converted = np.asarray(converted, dtype=np.float64) + return replace( + self, + sample_rate_hz=target, + hrir=converted, + delay_samples=np.asarray(self.delay_samples * ratio, dtype=np.float64), + resampling_label=( + f"scipy.signal.resample_poly {self.sample_rate_hz:g}->{target:g} Hz " + f"({fraction.numerator}/{fraction.denominator}, Kaiser beta=8.6)"), + ) + + def materialized_measurement(self, index: int, *, fractional_half_length: int = 48 + ) -> np.ndarray: + """Return [L/R,taps] with SOFA Data.Delay applied exactly once.""" + index = int(index) + if not 0 <= index < self.measurements: + raise IndexError(index) + ears = [] + for ear in range(2): + ears.append(apply_fractional_delay( + self.hrir[index, ear], float(self.delay_samples[index, ear]), + half_length=fractional_half_length)) + length = max(map(len, ears)) + result = np.zeros((2, length), dtype=np.float64) + for ear, value in enumerate(ears): + result[ear, :len(value)] = value + return result + + def info(self) -> dict: + return { + "source_path": self.source_path, + "source_sha256": self.source_sha256, + "convention": self.convention, + "convention_version": self.convention_version, + "sofa_version": self.sofa_version, + "source_sample_rate_hz": self.source_sample_rate_hz, + "sample_rate_hz": self.sample_rate_hz, + "measurements": self.measurements, + "taps": self.taps, + "shells_m": [float(value) for value in self.shells_m], + "source_receiver_order": [self.left_receiver_index, self.right_receiver_index], + "canonical_ear_order": ["left", "right"], + "data_delay_samples_min": float(np.min(self.delay_samples)), + "data_delay_samples_max": float(np.max(self.delay_samples)), + "data_delay_applied": False, + "processing_label": self.processing_label, + "resampling": self.resampling_label, + "precision": "float64", + } + + +def load_simple_free_field_hrir(path, *, target_sample_rate_hz: float | None = None + ) -> CanonicalHrtf: + """Strictly import the supported SimpleFreeFieldHRIR subset.""" + source = Path(path).expanduser().resolve() + if not source.is_file(): + raise FileNotFoundError(source) + with _stable_hdf5_source(source) as (file, source_sha256): + if _text(file.attrs.get("Conventions", "")) != "SOFA": + raise SofaImportError("Conventions must be SOFA") + convention = _text(file.attrs.get("SOFAConventions", "")) + if convention != "SimpleFreeFieldHRIR": + raise SofaImportError( + f"unsupported SOFAConventions={convention!r}; convert explicitly first") + convention_version = _text(file.attrs.get("SOFAConventionsVersion", "")) + if convention_version not in _SUPPORTED_VERSIONS: + raise SofaImportError( + f"unsupported SimpleFreeFieldHRIR version {convention_version!r}; " + f"supported={sorted(_SUPPORTED_VERSIONS)}") + if _text(file.attrs.get("DataType", "")) != "FIR": + raise SofaImportError("DataType must be FIR") + room_type = _text(file.attrs.get("RoomType", "")).strip().lower() + if room_type not in _FREE_FIELD_ROOM_TYPES: + raise SofaImportError(f"RoomType must explicitly be free-field, got {room_type!r}") + if "Data.IR" not in file: + raise SofaImportError("missing Data.IR") + hrir_source = np.asarray(file["Data.IR"][...], dtype=np.float64) + if hrir_source.ndim != 3 or hrir_source.shape[1] != 2 or min(hrir_source.shape) <= 0: + raise SofaImportError("Data.IR must have shape [M,R=2,N]") + if not np.isfinite(hrir_source).all(): + raise SofaImportError("Data.IR contains non-finite values") + measurements = int(hrir_source.shape[0]) + sample_rate = _sampling_rate(file) + delay_source = _read_delay(file, measurements) + _emitter_is_origin(file, measurements) + + listener_position = _rows(file, "ListenerPosition", measurements) + listener_view = _rows(file, "ListenerView", measurements) + listener_up_raw = _rows( + file, "ListenerUp", measurements, + inherit=file["ListenerView"] if "ListenerView" in file else None) + forward_norm = np.linalg.norm(listener_view, axis=1) + if np.any(forward_norm <= 1.0e-12): + raise SofaImportError("ListenerView must be non-zero") + forward = listener_view / forward_norm[:, None] + left = np.cross(listener_up_raw, forward) + left_norm = np.linalg.norm(left, axis=1) + if np.any(left_norm <= 1.0e-12): + raise SofaImportError("ListenerUp must not be parallel to ListenerView") + left /= left_norm[:, None] + up = np.cross(forward, left) + + if "SourcePosition" not in file: + raise SofaImportError("missing required SOFA variable SourcePosition") + source_dataset = file["SourcePosition"] + source_type, source_units = _coordinate_attributes(source_dataset) + source_world = coordinates_to_cartesian_m( + source_dataset[...], source_type, source_units, variable="SourcePosition") + if source_world.shape != (measurements, 3): + raise SofaImportError("SourcePosition must have shape [M,C=3]") + relative = source_world - listener_position + source_local = np.stack( + (np.sum(relative * forward, axis=1), + np.sum(relative * left, axis=1), + np.sum(relative * up, axis=1)), axis=1) + radii = np.linalg.norm(source_local, axis=1) + if np.any(radii <= 1.0e-8) or not np.isfinite(radii).all(): + raise SofaImportError("every source measurement must have a positive radius") + + receiver = _receiver_rows(file, measurements) + lateral_difference = receiver[:, 0, 1] - receiver[:, 1, 1] + if np.all(lateral_difference > 1.0e-5): + left_index, right_index = 0, 1 + elif np.all(lateral_difference < -1.0e-5): + left_index, right_index = 1, 0 + else: + raise SofaImportError( + "ReceiverPosition does not identify one consistently-left and one " + "consistently-right receiver") + ear_order = [left_index, right_index] + hrir = hrir_source[:, ear_order, :] + delay = delay_source[:, ear_order] + receiver = receiver[:, ear_order, :] + processing_label = _processing_label(file) + sofa_version = _text(file.attrs.get("Version", "")) + + canonical = CanonicalHrtf( + source_path=str(source), + source_sha256=source_sha256, + convention=convention, + convention_version=convention_version, + sofa_version=sofa_version, + source_sample_rate_hz=sample_rate, + sample_rate_hz=sample_rate, + source_position_cartesian_m=source_local, + listener_view=forward, + listener_up=up, + receiver_position_cartesian_m=receiver, + left_receiver_index=left_index, + right_receiver_index=right_index, + hrir=hrir, + delay_samples=delay, + measurement_radius_m=radii, + processing_label=processing_label, + ) + return (canonical if target_sample_rate_hz is None + else canonical.resampled(target_sample_rate_hz)) + + +def apply_fractional_delay(values, delay_samples: float, *, half_length: int = 48 + ) -> np.ndarray: + """Apply one causal non-negative delay to a real FIR using windowed sinc.""" + source = np.asarray(values, dtype=np.float64) + delay = float(delay_samples) + if source.ndim != 1 or not np.isfinite(source).all(): + raise ValueError("fractional delay input must be a finite real vector") + if not math.isfinite(delay) or delay < -1.0e-12: + raise ValueError("fractional delay must be finite and non-negative") + if delay < 1.0e-12: + return source.copy() + integer = int(math.floor(delay)) + fraction = delay - integer + if fraction < 1.0e-12: + return np.pad(source, (integer, 0)).astype(np.float64, copy=False) + half = int(half_length) + if half < 8: + raise ValueError("fractional delay half_length must be at least 8") + index = np.arange(-half, half + 1, dtype=np.float64) + kernel = np.sinc(index - fraction) * np.kaiser(2 * half + 1, 8.6) + kernel /= np.sum(kernel, dtype=np.float64) + full = signal.fftconvolve(source, kernel, mode="full") + causal = np.asarray(full[half:], dtype=np.float64) + return np.pad(causal, (integer, 0)).astype(np.float64, copy=False) + + +def shift_signal_fft(values, shift_samples: float) -> np.ndarray: + """Band-limited linear shift; positive is delay and negative is advance.""" + source = np.asarray(values, dtype=np.float64) + shift = float(shift_samples) + if source.ndim != 1 or not np.isfinite(source).all() or not math.isfinite(shift): + raise ValueError("shift input and amount must be finite") + if abs(shift) < 1.0e-12: + return source.copy() + guard = max(128, int(math.ceil(abs(shift))) + 64) + needed = len(source) + 2 * guard + fft_size = next_fast_len(needed) + padded = np.zeros(fft_size, dtype=np.float64) + padded[guard:guard + len(source)] = source + bins = np.arange(fft_size // 2 + 1, dtype=np.float64) + spectrum = np.fft.rfft(padded) + spectrum *= np.exp(-2j * np.pi * bins * shift / fft_size) + shifted = np.fft.irfft(spectrum, fft_size) + return np.asarray(shifted[guard:guard + len(source)], dtype=np.float64) + + +def estimate_interaural_delay_samples(left, right, sample_rate_hz: float, + *, low_hz: float = 200.0, + high_hz: float = 1500.0) -> float: + """Estimate L-minus-R delay by low-frequency circular phase coherence. + + A coarse-to-fine delay search avoids the phase-unwrapping branch failures + that ordinary straight-line regression can exhibit for strongly filtered + Far responses. + """ + left = np.asarray(left, dtype=np.float64) + right = np.asarray(right, dtype=np.float64) + if left.shape != right.shape or left.ndim != 1: + raise ValueError("ITD inputs must be equal-length vectors") + fft_size = next_fast_len(max(4096, 4 * len(left))) + left_spectrum = np.fft.rfft(left, fft_size) + right_spectrum = np.fft.rfft(right, fft_size) + frequency = np.fft.rfftfreq(fft_size, 1.0 / float(sample_rate_hz)) + selected = (frequency >= low_hz) & (frequency <= high_hz) + if np.count_nonzero(selected) < 8: + return 0.0 + cross = left_spectrum[selected] * np.conj(right_spectrum[selected]) + magnitude = np.abs(cross) + maximum = float(np.max(magnitude, initial=0.0)) + if maximum <= 1.0e-20: + return 0.0 + weighted_unit = cross / np.maximum(magnitude, 1.0e-30) + weight = np.sqrt(magnitude / maximum) + weighted_unit *= weight + omega = 2.0 * np.pi * frequency[selected] / float(sample_rate_hz) + limit = 0.0012 * float(sample_rate_hz) + + def best(candidates: np.ndarray) -> float: + steering = np.exp(1j * omega[:, None] * candidates[None, :]) + score = np.abs(weighted_unit @ steering) + return float(candidates[int(np.argmax(score))]) + + coarse = np.arange(-limit, limit + 0.25, 0.5, dtype=np.float64) + estimate = best(coarse) + fine = np.arange(estimate - 0.6, estimate + 0.6001, 0.02, dtype=np.float64) + return float(np.clip(best(fine), -limit, limit)) + +def _subsample_peak(values: np.ndarray) -> float: + magnitude = np.abs(np.asarray(values, dtype=np.float64)) + index = int(np.argmax(magnitude)) + if index == 0 or index + 1 >= len(magnitude): + return float(index) + y0, y1, y2 = (float(magnitude[index - 1]), float(magnitude[index]), + float(magnitude[index + 1])) + denominator = y0 - 2.0 * y1 + y2 + correction = 0.0 if abs(denominator) < 1.0e-30 else 0.5 * (y0 - y2) / denominator + return float(index + np.clip(correction, -0.5, 0.5)) + + +@dataclass(frozen=True) +class TimeAlignedHrtf: + canonical: CanonicalHrtf + aligned_hrir: np.ndarray + runtime_delay_samples: np.ndarray + embedded_delay_removed_samples: np.ndarray + delay_source: str + + def __post_init__(self): + object.__setattr__(self, "aligned_hrir", _readonly(self.aligned_hrir, np.float64)) + object.__setattr__(self, "runtime_delay_samples", _readonly( + self.runtime_delay_samples, np.float64)) + object.__setattr__(self, "embedded_delay_removed_samples", _readonly( + self.embedded_delay_removed_samples, np.float64)) + + +def time_align_hrtf(canonical: CanonicalHrtf) -> TimeAlignedHrtf: + """Separate one delay representation before directional interpolation. + + Trusted non-zero ``Data.Delay`` is external to ``Data.IR`` and is therefore + retained without de-rotating the FIR. When ``Data.Delay`` is identically + zero, ordinary measured HRIRs with a positive onset use their per-ear main + peaks. A zero-origin effective FIR is already expressed at one common + time origin; its interaural phase is therefore retained in ``Data.IR``. + + These representations are mutually exclusive. Runtime rendering must + restore exactly the delay separated here and must not add any second ear + delay or phase-group delay. + """ + hrir = np.asarray(canonical.hrir, dtype=np.float64) + if np.max(np.abs(canonical.delay_samples), initial=0.0) > 1.0e-12: + return TimeAlignedHrtf( + canonical=canonical, + aligned_hrir=hrir.copy(), + runtime_delay_samples=np.asarray(canonical.delay_samples, dtype=np.float64), + embedded_delay_removed_samples=np.zeros_like(canonical.delay_samples), + delay_source="Data.Delay (external; applied once at render time)", + ) + + measurements = canonical.measurements + runtime = np.zeros((measurements, 2), dtype=np.float64) + removed = np.zeros_like(runtime) + used_peak = 0 + retained_embedded_phase = 0 + for measurement in range(measurements): + peaks = np.asarray([ + _subsample_peak(hrir[measurement, 0]), + _subsample_peak(hrir[measurement, 1]), + ], dtype=np.float64) + if float(np.max(peaks)) > 2.0: + delays = peaks + used_peak += 1 + else: + # An effective response can have both ear FIRs beginning at sample + # zero while still carrying the correct ITD in complex phase. Do + # not invent an external delay which SOFA did not author. + delays = np.zeros(2, dtype=np.float64) + retained_embedded_phase += 1 + runtime[measurement] = delays + removed[measurement] = delays + + aligned = np.empty_like(hrir) + for measurement in range(measurements): + for ear in range(2): + aligned[measurement, ear] = shift_signal_fft( + hrir[measurement, ear], -float(removed[measurement, ear])) + source = ( + f"embedded Data.IR arrival separation: peak={used_peak}, " + f"zero-origin embedded phase retained={retained_embedded_phase}; " + "positive onset restored once at render time") + return TimeAlignedHrtf( + canonical=canonical, + aligned_hrir=aligned, + runtime_delay_samples=runtime, + embedded_delay_removed_samples=removed, + delay_source=source, + ) diff --git a/src/sofa_hrtf_field.py b/src/sofa_hrtf_field.py new file mode 100644 index 0000000..7dcb3b1 --- /dev/null +++ b/src/sofa_hrtf_field.py @@ -0,0 +1,1020 @@ +"""Compile canonical SOFA data into the runtime directional HRTF field.""" +from __future__ import annotations + +from collections import OrderedDict +from contextlib import contextmanager +from dataclasses import dataclass +import errno +import hashlib +import io +import json +import math +import os +from pathlib import Path +import re +import tempfile +import threading +import time +import zipfile + +import numpy as np + +from public_filterbank import ( + ANALYSIS_SYNTHESIS_LATENCY_SAMPLES, + HYBRID_BANDS, + QMF_HOP, + PublicAnalysis77, + PublicSynthesis77, + filterbank_fingerprint, + hybrid_band_center_frequencies_hz, + project_hrir_to_hybrid_gains, +) +from sofa_canonical import ( + CanonicalHrtf, + load_simple_free_field_hrir, + time_align_hrtf, +) +from spherical_harmonics import ( + evaluate_real_spherical_harmonics, + fit_real_spherical_harmonics, + real_spherical_harmonics, + spherical_voronoi_weights, +) + + +PROJECT_DIR = Path(__file__).resolve().parent.parent +DEFAULT_HRTF_CACHE_DIR = PROJECT_DIR / "output" / "hrtf-cache" +JOCHRTF_MAGIC = "JOC-HRTF-CACHE" +JOCHRTF_FORMAT_VERSION = 1 +JOCHRTF_CACHE_SCHEMA = "joc-compiled-hrtf-v1" +COMPILER_VERSION = "joc-sofa-compiler-v1" +PHASE_POLICY_VERSION = "sofa-delay-exactly-once-v1" +SH_CONVENTION = "ACN/N3D real" +TARGET_SAMPLE_RATE_HZ = 48000.0 +DEFAULT_ORDER = 5 +DEFAULT_PROJECTION_RIDGE = 1.0e-3 +DEFAULT_SH_RIDGE = 1.0e-5 + +_ARCHIVE_KEYS = { + "metadata_json", + "band_center_frequencies_hz", + "coefficients", + "delay_coefficients", + "delay_bounds", +} +_METADATA_KEYS = { + "magic", + "format_version", + "cache_schema", + "cache_key_version", + "compiler_version", + "phase_policy_version", + "sh_convention", + "filterbank", + "cache_key", + "payload_sha256", + "source_sha256", + "source_display_name", + "sample_rate_hz", + "measurement_radius_m", + "order", + "projection_ridge", + "spherical_harmonic_ridge", + "delay_source", + "fit_report", +} +_MAX_ARCHIVE_BYTES = 4 << 20 +_MAX_METADATA_BYTES = 64 << 10 +_MAX_DELAY_COEFFICIENT_ABS = TARGET_SAMPLE_RATE_HZ * 64.0 +_MEMORY_CACHE_MAX_ENTRIES = 8 +_MEMORY_CACHE: OrderedDict[str, "SofaHrtfField"] = OrderedDict() +_MEMORY_CACHE_LOCK = threading.RLock() + + +class HrtfCacheError(ValueError): + """A compiled HRTF cache is damaged, stale, or incompatible.""" + + +def adm_to_sofa_direction(position) -> np.ndarray: + """ADM +X right,+Y front,+Z up to SOFA +X front,+Y left,+Z up.""" + value = np.asarray(position, dtype=np.float64) + if value.shape != (3,) or not np.isfinite(value).all(): + raise ValueError("ADM direction must contain three finite values") + result = np.asarray([value[1], -value[0], value[2]], dtype=np.float64) + length = float(np.linalg.norm(result)) + if length <= 1.0e-15: + return np.asarray([1.0, 0.0, 0.0], dtype=np.float64) + return result / length + + +def sofa_to_adm_direction(position) -> np.ndarray: + value = np.asarray(position, dtype=np.float64) + if value.shape != (3,) or not np.isfinite(value).all(): + raise ValueError("SOFA direction must contain three finite values") + result = np.asarray([-value[1], value[0], value[2]], dtype=np.float64) + length = float(np.linalg.norm(result)) + if length <= 1.0e-15: + return np.asarray([0.0, 1.0, 0.0], dtype=np.float64) + return result / length + + +def _group_coincident(directions, gains, delays): + unit = np.asarray(directions, dtype=np.float64) + keys = np.round(unit, 10) + _, first, inverse = np.unique(keys, axis=0, return_index=True, return_inverse=True) + grouped_directions = unit[first] + grouped_gains = np.zeros((len(first),) + gains.shape[1:], dtype=np.complex128) + grouped_delays = np.zeros((len(first), 2), dtype=np.float64) + counts = np.bincount(inverse).astype(np.float64) + for measurement, group in enumerate(inverse): + grouped_gains[group] += gains[measurement] + grouped_delays[group] += delays[measurement] + grouped_gains /= counts[:, None, None] + grouped_delays /= counts[:, None] + return grouped_directions, grouped_gains, grouped_delays + + +def _cache_key_payload(*, source_sha256: str, sample_rate_hz: float, + shell_radius_m: float, order: int, + projection_ridge: float, sh_ridge: float) -> dict: + return { + "source_sha256": str(source_sha256).upper(), + "target_sample_rate_hz": float(sample_rate_hz).hex(), + "shell_radius_m": float(shell_radius_m).hex(), + "order": int(order), + "projection_ridge": float(projection_ridge).hex(), + "spherical_harmonic_ridge": float(sh_ridge).hex(), + "compiler_version": COMPILER_VERSION, + "phase_policy_version": PHASE_POLICY_VERSION, + "sh_convention": SH_CONVENTION, + "filterbank": filterbank_fingerprint(), + } + + +def compiled_hrtf_cache_key(*, source_sha256: str, sample_rate_hz: float, + shell_radius_m: float, order: int, + projection_ridge: float, + sh_ridge: float) -> str: + source_sha256 = _validate_sha256(source_sha256) + order = _strict_integer(order, "compiled HRTF order") + numeric = ( + float(sample_rate_hz), float(shell_radius_m), + float(projection_ridge), float(sh_ridge)) + if not all(math.isfinite(value) for value in numeric): + raise ValueError("compiled HRTF cache-key values must be finite") + if numeric[0] <= 0.0 or numeric[1] <= 0.0: + raise ValueError("compiled HRTF sample rate and radius must be positive") + if numeric[2] < 0.0 or numeric[3] < 0.0: + raise ValueError("compiled HRTF ridge values must be non-negative") + payload = _cache_key_payload( + source_sha256=source_sha256, + sample_rate_hz=sample_rate_hz, + shell_radius_m=shell_radius_m, + order=order, + projection_ridge=projection_ridge, + sh_ridge=sh_ridge, + ) + encoded = b"JOC-HRTF-CACHE-KEY-V1\0" + json.dumps( + payload, ensure_ascii=True, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest().upper() + + +def _safe_display_name(value: str | None) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise HrtfCacheError("source_display_name must be a string or null") + if not value: + return None + name = value + if len(name) > 255 or Path(name).name != name or "/" in name or "\\" in name: + raise HrtfCacheError("source_display_name must be a plain file name") + return name + + +def _validate_sha256(value: str, label: str = "source_sha256") -> str: + if not isinstance(value, str): + raise HrtfCacheError(f"{label} must be a hexadecimal string") + digest = value.upper() + if re.fullmatch(r"[0-9A-F]{64}", digest) is None: + raise HrtfCacheError(f"{label} must be a 64-digit hexadecimal digest") + return digest + + +def _strict_integer(value, label: str) -> int: + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)): + raise ValueError(f"{label} must be an integer") + return int(value) + + +def _json_number(metadata: dict, label: str) -> float: + value = metadata[label] + if isinstance(value, bool) or type(value) not in (int, float): + raise HrtfCacheError(f"compiled HRTF {label} must be a JSON number") + try: + result = float(value) + except (OverflowError, ValueError) as exc: + raise HrtfCacheError( + f"compiled HRTF {label} is outside the supported numeric range") from exc + if not math.isfinite(result): + raise HrtfCacheError(f"compiled HRTF {label} must be finite") + return result + + +def _json_bytes(metadata: dict) -> bytes: + encoded = json.dumps( + metadata, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + if len(encoded) > _MAX_METADATA_BYTES: + raise HrtfCacheError("compiled HRTF metadata is too large") + return encoded + + +def _payload_sha256(centers, coefficients, delay_coefficients, delay_bounds) -> str: + digest = hashlib.sha256(b"JOC-HRTF-CACHE-PAYLOAD-V1\0") + for name, value, dtype in ( + ("band_center_frequencies_hz", centers, " dict: + def pairs_hook(pairs): + result = {} + for key, value in pairs: + if key in result: + raise HrtfCacheError(f"duplicate compiled HRTF metadata key: {key}") + result[key] = value + return result + + def reject_constant(value): + raise HrtfCacheError(f"non-finite JSON number in compiled HRTF metadata: {value}") + + try: + value = json.loads( + text, object_pairs_hook=pairs_hook, parse_constant=reject_constant) + except (json.JSONDecodeError, RecursionError, ValueError) as exc: + raise HrtfCacheError(f"invalid compiled HRTF metadata JSON: {exc}") from exc + if not isinstance(value, dict): + raise HrtfCacheError("compiled HRTF metadata must be a JSON object") + return value + + +def _try_lock_stream(stream) -> bool: + """Acquire one process-owned advisory lock without unlink races.""" + try: + if os.name == "nt": + import msvcrt + + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"\0") + stream.flush() + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}: + return False + raise + + +def _unlock_stream(stream) -> None: + if os.name == "nt": + import msvcrt + + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def _cache_write_lock(target: Path, timeout_seconds: float = 30.0): + # The sidecar intentionally remains on disk. The OS releases the held + # lock on process exit, so a crashed writer cannot leave a stale owner or + # trigger the unlink/recreate ABA race of sentinel-file locks. + lock = target.with_name("." + target.name + ".lock") + deadline = time.monotonic() + float(timeout_seconds) + descriptor = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o600) + stream = os.fdopen(descriptor, "r+b", buffering=0) + acquired = False + try: + while not acquired: + acquired = _try_lock_stream(stream) + if acquired: + break + if time.monotonic() >= deadline: + raise HrtfCacheError( + f"timed out waiting for cache writer: {target.name}") + time.sleep(0.05) + yield + finally: + if acquired: + _unlock_stream(stream) + stream.close() + + +def _memory_cache_get(key: str) -> "SofaHrtfField | None": + with _MEMORY_CACHE_LOCK: + value = _MEMORY_CACHE.pop(key, None) + if value is not None: + _MEMORY_CACHE[key] = value + return value + + +def _memory_cache_put(key: str, value: "SofaHrtfField") -> None: + with _MEMORY_CACHE_LOCK: + _MEMORY_CACHE.pop(key, None) + _MEMORY_CACHE[key] = value + while len(_MEMORY_CACHE) > _MEMORY_CACHE_MAX_ENTRIES: + _MEMORY_CACHE.popitem(last=False) + + +def _read_npy_member(payload: bytes, *, name: str) -> np.ndarray: + """Validate an NPY header before NumPy is allowed to allocate its array.""" + expected = { + "band_center_frequencies_hz.npy": (" 4 * _MAX_METADATA_BYTES: + raise HrtfCacheError("compiled HRTF metadata is too large") + payload_bytes = dtype.itemsize + else: + dtype_string, expected_shape = expected[name] + if dtype.str != dtype_string or shape != expected_shape: + raise HrtfCacheError( + f"compiled HRTF {Path(name).stem} must be " + f"{dtype_string}{expected_shape}, got {dtype.str}{shape}") + payload_bytes = math.prod(expected_shape) * dtype.itemsize + if header.tell() + payload_bytes != len(payload): + raise HrtfCacheError( + f"compiled HRTF member payload length does not match its NPY header: {name}") + + try: + value = np.load(io.BytesIO(payload), allow_pickle=False) + except (EOFError, OSError, TypeError, ValueError) as exc: + raise HrtfCacheError( + f"invalid compiled HRTF NPY member: {name}: {exc}") from exc + if not isinstance(value, np.ndarray): + raise HrtfCacheError(f"compiled HRTF member is not an array: {name}") + return value + + +def _read_validated_archive(stream) -> dict[str, np.ndarray]: + size = os.fstat(stream.fileno()).st_size + if size <= 0 or size > _MAX_ARCHIVE_BYTES: + raise HrtfCacheError("compiled HRTF cache has an invalid file size") + expected = {name + ".npy" for name in _ARCHIVE_KEYS} + limits = { + "metadata_json.npy": 4 * _MAX_METADATA_BYTES + 4096, + "band_center_frequencies_hz.npy": 8192, + "coefficients.npy": 200000, + "delay_coefficients.npy": 8192, + "delay_bounds.npy": 4096, + } + try: + stream.seek(0) + with zipfile.ZipFile(stream, "r") as archive: + members = archive.infolist() + if (len(members) != len(expected) + or {member.filename for member in members} != expected): + raise HrtfCacheError("compiled HRTF cache has an invalid member set") + total_size = 0 + arrays = {} + for member in members: + if member.flag_bits & 0x1: + raise HrtfCacheError("encrypted compiled HRTF caches are unsupported") + if member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise HrtfCacheError("unsupported compiled HRTF compression method") + if member.file_size > limits[member.filename]: + raise HrtfCacheError( + f"compiled HRTF member is unexpectedly large: {member.filename}") + total_size += member.file_size + if total_size > 512 << 10: + raise HrtfCacheError("compiled HRTF cache expands beyond its size limit") + for member in members: + with archive.open(member, "r") as member_stream: + payload = member_stream.read(limits[member.filename] + 1) + if len(payload) != member.file_size: + raise HrtfCacheError( + f"compiled HRTF member has an invalid expanded size: " + f"{member.filename}") + arrays[Path(member.filename).stem] = _read_npy_member( + payload, name=member.filename) + return arrays + except HrtfCacheError: + raise + except (EOFError, RuntimeError, zipfile.BadZipFile, OSError, ValueError) as exc: + raise HrtfCacheError(f"invalid compiled HRTF archive: {exc}") from exc + + +@dataclass(frozen=True) +class FieldEvaluation: + aligned_gains: np.ndarray + delay_samples: np.ndarray + transfer_gains: np.ndarray + + +@dataclass(frozen=True) +class SofaHrtfField: + source_sha256: str + source_display_name: str | None + sample_rate_hz: float + measurement_radius_m: float + order: int + projection_ridge: float + spherical_harmonic_ridge: float + band_center_frequencies_hz: np.ndarray + coefficients: np.ndarray + delay_coefficients: np.ndarray + delay_bounds: np.ndarray + delay_source: str + fit_report: dict + cache_key: str + format_version: int = JOCHRTF_FORMAT_VERSION + + def __post_init__(self): + digest = _validate_sha256(self.source_sha256) + display_name = _safe_display_name(self.source_display_name) + rate = float(self.sample_rate_hz) + radius = float(self.measurement_radius_m) + projection_ridge = float(self.projection_ridge) + sh_ridge = float(self.spherical_harmonic_ridge) + order = _strict_integer(self.order, "field order") + format_version = _strict_integer(self.format_version, "field format_version") + if format_version != JOCHRTF_FORMAT_VERSION: + raise HrtfCacheError( + f"unsupported .jochrtf version {format_version}; rebuild it from the source SOFA") + if not (math.isfinite(rate) and rate > 0.0 + and math.isfinite(radius) and radius > 0.0): + raise ValueError("field sample rate and measurement radius must be positive") + if abs(rate - TARGET_SAMPLE_RATE_HZ) > 1.0e-9: + raise ValueError("runtime HRTF fields must use 48 kHz") + if not (math.isfinite(projection_ridge) and projection_ridge >= 0.0 + and math.isfinite(sh_ridge) and sh_ridge >= 0.0): + raise ValueError("field ridge values must be finite and non-negative") + if order != DEFAULT_ORDER: + raise ValueError("runtime HRTF fields must be fifth order") + terms = (order + 1) ** 2 + centers = np.array( + self.band_center_frequencies_hz, dtype=" delay_bounds[:, 1]): + raise ValueError("field delay bounds are reversed") + if np.any(delay_bounds < 0.0): + raise ValueError("field delay bounds must be non-negative") + for value in (centers, coefficients, delay_coefficients, delay_bounds): + if not np.isfinite(value).all(): + raise ValueError("field contains non-finite values") + value.setflags(write=False) + if np.any(centers < 0.0) or np.any(centers >= 0.5 * rate): + raise ValueError("field band centers must lie in [0, Nyquist)") + if np.max(np.abs(coefficients), initial=0.0) > 1.0e6: + raise ValueError("field coefficients exceed the supported safety bound") + if np.max(np.abs(delay_bounds), initial=0.0) > rate: + raise ValueError("field delays exceed the supported one-second bound") + if (np.max(np.abs(delay_coefficients), initial=0.0) + > _MAX_DELAY_COEFFICIENT_ABS): + raise ValueError("field delay coefficients exceed the supported safety bound") + expected_key = compiled_hrtf_cache_key( + source_sha256=digest, + sample_rate_hz=rate, + shell_radius_m=radius, + order=order, + projection_ridge=projection_ridge, + sh_ridge=sh_ridge, + ) + if str(self.cache_key).upper() != expected_key: + raise HrtfCacheError("compiled HRTF cache key does not match its configuration") + object.__setattr__(self, "source_sha256", digest) + object.__setattr__(self, "source_display_name", display_name) + object.__setattr__(self, "sample_rate_hz", rate) + object.__setattr__(self, "measurement_radius_m", radius) + object.__setattr__(self, "order", order) + object.__setattr__(self, "format_version", format_version) + object.__setattr__(self, "projection_ridge", projection_ridge) + object.__setattr__(self, "spherical_harmonic_ridge", sh_ridge) + object.__setattr__(self, "band_center_frequencies_hz", centers) + object.__setattr__(self, "coefficients", coefficients) + object.__setattr__(self, "delay_coefficients", delay_coefficients) + object.__setattr__(self, "delay_bounds", delay_bounds) + object.__setattr__(self, "cache_key", expected_key) + object.__setattr__(self, "fit_report", json.loads( + _json_bytes(dict(self.fit_report)).decode("utf-8"))) + + @classmethod + def fit(cls, canonical: CanonicalHrtf, *, shell_radius_m: float | None = None, + order: int = DEFAULT_ORDER, ridge: float = DEFAULT_SH_RIDGE, + projection_ridge: float = DEFAULT_PROJECTION_RIDGE) -> "SofaHrtfField": + order = _strict_integer(order, "field order") + if order != DEFAULT_ORDER: + raise ValueError("the runtime HRTF field is fixed at fifth order") + if abs(float(canonical.sample_rate_hz) - TARGET_SAMPLE_RATE_HZ) > 1.0e-9: + raise ValueError("SOFA HRTF fields must be compiled at 48 kHz") + aligned = time_align_hrtf(canonical) + shell_target = 1.0 if shell_radius_m is None else float(shell_radius_m) + if not math.isfinite(shell_target) or shell_target <= 0.0: + raise ValueError("shell_radius_m must be positive and finite") + indices = canonical.shell_indices(shell_target) + if len(indices) < (order + 1) ** 2: + raise ValueError( + f"fifth-order SH needs at least 36 measurements on one shell, got {len(indices)}") + shell_radius = float(np.mean(canonical.measurement_radius_m[indices])) + directions = canonical.unit_directions[indices] + runtime_delay = np.asarray(aligned.runtime_delay_samples[indices], dtype=np.float64) + centers = hybrid_band_center_frequencies_hz(canonical.sample_rate_hz) + gains, projection_report = project_hrir_to_hybrid_gains( + np.asarray(canonical.hrir[indices], dtype=np.float64), + embedded_delay_samples=np.asarray( + aligned.embedded_delay_removed_samples[indices], dtype=np.float64), + sample_rate_hz=canonical.sample_rate_hz, + ridge=projection_ridge) + + directions, gains, runtime_delay = _group_coincident( + directions, gains, runtime_delay) + if len(directions) < (order + 1) ** 2: + raise ValueError("coincident-direction merging left fewer than 36 directions") + weights = spherical_voronoi_weights(directions) + coefficients = fit_real_spherical_harmonics( + directions, gains, order=order, ridge=ridge, weights=weights) + delay_coefficients = fit_real_spherical_harmonics( + directions, runtime_delay, order=order, ridge=ridge, weights=weights) + reconstructed_aligned = evaluate_real_spherical_harmonics( + coefficients, directions, order=order) + delay_reconstructed = evaluate_real_spherical_harmonics( + delay_coefficients, directions, order=order) + reference_phase = np.exp( + -2j * np.pi * runtime_delay[..., None] + * centers[None, None, :] / canonical.sample_rate_hz) + reconstructed_phase = np.exp( + -2j * np.pi * delay_reconstructed[..., None] + * centers[None, None, :] / canonical.sample_rate_hz) + reference_transfer = np.asarray(gains * reference_phase, dtype=np.complex128) + reconstructed_transfer = np.asarray( + reconstructed_aligned * reconstructed_phase, dtype=np.complex128) + magnitude_reference = np.maximum(np.abs(reference_transfer), 1.0e-12) + relative = np.abs(reconstructed_transfer - reference_transfer) / magnitude_reference + magnitude_db_error = np.abs( + 20.0 * np.log10(np.maximum(np.abs(reconstructed_transfer), 1.0e-12)) + - 20.0 * np.log10(magnitude_reference)) + delay_error = delay_reconstructed - runtime_delay + report = { + "format": "SOFA FIR -> public 64-QMF/77-hybrid -> ACN/N3D real SH", + "order": order, + "terms": (order + 1) ** 2, + "input_measurements": int(len(indices)), + "unique_directions": int(len(directions)), + "shell_radius_m": shell_radius, + "spherical_harmonic_ridge": float(ridge), + "projection": projection_report, + "phase_policy_version": PHASE_POLICY_VERSION, + "complex_relative_error_median": float(np.median(relative)), + "complex_relative_error_p95": float(np.percentile(relative, 95.0)), + "magnitude_error_db_median": float(np.median(magnitude_db_error)), + "magnitude_error_db_p95": float(np.percentile(magnitude_db_error, 95.0)), + "delay_error_samples_rms": float(np.sqrt(np.mean(delay_error * delay_error))), + "delay_error_samples_max": float(np.max(np.abs(delay_error))), + "delay_source": aligned.delay_source, + "precision": "float64/complex128", + } + delay_bounds = np.stack( + (np.min(runtime_delay, axis=0), np.max(runtime_delay, axis=0)), axis=1) + cache_key = compiled_hrtf_cache_key( + source_sha256=canonical.source_sha256, + sample_rate_hz=canonical.sample_rate_hz, + shell_radius_m=shell_radius, + order=order, + projection_ridge=float(projection_ridge), + sh_ridge=float(ridge), + ) + display_name = Path(canonical.source_path).name if canonical.source_path else None + return cls( + source_sha256=canonical.source_sha256, + source_display_name=display_name, + sample_rate_hz=canonical.sample_rate_hz, + measurement_radius_m=shell_radius, + order=order, + projection_ridge=float(projection_ridge), + spherical_harmonic_ridge=float(ridge), + band_center_frequencies_hz=centers, + coefficients=coefficients, + delay_coefficients=delay_coefficients, + delay_bounds=delay_bounds, + delay_source=aligned.delay_source, + fit_report=report, + cache_key=cache_key, + ) + + def evaluate_sofa(self, direction_sofa) -> FieldEvaluation: + direction = np.asarray(direction_sofa, dtype=np.float64) + if direction.shape != (3,) or not np.isfinite(direction).all(): + raise ValueError("SOFA direction must contain three finite values") + length = float(np.linalg.norm(direction)) + direction = (np.asarray([1.0, 0.0, 0.0], dtype=np.float64) + if length <= 1.0e-15 else direction / length) + basis = real_spherical_harmonics(direction, order=self.order) + with np.errstate(over="ignore", invalid="ignore"): + aligned = np.asarray( + np.tensordot(basis, self.coefficients, axes=(0, 0)), + dtype=np.complex128) + delay = np.asarray( + np.tensordot(basis, self.delay_coefficients, axes=(0, 0)), + dtype=np.float64) + if not np.isfinite(aligned).all() or not np.isfinite(delay).all(): + raise HrtfCacheError("HRTF field evaluation produced non-finite values") + delay = np.clip(delay, self.delay_bounds[:, 0], self.delay_bounds[:, 1]) + phase = np.exp( + -2j * np.pi * delay[:, None] + * self.band_center_frequencies_hz[None, :] / self.sample_rate_hz) + return FieldEvaluation(aligned, delay, np.asarray(aligned * phase, np.complex128)) + + def evaluate_adm(self, direction_adm) -> FieldEvaluation: + return self.evaluate_sofa(adm_to_sofa_direction(direction_adm)) + + def render_impulse(self, direction_sofa, *, sample_count: int = 1024) -> np.ndarray: + sample_count = int(sample_count) + if sample_count <= 0: + raise ValueError("sample_count must be positive") + evaluation = self.evaluate_sofa(direction_sofa) + delay_slots = np.floor( + evaluation.delay_samples / QMF_HOP).astype(np.int64) + residual_delay = ( + evaluation.delay_samples - delay_slots.astype(np.float64) * QMF_HOP) + residual_phase = np.exp( + -2j * np.pi + * residual_delay[:, None] + * self.band_center_frequencies_hz[None, :] + / self.sample_rate_hz) + residual_gains = np.asarray( + evaluation.aligned_gains * residual_phase, dtype=np.complex128) + total = int(math.ceil( + (ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + sample_count + 512) + / QMF_HOP) * QMF_HOP) + impulse = np.zeros((total, 1), dtype=np.float64) + impulse[0, 0] = 1.0 + base = PublicAnalysis77(1).process(impulse)[:, 0, :] + hybrid = np.zeros( + (len(base) + int(np.max(delay_slots, initial=0)), 2, HYBRID_BANDS), + dtype=np.complex128) + for ear in range(2): + start_slot = int(delay_slots[ear]) + hybrid[start_slot:start_slot + len(base), ear] = ( + base * residual_gains[ear][None, :]) + raw = PublicSynthesis77(2).process(hybrid) + start = ANALYSIS_SYNTHESIS_LATENCY_SAMPLES + return np.asarray(raw[start:start + sample_count].T, dtype=np.float64) + + def _metadata(self) -> dict: + return { + "magic": JOCHRTF_MAGIC, + "format_version": JOCHRTF_FORMAT_VERSION, + "cache_schema": JOCHRTF_CACHE_SCHEMA, + "cache_key_version": 1, + "compiler_version": COMPILER_VERSION, + "phase_policy_version": PHASE_POLICY_VERSION, + "sh_convention": SH_CONVENTION, + "filterbank": filterbank_fingerprint(), + "cache_key": self.cache_key, + "payload_sha256": _payload_sha256( + self.band_center_frequencies_hz, + self.coefficients, + self.delay_coefficients, + self.delay_bounds), + "source_sha256": self.source_sha256, + "source_display_name": self.source_display_name, + "sample_rate_hz": self.sample_rate_hz, + "measurement_radius_m": self.measurement_radius_m, + "order": self.order, + "projection_ridge": self.projection_ridge, + "spherical_harmonic_ridge": self.spherical_harmonic_ridge, + "delay_source": self.delay_source, + "fit_report": self.fit_report, + } + + def save(self, path) -> Path: + requested = Path(path).expanduser() + if requested.suffix.lower() != ".jochrtf": + raise ValueError("compiled HRTF cache path must end in .jochrtf") + requested.parent.mkdir(parents=True, exist_ok=True) + target = requested.parent.resolve() / requested.name + if target.is_symlink(): + raise HrtfCacheError("refusing to replace a symlinked compiled HRTF cache") + metadata_text = _json_bytes(self._metadata()).decode("utf-8") + with _cache_write_lock(target): + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".tmp", dir=target.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as stream: + np.savez_compressed( + stream, + metadata_json=np.asarray(metadata_text), + band_center_frequencies_hz=np.asarray( + self.band_center_frequencies_hz, dtype=" "SofaHrtfField": + source = Path(path).expanduser().resolve() + if not source.is_file(): + raise FileNotFoundError(source) + try: + with source.open("rb") as stream: + arrays = _read_validated_archive(stream) + metadata_array = arrays.pop("metadata_json") + metadata_text = str(metadata_array.item()) + if len(metadata_text.encode("utf-8")) > _MAX_METADATA_BYTES: + raise HrtfCacheError("compiled HRTF metadata is too large") + metadata = _strict_json_object(metadata_text) + except HrtfCacheError: + raise + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError, + OverflowError, RecursionError, zipfile.BadZipFile) as exc: + raise HrtfCacheError(f"invalid compiled HRTF cache: {exc}") from exc + try: + version = _strict_integer( + metadata.get("format_version", -1), "compiled HRTF version") + except ValueError as exc: + raise HrtfCacheError(str(exc)) from exc + if version != JOCHRTF_FORMAT_VERSION: + raise HrtfCacheError( + f"unsupported .jochrtf version {version}; rebuild it from the source SOFA") + if set(metadata) != _METADATA_KEYS: + raise HrtfCacheError("compiled HRTF metadata has an invalid key set") + try: + cache_key_version = _strict_integer( + metadata["cache_key_version"], "compiled HRTF cache_key_version") + order = _strict_integer(metadata["order"], "compiled HRTF order") + except ValueError as exc: + raise HrtfCacheError(str(exc)) from exc + if cache_key_version != 1: + raise HrtfCacheError("compiled HRTF cache_key_version is incompatible") + if order != DEFAULT_ORDER: + raise HrtfCacheError("compiled HRTF order is incompatible") + for name in ( + "sample_rate_hz", "measurement_radius_m", "projection_ridge", + "spherical_harmonic_ridge"): + _json_number(metadata, name) + for name in ("cache_key", "payload_sha256", "source_sha256", "delay_source"): + if not isinstance(metadata[name], str): + raise HrtfCacheError(f"compiled HRTF {name} must be a string") + if metadata["source_display_name"] is not None and not isinstance( + metadata["source_display_name"], str): + raise HrtfCacheError( + "compiled HRTF source_display_name must be a string or null") + if metadata.get("magic") != JOCHRTF_MAGIC: + raise HrtfCacheError("compiled HRTF cache magic mismatch") + expected_static = { + "cache_schema": JOCHRTF_CACHE_SCHEMA, + "cache_key_version": 1, + "compiler_version": COMPILER_VERSION, + "phase_policy_version": PHASE_POLICY_VERSION, + "sh_convention": SH_CONVENTION, + "filterbank": filterbank_fingerprint(), + } + for name, expected in expected_static.items(): + if metadata.get(name) != expected: + raise HrtfCacheError(f"compiled HRTF {name} is incompatible") + expected_arrays = { + "band_center_frequencies_hz": (" dict: + return { + "format": JOCHRTF_MAGIC, + "format_version": self.format_version, + "cache_key": self.cache_key, + "source_sha256": self.source_sha256, + "source_display_name": self.source_display_name, + "sample_rate_hz": self.sample_rate_hz, + "measurement_radius_m": self.measurement_radius_m, + "order": self.order, + "terms": (self.order + 1) ** 2, + "bands": HYBRID_BANDS, + "projection_ridge": self.projection_ridge, + "spherical_harmonic_ridge": self.spherical_harmonic_ridge, + "delay_source": self.delay_source, + "fit_report": self.fit_report, + "precision": "float64/complex128", + } + + +def _cache_file_name(display_name: str | None, cache_key: str) -> str: + stem = Path(display_name).stem if display_name else "hrtf" + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._") or "hrtf" + return f"{safe}.{cache_key[:20]}.jochrtf" + + +def compile_sofa_hrtf( + sofa: str | Path | CanonicalHrtf, *, + target_sample_rate_hz: float = TARGET_SAMPLE_RATE_HZ, + shell_radius_m: float = 1.0, + order: int = DEFAULT_ORDER, + projection_ridge: float = DEFAULT_PROJECTION_RIDGE, + sh_ridge: float = DEFAULT_SH_RIDGE, + cache_policy: str = "memory", + cache_dir: str | Path | None = None) -> SofaHrtfField: + """Compile SOFA in memory, optionally using a validated disposable cache.""" + policy = str(cache_policy).strip().lower() + if policy not in {"none", "memory", "disk"}: + raise ValueError("cache_policy must be none, memory, or disk") + target_rate = float(target_sample_rate_hz) + if (not math.isfinite(target_rate) + or abs(target_rate - TARGET_SAMPLE_RATE_HZ) > 1.0e-9): + raise ValueError("the public binaural runtime currently requires 48 kHz") + radius = float(shell_radius_m) + projection_ridge = float(projection_ridge) + sh_ridge = float(sh_ridge) + if not math.isfinite(radius) or radius <= 0.0: + raise ValueError("shell_radius_m must be positive and finite") + if (not math.isfinite(projection_ridge) or projection_ridge < 0.0 + or not math.isfinite(sh_ridge) or sh_ridge < 0.0): + raise ValueError("compiler ridge values must be finite and non-negative") + order = _strict_integer(order, "compiler order") + if order != DEFAULT_ORDER: + raise ValueError("the runtime HRTF field is fixed at fifth order") + if isinstance(sofa, CanonicalHrtf): + canonical = (sofa if abs(sofa.sample_rate_hz - target_rate) <= 1.0e-9 + else sofa.resampled(target_rate)) + else: + canonical = load_simple_free_field_hrir( + sofa, target_sample_rate_hz=target_rate) + selected = canonical.shell_indices(radius) + actual_radius = float(np.mean(canonical.measurement_radius_m[selected])) + key = compiled_hrtf_cache_key( + source_sha256=canonical.source_sha256, + sample_rate_hz=target_rate, + shell_radius_m=actual_radius, + order=order, + projection_ridge=projection_ridge, + sh_ridge=sh_ridge, + ) + cached = None + if policy in {"memory", "disk"}: + cached = _memory_cache_get(key) + if policy == "memory" and cached is not None: + return cached + target: Path | None = None + if policy == "disk": + directory = (DEFAULT_HRTF_CACHE_DIR if cache_dir is None + else Path(cache_dir).expanduser().resolve()) + directory.mkdir(parents=True, exist_ok=True) + display_name = Path(canonical.source_path).name if canonical.source_path else None + target = directory / _cache_file_name(display_name, key) + if target.is_file(): + try: + field = SofaHrtfField.load( + target, + expected_source_sha256=canonical.source_sha256, + expected_cache_key=key) + _memory_cache_put(key, field) + return field + except (HrtfCacheError, OSError): + pass + if cached is not None: + cached.save(target) + return cached + field = SofaHrtfField.fit( + canonical, + shell_radius_m=actual_radius, + order=order, + ridge=sh_ridge, + projection_ridge=projection_ridge) + if field.cache_key != key: + raise RuntimeError("internal compiled HRTF cache-key mismatch") + if target is not None: + field.save(target) + if policy in {"memory", "disk"}: + _memory_cache_put(key, field) + return field diff --git a/src/sofa_native_backend.py b/src/sofa_native_backend.py new file mode 100644 index 0000000..be45bac --- /dev/null +++ b/src/sofa_native_backend.py @@ -0,0 +1,398 @@ +"""Native float64 SOFA binaural DSP (ctypes bridge to eac3joc_core). + +The C++ side mirrors the Python :class:`sofa_binaural_backend.SofaBinauralBackend` +mathematics: 64-QMF/77-hybrid analysis and synthesis, fifth-order ACN/N3D real +spherical-harmonic field evaluation, whole-QMF-slot per-object delay histories, +six image-source early reflections, the shared unitary FDN late room, the LFE +low-pass and the 961-sample latency policy. The compiled HRTF field, the +filterbank tables and the room constants are uploaded once; per 512-sample +block the adapter updates every source and streams PCM through the DLL. +""" +from __future__ import annotations + +import ctypes +import math +from pathlib import Path + +import numpy as np + +from native_renderer import ABI_VERSION, find_native_library +from public_filterbank import DEFAULT_FILTERBANK_DATA, load_filterbank_tables +from public_room import LateFdnConfig, SharedUnitaryFdn, ShoeboxRoomConfig +from reference_distance import ReferenceDistanceProfileV1 +from sofa_binaural_backend import SofaBinauralBackend +from sofa_hrtf_field import ( + DEFAULT_HRTF_CACHE_DIR, + SofaHrtfField, + compile_sofa_hrtf, +) + +BLOCK_SAMPLES = 512 +INPUT_CHANNELS = 16 +OUTPUT_CHANNELS = 2 +QMF_HOP = 64 +LATENCY_SAMPLES = 961 + +_PROFILE_INDEX = {"near": 0, "mid": 1, "far": 2} + + +def _room_numbers(fdn_config: LateFdnConfig) -> dict: + """Derive the FDN delays/feedback with the same arithmetic as the Python room.""" + fdn = SharedUnitaryFdn(fdn_config) + return { + "fdn_delays": np.asarray(fdn.delays, dtype=np.uint32), + "fdn_feedback": np.asarray(fdn.feedback_gain, dtype=np.float64), + "damping": fdn.damping, + "output_gain": fdn.output_gain, + "allpass_delays": np.asarray( + [diffuser.delay_samples for diffuser in fdn.diffusers], dtype=np.uint32), + "allpass_gains": np.asarray(fdn_config.allpass_gain, dtype=np.float64), + "tail_samples": fdn.tail_samples, + } + + +class NativeSofaBinauralDsp: + """Duck-type compatible with SofaBinauralBackend for the JOC adapter.""" + + def __init__( + self, + field: SofaHrtfField, + *, + source_count: int = INPUT_CHANNELS, + default_profile: str = "mid", + enable_early_reflections: bool = True, + enable_late_room: bool = True, + room_config: ShoeboxRoomConfig = ShoeboxRoomConfig(), + fdn_config: LateFdnConfig | None = None, + library_path: str | Path | None = None): + if not isinstance(field, SofaHrtfField): + raise TypeError("field must be SofaHrtfField") + self.source_count = int(source_count) + self.default_profile = ReferenceDistanceProfileV1.validate_profile(default_profile) + self.enable_early_reflections = bool(enable_early_reflections) + self.enable_late_room = bool(enable_late_room) + self.field = field + if self.source_count != INPUT_CHANNELS: + raise ValueError(f"native SOFA backend requires {INPUT_CHANNELS} sources") + if abs(self.field.sample_rate_hz - 48000.0) > 1.0e-9: + raise ValueError("the native SOFA binaural runtime requires 48 kHz") + self.room_config = room_config + self.room_config.validate() + self.dsp_backend = "native-sofa" + self.hrtf_input_kind = "field" + self.hrtf_input_path = None + self.cache_policy = None + + self.library_path = find_native_library(library_path) + self._lib = ctypes.CDLL(str(self.library_path)) + self._bind() + version = int(self._lib.ejoc_abi_version()) + if version != ABI_VERSION: + raise RuntimeError( + f"native ABI mismatch: expected {ABI_VERSION}, got {version}") + self._handle = self._lib.ejoc_sofa_binaural_create() + if not self._handle: + raise RuntimeError("native SOFA binaural renderer creation failed") + try: + self._configure_kernels() + self._configure_field() + self._configure_room(fdn_config) + except Exception: + self.close() + raise + + self.positions = np.zeros((self.source_count, 3), dtype=np.float64) + self.positions[:, 1] = 1.0 + self.profiles = [self.default_profile] * self.source_count + self.user_gain = np.ones(self.source_count, dtype=np.float64) + self.special_lfe = np.zeros(self.source_count, dtype=bool) + self.parameter_updates = 0 + self.finished = False + for source in range(self.source_count): + self.set_source( + source, self.positions[source], profile=self.default_profile, + fade=False) + + def _bind(self): + void_p = ctypes.c_void_p + f64_p = ctypes.POINTER(ctypes.c_double) + i16_p = ctypes.POINTER(ctypes.c_int16) + u32_p = ctypes.POINTER(ctypes.c_uint32) + self._lib.ejoc_abi_version.argtypes = [] + self._lib.ejoc_abi_version.restype = ctypes.c_uint32 + self._lib.ejoc_sofa_binaural_create.argtypes = [] + self._lib.ejoc_sofa_binaural_create.restype = void_p + self._lib.ejoc_sofa_binaural_destroy.argtypes = [void_p] + self._lib.ejoc_sofa_binaural_destroy.restype = None + self._lib.ejoc_sofa_binaural_reset.argtypes = [void_p] + self._lib.ejoc_sofa_binaural_reset.restype = ctypes.c_int + self._lib.ejoc_sofa_binaural_last_error.argtypes = [void_p] + self._lib.ejoc_sofa_binaural_last_error.restype = ctypes.c_char_p + self._lib.ejoc_sofa_binaural_configure_kernels.argtypes = [ + void_p, f64_p, f64_p, i16_p, f64_p, ctypes.c_uint32, f64_p, f64_p] + self._lib.ejoc_sofa_binaural_configure_kernels.restype = ctypes.c_int + self._lib.ejoc_sofa_binaural_configure_field.argtypes = [ + void_p, f64_p, f64_p, f64_p, f64_p, ctypes.c_double] + self._lib.ejoc_sofa_binaural_configure_field.restype = ctypes.c_int + self._lib.ejoc_sofa_binaural_configure_room.argtypes = [ + void_p, f64_p, f64_p, f64_p, ctypes.c_double, u32_p, f64_p, + ctypes.c_double, ctypes.c_double, u32_p, f64_p, + ctypes.c_uint32, ctypes.c_uint32] + self._lib.ejoc_sofa_binaural_configure_room.restype = ctypes.c_int + self._lib.ejoc_sofa_binaural_set_source.argtypes = [ + void_p, ctypes.c_uint32, f64_p, ctypes.c_uint32, ctypes.c_double, + ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32] + self._lib.ejoc_sofa_binaural_set_source.restype = ctypes.c_int + self._lib.ejoc_sofa_binaural_process.argtypes = [ + void_p, f64_p, ctypes.c_uint32, ctypes.c_double, f64_p] + self._lib.ejoc_sofa_binaural_process.restype = ctypes.c_int + self._lib.ejoc_sofa_binaural_finish.argtypes = [ + void_p, ctypes.c_uint32, f64_p, ctypes.c_uint32] + self._lib.ejoc_sofa_binaural_finish.restype = ctypes.c_int + + def _raise(self, operation, status): + message = self._lib.ejoc_sofa_binaural_last_error(self._handle) + detail = (message or b"").decode("utf-8", "replace") + raise RuntimeError( + f"native SOFA binaural renderer {operation} failed ({status}): {detail}") + + @staticmethod + def _f64_pointer(values): + return values.ctypes.data_as(ctypes.POINTER(ctypes.c_double)) + + def _configure_kernels(self): + tables = load_filterbank_tables(DEFAULT_FILTERBANK_DATA) + qmf_analysis = np.ascontiguousarray( + tables["qmf_analysis_coefficients"], dtype=np.float64) + hybrid_low = np.ascontiguousarray( + tables["hybrid_analysis_low_kernel"], dtype=np.float64) + hybrid_indices = np.ascontiguousarray( + tables["hybrid_synthesis_indices"], dtype=np.int16) + hybrid_values = np.ascontiguousarray( + tables["hybrid_synthesis_values"], dtype=np.float64) + qmf_basis = np.ascontiguousarray( + tables["qmf_synthesis_basis"], dtype=np.float64) + qmf_taps = np.ascontiguousarray( + tables["qmf_synthesis_taps"], dtype=np.float64) + status = self._lib.ejoc_sofa_binaural_configure_kernels( + self._handle, + self._f64_pointer(qmf_analysis), + self._f64_pointer(hybrid_low), + hybrid_indices.ctypes.data_as(ctypes.POINTER(ctypes.c_int16)), + self._f64_pointer(hybrid_values), + len(hybrid_indices), + self._f64_pointer(qmf_basis), + self._f64_pointer(qmf_taps)) + if status: + self._raise("configure_kernels", status) + self._keepalive = (qmf_analysis, hybrid_low, hybrid_indices, + hybrid_values, qmf_basis, qmf_taps) + + def _configure_field(self): + coefficients = np.ascontiguousarray( + self.field.coefficients, dtype=np.complex128).view(np.float64) + delay_coefficients = np.ascontiguousarray( + self.field.delay_coefficients, dtype=np.float64) + delay_bounds = np.ascontiguousarray( + self.field.delay_bounds, dtype=np.float64) + centers = np.ascontiguousarray( + self.field.band_center_frequencies_hz, dtype=np.float64) + status = self._lib.ejoc_sofa_binaural_configure_field( + self._handle, + self._f64_pointer(coefficients), + self._f64_pointer(delay_coefficients), + self._f64_pointer(delay_bounds), + self._f64_pointer(centers), + float(self.field.measurement_radius_m)) + if status: + self._raise("configure_field", status) + + def _configure_room(self, fdn_config: LateFdnConfig | None): + actual = fdn_config or LateFdnConfig(sample_rate_hz=48000.0) + numbers = _room_numbers(actual) + dims = np.asarray(self.room_config.dimensions_m, dtype=np.float64) + listener = np.asarray(self.room_config.listener_position_m, dtype=np.float64) + walls = np.asarray(self.room_config.wall_reflection_gain, dtype=np.float64) + status = self._lib.ejoc_sofa_binaural_configure_room( + self._handle, + self._f64_pointer(dims), + self._f64_pointer(listener), + self._f64_pointer(walls), + float(self.room_config.speed_of_sound_m_s), + numbers["fdn_delays"].ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)), + self._f64_pointer(numbers["fdn_feedback"]), + float(numbers["damping"]), + float(numbers["output_gain"]), + numbers["allpass_delays"].ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)), + self._f64_pointer(numbers["allpass_gains"]), + 1 if self.enable_early_reflections else 0, + 1 if self.enable_late_room else 0) + if status: + self._raise("configure_room", status) + self._fdn = SharedUnitaryFdn(actual) + self._fdn_tail_samples = numbers["tail_samples"] + + def set_source(self, source: int, position_adm, *, profile: str | None = None, + gain: float = 1.0, enabled: bool = True, + special_lfe: bool = False, fade: bool = True) -> None: + if self.finished: + raise RuntimeError("SOFA renderer is finished") + source = int(source) + if not 0 <= source < self.source_count: + raise IndexError(source) + name = self.default_profile if profile is None else profile + position = np.asarray(position_adm, dtype=np.float64) + if position.shape != (3,): + raise ValueError("ADM position must contain three Cartesian values") + status = self._lib.ejoc_sofa_binaural_set_source( + self._handle, source, + self._f64_pointer(np.ascontiguousarray(position)), + _PROFILE_INDEX[ReferenceDistanceProfileV1.validate_profile(name)], + float(gain), 1 if enabled else 0, 1 if special_lfe else 0, + 1 if fade else 0) + if status: + self._raise("set_source", status) + self.positions[source] = position + self.profiles[source] = name + self.user_gain[source] = float(gain) + self.special_lfe[source] = bool(special_lfe) + self.parameter_updates += 1 + + def process(self, sources) -> np.ndarray: + if self.finished: + raise RuntimeError("SOFA renderer is finished") + values = np.ascontiguousarray(sources, dtype=np.float64) + if values.ndim != 2 or values.shape[1] != self.source_count: + raise ValueError(f"sources must have shape [samples,{self.source_count}]") + if len(values) % QMF_HOP or len(values) > BLOCK_SAMPLES: + raise ValueError("native SOFA backend input must be a 64-aligned block") + output = np.empty((len(values), OUTPUT_CHANNELS), dtype=np.float64) + count = self._lib.ejoc_sofa_binaural_process( + self._handle, self._f64_pointer(values), len(values), 1.0, + self._f64_pointer(output)) + if count < 0: + self._raise("process", count) + return output[:count] + + def finish(self, *, tail_seconds: float | None = None) -> np.ndarray: + if self.finished: + return np.zeros((0, OUTPUT_CHANNELS), dtype=np.float64) + if tail_seconds is not None and ( + not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0): + raise ValueError("tail_seconds must be finite and non-negative") + flush = self.finish_output_capacity(tail_seconds) + pieces = [] + remaining = flush + while remaining > 0: + chunk = min(BLOCK_SAMPLES, remaining) + output = np.empty((chunk, OUTPUT_CHANNELS), dtype=np.float64) + count = self._lib.ejoc_sofa_binaural_finish( + self._handle, chunk, self._f64_pointer(output), chunk) + if count < 0: + self._raise("finish", count) + pieces.append(output[:count]) + remaining -= chunk + self.finished = True + nonempty = [piece for piece in pieces if len(piece)] + if not nonempty: + return np.zeros((0, OUTPUT_CHANNELS), dtype=np.float64) + return np.concatenate(nonempty, axis=0) + + def finish_output_capacity(self, tail_seconds: float | None = None) -> int: + if tail_seconds is not None and ( + not math.isfinite(float(tail_seconds)) or float(tail_seconds) < 0.0): + raise ValueError("tail_seconds must be finite and non-negative") + requested = (self._fdn_tail_samples if tail_seconds is None + else int(math.ceil(float(tail_seconds) * 48000.0))) + maximum_hrtf = float(np.max(self.field.delay_bounds[:, 1], initial=0.0)) + hrtf_slots = int(math.ceil(maximum_hrtf / QMF_HOP)) + hrtf_bound = hrtf_slots * QMF_HOP + early_bound = hrtf_bound + 2048 + if self.enable_early_reflections: + early_bound += 256 * QMF_HOP + drain = max(requested if self.enable_late_room else 0, early_bound) + drain += LATENCY_SAMPLES + return int(math.ceil(drain / QMF_HOP) * QMF_HOP) + + def reset(self) -> None: + if self._lib.ejoc_sofa_binaural_reset(self._handle): + self._raise("reset", -1) + self.finished = False + for source in range(self.source_count): + self.set_source( + source, self.positions[source], profile=self.profiles[source], + gain=float(self.user_gain[source]), + special_lfe=bool(self.special_lfe[source]), fade=False) + + def info(self) -> dict: + return { + "name": "NativeSofaBinauralDsp", + "source_count": self.source_count, + "sample_rate_hz": 48000.0, + "precision": "float64/complex128", + "signal_path": ( + "native 64-QMF -> native 77-hybrid -> SOFA order-5 real-SH " + "direct/early -> native synthesis + shared unitary FDN"), + "hrtf_input_kind": self.hrtf_input_kind, + "hrtf_input_path": self.hrtf_input_path, + "cache_policy": self.cache_policy, + "latency_compensated_samples": LATENCY_SAMPLES, + "enable_early_reflections": self.enable_early_reflections, + "enable_late_room": self.enable_late_room, + "early_history_slots": 256, + "hrtf_history_slots": int(math.ceil( + float(np.max(self.field.delay_bounds[:, 1], initial=0.0)) / QMF_HOP)), + "maximum_hrtf_delay_samples": float( + np.max(self.field.delay_bounds[:, 1], initial=0.0)), + "parameter_updates": self.parameter_updates, + "distance": ReferenceDistanceProfileV1.info(), + "field": self.field.info(), + "library_path": str(self.library_path), + "native_backend": True, + } + + def close(self) -> None: + handle = getattr(self, "_handle", None) + if handle: + self._lib.ejoc_sofa_binaural_destroy(handle) + self._handle = None + self.finished = True + + +def create_native_sofa_renderer( + sofa, *, mode="mid", cache_policy="memory", cache_dir=None, + shell_radius_m=1.0, object_delay_samples=1473, tail_seconds=5.0, + output_gain=1.0, chunk_frames=64): + """Compile a SOFA source and build a JOC adapter over the native DSP.""" + from binaural_renderer import SofaBinauralRenderer, resolve_sofa_hrtf + + source = resolve_sofa_hrtf(sofa) + field = compile_sofa_hrtf( + source, + shell_radius_m=shell_radius_m, + cache_policy=cache_policy, + cache_dir=cache_dir) + backend = NativeSofaBinauralDsp(field, default_profile=mode) + backend.hrtf_input_kind = "sofa" + backend.hrtf_input_path = str(source) + backend.cache_policy = str(cache_policy).lower() + return SofaBinauralRenderer( + backend, mode=mode, object_delay_samples=object_delay_samples, + tail_seconds=tail_seconds, chunk_frames=chunk_frames) + + +def create_native_compiled_cache_renderer( + cache, *, mode="mid", object_delay_samples=1473, tail_seconds=5.0, + output_gain=1.0, chunk_frames=64): + """Load a compiled cache and build a JOC adapter over the native DSP.""" + from binaural_renderer import SofaBinauralRenderer, resolve_compiled_hrtf_cache + + source = resolve_compiled_hrtf_cache(cache) + field = SofaHrtfField.load(source) + backend = NativeSofaBinauralDsp(field, default_profile=mode) + backend.hrtf_input_kind = "compiled_cache" + backend.hrtf_input_path = str(source) + backend.cache_policy = None + return SofaBinauralRenderer( + backend, mode=mode, object_delay_samples=object_delay_samples, + tail_seconds=tail_seconds, chunk_frames=chunk_frames) diff --git a/src/speaker_wav.py b/src/speaker_wav.py index 97a6c8d..de482f8 100644 --- a/src/speaker_wav.py +++ b/src/speaker_wav.py @@ -1,6 +1,7 @@ -"""Streaming spool and WAV writer for direct speaker-layout output.""" +"""Shared PCM spool, peak analysis, and WAV writer for direct outputs.""" from __future__ import annotations +import math import struct from pathlib import Path @@ -13,48 +14,115 @@ _PCM_GUID = bytes.fromhex("0100000000001000800000aa00389b71") _FLOAT_GUID = bytes.fromhex("0300000000001000800000aa00389b71") -class SpeakerPcmSpool: - """Temporary interleaved float32 store with float64 peak analysis.""" +class PcmSpool: + """Temporary interleaved PCM store with float64 peak and clipping analysis. - def __init__(self, path, sample_count, channel_count): + ``storage_dtype`` controls only the temporary representation. Speaker + output keeps its historical float32 spool, while binaural uses float64 so + precision is reduced only by the selected final WAV format. + """ + + def __init__(self, path, sample_capacity, channel_count, *, + expected_samples=None, storage_dtype=" self.sample_count: - raise ValueError("speaker spool received more samples than allocated") + f"PCM frame must have shape [samples,{self.channel_count}], got {values.shape}") + if self.position + len(values) > self.sample_capacity: + raise ValueError("PCM spool received more samples than allocated") if not np.all(np.isfinite(values)): - raise ValueError("speaker renderer produced NaN or infinity") + raise ValueError("renderer produced NaN or infinity") absolute = np.abs(values) if absolute.size: self.peak = max(self.peak, float(np.max(absolute))) self.clipped_values += int(np.count_nonzero(absolute > 1.0)) - self.values[self.position:self.position + len(values)] = values.astype(np.float32) + if self.tail_threshold is not None: + per_sample = np.max(absolute, axis=1) + above = np.flatnonzero(per_sample > self.tail_threshold) + if above.size: + self.last_above_threshold = self.position + int(above[-1]) + self._values[self.position:self.position + len(values)] = values.astype( + self.storage_dtype, copy=False) self.position += len(values) - def finalize(self): - if self.position != self.sample_count: + def finalize(self, *, minimum_samples=0): + if (self.expected_samples is not None + and self.position != self.expected_samples): raise ValueError( - f"speaker spool has {self.position} samples, expected {self.sample_count}") - self.values.flush() + f"PCM spool has {self.position} samples, expected {self.expected_samples}") + keep = self.position + if self.tail_threshold is not None: + keep = min( + self.position, + max(int(minimum_samples), self.last_above_threshold + 1), + ) + self.kept_samples = keep + self.sample_count = keep + self._values.flush() return self def close(self): - values = self.values - self.values = None - del values + values = self._values + self._values = None + if values is not None: + del values + + +class SpeakerPcmSpool(PcmSpool): + """Backward-compatible fixed-length float32 speaker spool.""" + + def __init__(self, path, sample_count, channel_count): + super().__init__( + path, sample_count, channel_count, + expected_samples=sample_count, storage_dtype=" 0xFFFFFFFF if use_rf64: - # RF64 + ds64 + fmt + data. file_size = 12 + 36 + 8 + len(fmt) + 8 + data_size stream.write(b"RF64") stream.write(struct.pack(" np.ndarray: + """P_degree^order(x), including the Condon-Shortley phase.""" + m = int(order) + l = int(degree) + if not 0 <= m <= l: + raise ValueError("associated Legendre indices require 0 <= m <= l") + x = np.asarray(x, dtype=np.float64) + p_mm = np.ones_like(x) + if m: + double_factorial = 1.0 + for value in range(1, 2 * m, 2): + double_factorial *= value + p_mm = ((-1.0) ** m) * double_factorial * np.power( + np.maximum(0.0, 1.0 - x * x), 0.5 * m) + if l == m: + return p_mm + p_m1 = x * (2 * m + 1) * p_mm + if l == m + 1: + return p_m1 + previous_previous = p_mm + previous = p_m1 + for current_degree in range(m + 2, l + 1): + current = ( + (2 * current_degree - 1) * x * previous + - (current_degree + m - 1) * previous_previous + ) / float(current_degree - m) + previous_previous, previous = previous, current + return previous + + +def real_spherical_harmonics(directions, order: int = 5) -> np.ndarray: + """Return [directions,(order+1)^2] ACN/N3D real harmonics. + + Coordinates use SOFA listener axes: +X front, +Y left, +Z up. The basis is + orthonormal over the sphere and includes the Condon-Shortley phase. + """ + maximum_order = int(order) + if not 0 <= maximum_order <= 12: + raise ValueError("supported spherical-harmonic orders are 0..12") + vectors = np.asarray(directions, dtype=np.float64) + one = vectors.ndim == 1 + if one: + vectors = vectors[None, :] + if vectors.ndim != 2 or vectors.shape[1] != 3 or not np.isfinite(vectors).all(): + raise ValueError("directions must have finite shape [M,3]") + length = np.linalg.norm(vectors, axis=1) + if np.any(length <= 1.0e-15): + raise ValueError("spherical-harmonic directions must be non-zero") + unit = vectors / length[:, None] + azimuth = np.arctan2(unit[:, 1], unit[:, 0]) + cos_colatitude = np.clip(unit[:, 2], -1.0, 1.0) + result = np.empty((len(unit), (maximum_order + 1) ** 2), dtype=np.float64) + column = 0 + for degree in range(maximum_order + 1): + for m in range(-degree, degree + 1): + absolute = abs(m) + normalization = math.sqrt( + (2 * degree + 1) / (4.0 * math.pi) + * math.factorial(degree - absolute) + / math.factorial(degree + absolute)) + legendre = _associated_legendre(absolute, degree, cos_colatitude) + if m < 0: + value = math.sqrt(2.0) * normalization * legendre * np.sin( + absolute * azimuth) + elif m > 0: + value = math.sqrt(2.0) * normalization * legendre * np.cos( + m * azimuth) + else: + value = normalization * legendre + result[:, column] = value + column += 1 + return result[0] if one else result + + +def spherical_voronoi_weights(directions) -> np.ndarray: + """Area weights for an irregular full-sphere grid, with uniform fallback.""" + vectors = np.asarray(directions, dtype=np.float64) + if vectors.ndim != 2 or vectors.shape[1] != 3: + raise ValueError("directions must have shape [M,3]") + unit = vectors / np.linalg.norm(vectors, axis=1)[:, None] + if len(unit) < 4: + return np.full(len(unit), 1.0 / len(unit), dtype=np.float64) + try: + voronoi = SphericalVoronoi(unit, radius=1.0, center=np.zeros(3)) + areas = np.asarray(voronoi.calculate_areas(), dtype=np.float64) + if not np.isfinite(areas).all() or np.any(areas <= 0.0): + raise ValueError("invalid spherical Voronoi areas") + return areas / np.sum(areas, dtype=np.float64) + except (ValueError, RuntimeError, np.linalg.LinAlgError): + return np.full(len(unit), 1.0 / len(unit), dtype=np.float64) + + +def fit_real_spherical_harmonics(directions, values, *, order: int = 5, + ridge: float = 1.0e-6, + weights=None) -> np.ndarray: + """Weighted ridge fit. Output shape is [terms,...value trailing axes].""" + basis = real_spherical_harmonics(directions, order=order) + target = np.asarray(values) + if target.shape[0] != basis.shape[0]: + raise ValueError("spherical-harmonic target count does not match directions") + if target.dtype.kind == "c": + target = np.asarray(target, dtype=np.complex128) + solve_dtype = np.complex128 + else: + target = np.asarray(target, dtype=np.float64) + solve_dtype = np.float64 + if weights is None: + weight = spherical_voronoi_weights(directions) + else: + weight = np.asarray(weights, dtype=np.float64) + if weight.shape != (len(basis),) or np.any(weight < 0.0) or not np.isfinite(weight).all(): + raise ValueError("weights must be finite non-negative [M]") + total = float(np.sum(weight)) + if total <= 0.0: + raise ValueError("weights must have positive sum") + weight = weight / total + flat = target.reshape(len(target), -1) + weighted_basis = basis * weight[:, None] + gram = basis.T @ weighted_basis + regularization = float(ridge) + if not math.isfinite(regularization) or regularization < 0.0: + raise ValueError("ridge must be finite and non-negative") + scale = float(np.trace(gram)) / gram.shape[0] + system = gram + np.eye(gram.shape[0], dtype=np.float64) * regularization * scale + right = basis.T @ (weight[:, None] * flat) + coefficients = np.linalg.solve(system.astype(solve_dtype), right.astype(solve_dtype)) + return coefficients.reshape((basis.shape[1],) + target.shape[1:]) + + +def evaluate_real_spherical_harmonics(coefficients, directions, + *, order: int = 5) -> np.ndarray: + basis = real_spherical_harmonics(directions, order=order) + coeff = np.asarray(coefficients) + if coeff.shape[0] != (int(order) + 1) ** 2: + raise ValueError("coefficient term count does not match order") + return np.tensordot(basis, coeff, axes=([-1], [0]))