HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
Browse files- README.md +126 -0
- examples/demo_e2e_testpack.py +85 -0
- examples/demo_phase0.py +63 -0
- examples/demo_phase1.py +62 -0
- examples/demo_phase2.py +65 -0
- examples/demo_phase3.py +70 -0
- hoa64/__init__.py +92 -0
- hoa64/__main__.py +3 -0
- hoa64/analysis.py +118 -0
- hoa64/audio_io.py +120 -0
- hoa64/basis.py +213 -0
- hoa64/cli.py +417 -0
- hoa64/conditioning.py +421 -0
- hoa64/decode.py +70 -0
- hoa64/detector.py +304 -0
- hoa64/encode.py +91 -0
- hoa64/live_audio.py +240 -0
- hoa64/report.py +299 -0
- hoa64/rnn_stub.py +101 -0
- hoa64/rotate.py +186 -0
- hoa64/server.py +369 -0
- hoa64/stft.py +96 -0
- hoa64/stream.py +235 -0
- hoa64/synth.py +46 -0
- hoa64/vision.py +237 -0
- hoa64/wigner.py +266 -0
- integrations/qwythos_system_snippet.md +50 -0
- scripts/spatial-report +11 -0
- tests/test_basis.py +107 -0
- tests/test_encode_decode.py +95 -0
- tests/test_integration_extras.py +108 -0
- tests/test_phase1_audio.py +116 -0
- tests/test_phase2_wigner.py +117 -0
- tests/test_phase3_vision.py +96 -0
- tests/test_rotate_rnn.py +81 -0
- tools/spatial_analyze.openai.json +65 -0
README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- en
|
| 4 |
+
library_name: other
|
| 5 |
+
pipeline_tag: other
|
| 6 |
+
license: other
|
| 7 |
+
tags:
|
| 8 |
+
- ambisonics
|
| 9 |
+
- spatial-audio
|
| 10 |
+
- hoa
|
| 11 |
+
- spherical-harmonics
|
| 12 |
+
- audio-analysis
|
| 13 |
+
- tool
|
| 14 |
+
- agi-tool
|
| 15 |
+
- wigner-d
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# HOA7 Spatial Field Decoder (hoa64)
|
| 19 |
+
|
| 20 |
+
A **formula-driven spatial field decoder** for **7th-order Ambisonics** — **64 channels** (Ambix **ACN + SN3D**). It encodes/decodes spherical fields, rotates them with **Wigner-D** matrices, analyzes direction-of-arrival / energy, fuses vision detections onto the sphere, and emits **spatial conditioning** for generative pipelines. Pure NumPy; **not an LLM and not a learned model** — deterministic spherical-harmonic geometry, no weights.
|
| 21 |
+
|
| 22 |
+
| Property | Choice |
|
| 23 |
+
|----------|--------|
|
| 24 |
+
| Version | `0.5.0` |
|
| 25 |
+
| Geometry | Ambix **ACN + SN3D** |
|
| 26 |
+
| Channels | 64 = (7+1)², order 7 |
|
| 27 |
+
| Rotation | **Wigner-D** (~150× vs dense) |
|
| 28 |
+
| Audio | WAV / live mic (ffmpeg·arecord) |
|
| 29 |
+
| Vision | Boxes / YOLO labels / optional torchvision |
|
| 30 |
+
| Agents | HTTP `:8765` + `spatial-report` CLI |
|
| 31 |
+
| Diffusion | Conditioning JSON + optional ComfyUI submit |
|
| 32 |
+
| Language | Python 3, NumPy only (optional torch/torchvision) |
|
| 33 |
+
|
| 34 |
+
## What it does
|
| 35 |
+
|
| 36 |
+
- **Encode** point sources / plane waves / scene mixes → HOA-7 coefficients (`encode_points`, `encode_plane_waves`, `encode_scene`).
|
| 37 |
+
- **Decode** coefficients → samples on the sphere, product-grid render, or a single beamform readout (`decode_directions`, `decode_grid`, `beamform`).
|
| 38 |
+
- **Rotate** the field in the listener frame via Wigner-D / zyz (`hoa_rotation_matrix`, `apply_hoa_rotation`, `rotate_yaw_pitch_roll`).
|
| 39 |
+
- **Analyze** DOA (intensity vector + peak search), directional power, field energy, per-STFT-band and per-frame reports (`analysis.py`, `report.py`).
|
| 40 |
+
- **Fuse vision** — object boxes / rays / YOLO detections projected onto the sphere alongside audio (`vision.py`, `detector.py`, `fuse_reports`).
|
| 41 |
+
- **Condition** — spatial reports → plain-text control lines for T2I/T2V prompts, structured JSON for ControlNet-style nodes, or a ComfyUI API payload (`conditioning.py`).
|
| 42 |
+
- **Serve** — HTTP API and iterative agent state (`server.py`, `rnn_stub.py`).
|
| 43 |
+
|
| 44 |
+
## Coordinates (always)
|
| 45 |
+
|
| 46 |
+
- **+X** front, **+Y** left, **+Z** up (Ambix listener frame).
|
| 47 |
+
- Azimuth 0° = front, **+90° = left**, −90° = right.
|
| 48 |
+
- Elevation 0° = horizon, +90° = zenith.
|
| 49 |
+
- W (omnidirectional HOA channel) maps to field size / POV: low W → tight / subject-focused / narrow FOV; high W → wide / environmental / immersive FOV.
|
| 50 |
+
|
| 51 |
+
## Install / run
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
cd spatial-hoa # this repo root
|
| 55 |
+
export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}"
|
| 56 |
+
python3 -m hoa64 --help
|
| 57 |
+
# or install the CLI on PATH:
|
| 58 |
+
ln -s "$PWD/scripts/spatial-report" ~/.local/bin/spatial-report
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
**Deps:** NumPy (system `ffmpeg`/`arecord` for live capture; optional torch/torchvision for the real object detector).
|
| 62 |
+
|
| 63 |
+
## CLI map (`spatial-report`)
|
| 64 |
+
|
| 65 |
+
| Command | Purpose |
|
| 66 |
+
|---------|---------|
|
| 67 |
+
| `analyze` | Ambix / mono WAV → spatial JSON |
|
| 68 |
+
| `demo-scene` | Synthetic multi-source audio |
|
| 69 |
+
| `vision` | Raw sphere boxes → report |
|
| 70 |
+
| `detect` | Image / YOLO / demo → report |
|
| 71 |
+
| `live` | Mic capture → report |
|
| 72 |
+
| `condition` | Report → diffusion prompt + control vector |
|
| 73 |
+
| `serve` | HTTP API `:8765` |
|
| 74 |
+
|
| 75 |
+
## HTTP API (`POST /v1/spatial/analyze`)
|
| 76 |
+
|
| 77 |
+
Modes: `demo_scene` · `ambix_file` · `mono_file` · `vision` · `fuse` · `detect` · `live` · `condition`
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
curl -s -X POST http://127.0.0.1:8765/v1/spatial/analyze \
|
| 81 |
+
-H 'Content-Type: application/json' \
|
| 82 |
+
-d '{"mode":"demo_scene","order":3}'
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
An OpenAI-compatible function schema is provided at `tools/spatial_analyze.openai.json`.
|
| 86 |
+
|
| 87 |
+
## Agent integration (Qwythos / Pi)
|
| 88 |
+
|
| 89 |
+
The HOA-7 calculator is **not** part of any LLM's weights. Agents should call it as a tool and treat the returned `one_liner` / `doa_*` / fuse fields as ground-truth geometry:
|
| 90 |
+
|
| 91 |
+
- Run the CLI: `spatial-report analyze /path/to.wav --ambix -o /tmp/spatial.json && cat /tmp/spatial.json`
|
| 92 |
+
- Or HTTP: `POST http://127.0.0.1:8765/v1/spatial/analyze`
|
| 93 |
+
- See `integrations/qwythos_system_snippet.md` for the exact agent prompt block.
|
| 94 |
+
|
| 95 |
+
## Quick test pack
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
python3 examples/demo_e2e_testpack.py # artifacts in /tmp/spatial_hoa_e2e/
|
| 99 |
+
spatial-report analyze /tmp/spatial_hoa_e2e/scene_ambix4.wav --ambix -o /tmp/a.json
|
| 100 |
+
spatial-report detect --demo-image /tmp/frame.png -o /tmp/v.json
|
| 101 |
+
spatial-report condition /tmp/spatial_hoa_e2e/fuse_report.json --prompt 'cinematic interior' -o /tmp/c.json
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
## Tests
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
python3 tests/test_basis.py
|
| 108 |
+
python3 tests/test_encode_decode.py
|
| 109 |
+
python3 tests/test_rotate_rnn.py
|
| 110 |
+
python3 tests/test_phase1_audio.py
|
| 111 |
+
python3 tests/test_phase2_wigner.py
|
| 112 |
+
python3 tests/test_phase3_vision.py
|
| 113 |
+
python3 tests/test_integration_extras.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
## Limitations
|
| 117 |
+
|
| 118 |
+
- **Deterministic geometry, not a learned model.** There are no trained weights; accuracy is bounded by the exact spherical-harmonic basis (Farina / Ambix), not by data. The `rnn_stub.py` integrator is an **explicit Euler pose/rotation loop** — learned field dynamics are not implemented yet.
|
| 119 |
+
- **No audio synthesis.** This decodes/analyzes spatial fields and produces conditioning; it does not generate audio content.
|
| 120 |
+
- **Live capture** requires system `ffmpeg`/`arecord`.
|
| 121 |
+
- **Object detection** is optional: the demo path uses synthetic boxes; the real detector downloads torchvision weights on first use.
|
| 122 |
+
- **Coordinate convention is Ambix ACN/SN3D.** Interop with other conventions (FuMa, N3D) requires explicit conversion.
|
| 123 |
+
|
| 124 |
+
## License
|
| 125 |
+
|
| 126 |
+
This repository carries no explicit license file. The code is provided as-is by the author; contact `woodfireind` for usage terms. Third-party optional deps (torch/torchvision) keep their own licenses.
|
examples/demo_e2e_testpack.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""End-to-end pack for manual testing: audio scene, vision detect, fuse, condition.
|
| 3 |
+
|
| 4 |
+
Writes artifacts under /tmp/spatial_hoa_e2e/ and prints commands for Qwythos/Comfy.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 14 |
+
|
| 15 |
+
from hoa64.audio_io import write_wav
|
| 16 |
+
from hoa64.conditioning import build_conditioning, comfy_txt2img_payload, save_conditioning
|
| 17 |
+
from hoa64.detector import detections_to_sphere_boxes, write_demo_image_with_box
|
| 18 |
+
from hoa64.report import report_from_scene
|
| 19 |
+
from hoa64.stream import SourceSpec, encode_scene
|
| 20 |
+
from hoa64.synth import envelope_adsr, tone
|
| 21 |
+
from hoa64.vision import fuse_reports, report_from_boxes
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
out = Path("/tmp/spatial_hoa_e2e")
|
| 26 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
sr = 48000
|
| 29 |
+
dur = 0.5
|
| 30 |
+
n = int(sr * dur)
|
| 31 |
+
env = envelope_adsr(n, sr)
|
| 32 |
+
sources = [
|
| 33 |
+
SourceSpec(15.0, 0.0, tone(520, dur, sr, amplitude=0.45) * env, "beep"),
|
| 34 |
+
SourceSpec(-80.0, 5.0, tone(780, dur, sr, amplitude=0.2) * env, "side"),
|
| 35 |
+
]
|
| 36 |
+
audio_rep = report_from_scene(sources, sr, max_order=3)
|
| 37 |
+
audio_rep.save(out / "audio_report.json")
|
| 38 |
+
hoa = encode_scene(sources, max_order=3)
|
| 39 |
+
write_wav(out / "scene_ambix4.wav", hoa[:4], sr)
|
| 40 |
+
|
| 41 |
+
img = out / "demo_frame.png"
|
| 42 |
+
det = write_demo_image_with_box(img)
|
| 43 |
+
boxes = detections_to_sphere_boxes([det])
|
| 44 |
+
(out / "boxes.json").write_text(json.dumps(boxes, indent=2) + "\n")
|
| 45 |
+
vision_rep = report_from_boxes(boxes, max_order=3)
|
| 46 |
+
vision_rep.save(out / "vision_report.json")
|
| 47 |
+
|
| 48 |
+
fused = fuse_reports(
|
| 49 |
+
{**audio_rep.to_dict(), "one_liner": audio_rep.one_liner()},
|
| 50 |
+
{**vision_rep.to_dict(), "one_liner": vision_rep.one_liner()},
|
| 51 |
+
)
|
| 52 |
+
(out / "fuse_report.json").write_text(json.dumps(fused, indent=2) + "\n")
|
| 53 |
+
|
| 54 |
+
cond = build_conditioning(
|
| 55 |
+
fused,
|
| 56 |
+
base_prompt="cinematic interior, soft window light, photoreal",
|
| 57 |
+
style="natural",
|
| 58 |
+
)
|
| 59 |
+
save_conditioning(cond, out / "conditioning.json")
|
| 60 |
+
wf = comfy_txt2img_payload(cond, width=512, height=512, steps=20)
|
| 61 |
+
(out / "comfy_workflow.json").write_text(json.dumps(wf, indent=2) + "\n")
|
| 62 |
+
|
| 63 |
+
print("=== E2E artifacts ===")
|
| 64 |
+
for p in sorted(out.iterdir()):
|
| 65 |
+
print(f" {p}")
|
| 66 |
+
print("\nAUDIO ", audio_rep.one_liner())
|
| 67 |
+
print("VISION", vision_rep.one_liner())
|
| 68 |
+
print("FUSE ", fused["one_liner"])
|
| 69 |
+
print("PROMPT", cond["positive_prompt"])
|
| 70 |
+
print(
|
| 71 |
+
"""
|
| 72 |
+
Test commands:
|
| 73 |
+
curl -s http://127.0.0.1:8765/health
|
| 74 |
+
spatial-report analyze /tmp/spatial_hoa_e2e/scene_ambix4.wav --ambix -o /tmp/a.json
|
| 75 |
+
spatial-report detect --demo-image /tmp/spatial_hoa_e2e/frame2.png -o /tmp/v.json
|
| 76 |
+
spatial-report condition /tmp/spatial_hoa_e2e/fuse_report.json -o /tmp/c.json --prompt 'moody hall'
|
| 77 |
+
spatial-report live --duration 1.5 -o /tmp/live.json --write-wav /tmp/live.wav
|
| 78 |
+
# optional Comfy (if running):
|
| 79 |
+
spatial-report condition /tmp/spatial_hoa_e2e/fuse_report.json --comfy --write-workflow /tmp/wf.json
|
| 80 |
+
"""
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
examples/demo_phase0.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Phase 0 demo: encode sources, rotate, iterative walk, print spatial summary."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from hoa64 import (
|
| 14 |
+
N_CHANNELS,
|
| 15 |
+
channel_names,
|
| 16 |
+
encode_points,
|
| 17 |
+
mix,
|
| 18 |
+
doa_from_intensity,
|
| 19 |
+
peak_direction,
|
| 20 |
+
field_energy,
|
| 21 |
+
beamform,
|
| 22 |
+
)
|
| 23 |
+
from hoa64.rnn_stub import step_rotate, world_from_sources
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
print(f"hoa64 Phase 0 — channels={N_CHANNELS}")
|
| 28 |
+
print("names:", ", ".join(channel_names()[:9]), "...")
|
| 29 |
+
|
| 30 |
+
# Two sources: front loud, rear quieter
|
| 31 |
+
field = mix(
|
| 32 |
+
encode_points([0.0], [0.0], [1.0]),
|
| 33 |
+
encode_points([180.0], [20.0], [0.4]),
|
| 34 |
+
)
|
| 35 |
+
print(f"\nenergy={field_energy(field):.4f}")
|
| 36 |
+
az, el = doa_from_intensity(field)
|
| 37 |
+
print(f"intensity DOA: az={az:.1f}° el={el:.1f}°")
|
| 38 |
+
paz, pel, pv = peak_direction(field)
|
| 39 |
+
print(f"power peak: az={paz:.1f}° el={pel:.1f}° power={pv:.4f}")
|
| 40 |
+
print(
|
| 41 |
+
f"beam front={float(beamform(field, 0, 0)):.3f} "
|
| 42 |
+
f"rear={float(beamform(field, 180, 20)):.3f}"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Iterative agent motion (order-3 dense for demo speed/quality balance)
|
| 46 |
+
st = world_from_sources([0.0], [0.0], [1.0])
|
| 47 |
+
print("\nRNN-stub walk: agent yaws +30° × 3 (order-3 field)")
|
| 48 |
+
for i in range(3):
|
| 49 |
+
st = step_rotate(st, d_yaw=30.0, max_order=3, dense=True)
|
| 50 |
+
snap = st.history[-1]
|
| 51 |
+
daz, del_ = snap["doa_intensity_az_el"]
|
| 52 |
+
print(
|
| 53 |
+
f" step {i+1}: pose_yaw={st.yaw:.0f}° "
|
| 54 |
+
f"head-frame intensity DOA=({daz:.1f},{del_:.1f}) "
|
| 55 |
+
f"E={snap['energy']:.4f}"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
print("\nHypothesis check: geometry is formula-driven, state is 64-D, loop integrates pose.")
|
| 59 |
+
print("Vision tower deferred. Audio encode/analyze/rotate path is live.")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
main()
|
examples/demo_phase1.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Phase 1 demo: multi-source scene → Ambix WAV → JSON spatial report for AIs."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from hoa64.audio_io import write_wav
|
| 14 |
+
from hoa64.report import report_from_ambix_wav, report_from_scene
|
| 15 |
+
from hoa64.stream import SourceSpec, encode_scene
|
| 16 |
+
from hoa64.synth import envelope_adsr, tone
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main() -> None:
|
| 20 |
+
sr = 48000
|
| 21 |
+
dur = 0.6
|
| 22 |
+
n = int(sr * dur)
|
| 23 |
+
env = envelope_adsr(n, sr, attack=0.02, release=0.08)
|
| 24 |
+
|
| 25 |
+
sources = [
|
| 26 |
+
SourceSpec(0.0, 0.0, tone(440, dur, sr, amplitude=0.45) * env, "front_A4"),
|
| 27 |
+
SourceSpec(90.0, 10.0, tone(554.37, dur, sr, amplitude=0.3) * env, "left_C#5"),
|
| 28 |
+
SourceSpec(-120.0, -5.0, tone(659.25, dur, sr, amplitude=0.2) * env, "backright_E5"),
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
print("=== Synthetic multi-source scene ===")
|
| 32 |
+
for s in sources:
|
| 33 |
+
print(f" {s.label}: az={s.azimuth_deg}° el={s.elevation_deg}°")
|
| 34 |
+
|
| 35 |
+
rep = report_from_scene(sources, sr, max_order=3)
|
| 36 |
+
print("\n" + rep.one_liner())
|
| 37 |
+
print(f"sources_hint: {json.dumps(rep.sources_hint, indent=2)}")
|
| 38 |
+
if rep.bands:
|
| 39 |
+
print("bands (first 3):")
|
| 40 |
+
for b in rep.bands[:3]:
|
| 41 |
+
print(
|
| 42 |
+
f" {b['band_hz']}: DOA ({b['doa_az_deg']:.1f},{b['doa_el_deg']:.1f}) "
|
| 43 |
+
f"E={b['energy']:.3g}"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
out_dir = Path(tempfile.mkdtemp(prefix="hoa64_p1_"))
|
| 47 |
+
wav_path = out_dir / "scene_ambix4.wav"
|
| 48 |
+
json_path = out_dir / "spatial_report.json"
|
| 49 |
+
|
| 50 |
+
hoa = encode_scene(sources, max_order=3)
|
| 51 |
+
write_wav(wav_path, hoa[:4], sr)
|
| 52 |
+
rep.save(json_path)
|
| 53 |
+
|
| 54 |
+
# Reload as Ambix and re-analyze
|
| 55 |
+
rep2 = report_from_ambix_wav(wav_path, max_order=1)
|
| 56 |
+
print(f"\nFrom written Ambix WAV: {rep2.one_liner()}")
|
| 57 |
+
print(f"\nArtifacts:\n {wav_path}\n {json_path}")
|
| 58 |
+
print("\nPhase 1: audio path + JSON report for other models — live.")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
main()
|
examples/demo_phase2.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Phase 2 demo: Wigner-D vs dense rotation accuracy + timing."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
+
|
| 14 |
+
from hoa64 import encode_points, rotate_yaw_pitch_roll, doa_from_intensity
|
| 15 |
+
from hoa64.analysis import angular_error_deg
|
| 16 |
+
from hoa64.rotate import rotate_source_directions
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main() -> None:
|
| 20 |
+
az0, el0 = 25.0, -12.0
|
| 21 |
+
yaw, pitch, roll = 40.0, 15.0, -10.0
|
| 22 |
+
a0 = encode_points([az0], [el0], [1.0])
|
| 23 |
+
|
| 24 |
+
az1, el1 = rotate_source_directions(az0, el0, yaw=yaw, pitch=pitch, roll=roll)
|
| 25 |
+
a_gt = encode_points([float(az1)], [float(el1)], [1.0])
|
| 26 |
+
|
| 27 |
+
a_w = rotate_yaw_pitch_roll(
|
| 28 |
+
a0, yaw=yaw, pitch=pitch, roll=roll, method="wigner"
|
| 29 |
+
)
|
| 30 |
+
a_d = rotate_yaw_pitch_roll(
|
| 31 |
+
a0, yaw=yaw, pitch=pitch, roll=roll, method="dense", n_azi=72, n_el=36
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
rel_w = np.linalg.norm(a_w - a_gt) / np.linalg.norm(a_gt)
|
| 35 |
+
rel_d = np.linalg.norm(a_d - a_gt) / np.linalg.norm(a_gt)
|
| 36 |
+
print(f"Ground-truth source after rot: az={float(az1):.2f} el={float(el1):.2f}")
|
| 37 |
+
print(f"Wigner rel error vs re-encode: {rel_w:.3e}")
|
| 38 |
+
print(f"Dense rel error vs re-encode: {rel_d:.3e}")
|
| 39 |
+
|
| 40 |
+
az_w, el_w = doa_from_intensity(a_w)
|
| 41 |
+
err = angular_error_deg(float(az1), float(el1), az_w, el_w)
|
| 42 |
+
print(f"Intensity DOA after Wigner: ({az_w:.2f},{el_w:.2f}) err={err:.3f}°")
|
| 43 |
+
|
| 44 |
+
# timing
|
| 45 |
+
for _ in range(20):
|
| 46 |
+
rotate_yaw_pitch_roll(a0, yaw=yaw, pitch=pitch, roll=roll, method="wigner")
|
| 47 |
+
t0 = time.perf_counter()
|
| 48 |
+
n = 200
|
| 49 |
+
for _ in range(n):
|
| 50 |
+
rotate_yaw_pitch_roll(a0, yaw=yaw, pitch=pitch, roll=roll, method="wigner")
|
| 51 |
+
tw = (time.perf_counter() - t0) / n
|
| 52 |
+
|
| 53 |
+
t0 = time.perf_counter()
|
| 54 |
+
n2 = 5
|
| 55 |
+
for _ in range(n2):
|
| 56 |
+
rotate_yaw_pitch_roll(
|
| 57 |
+
a0, yaw=yaw, pitch=pitch, roll=roll, method="dense", n_azi=48, n_el=24
|
| 58 |
+
)
|
| 59 |
+
td = (time.perf_counter() - t0) / n2
|
| 60 |
+
print(f"Timing: wigner={tw*1e3:.3f} ms dense={td*1e3:.3f} ms speedup≈{td/tw:.0f}×")
|
| 61 |
+
print("Phase 2: Wigner-D rotation is default for pose loops and tools.")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
main()
|
examples/demo_phase3.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Phase 3 + Qwythos wiring demo: vision boxes, fuse with audio, HTTP API."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
import urllib.request
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from hoa64.report import report_from_scene
|
| 14 |
+
from hoa64.stream import SourceSpec
|
| 15 |
+
from hoa64.synth import envelope_adsr, tone
|
| 16 |
+
from hoa64.vision import fuse_reports, report_from_boxes
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main() -> None:
|
| 20 |
+
sr = 48000
|
| 21 |
+
dur = 0.4
|
| 22 |
+
n = int(sr * dur)
|
| 23 |
+
env = envelope_adsr(n, sr)
|
| 24 |
+
audio_rep = report_from_scene(
|
| 25 |
+
[
|
| 26 |
+
SourceSpec(20.0, 0.0, tone(500, dur, sr, amplitude=0.5) * env, "beep"),
|
| 27 |
+
],
|
| 28 |
+
sr,
|
| 29 |
+
max_order=3,
|
| 30 |
+
)
|
| 31 |
+
vision_rep = report_from_boxes(
|
| 32 |
+
[
|
| 33 |
+
{"az": 25.0, "el": 2.0, "w_deg": 12, "h_deg": 12, "weight": 1.0, "label": "speaker"},
|
| 34 |
+
{"az": -100.0, "el": 0.0, "w_deg": 8, "h_deg": 8, "weight": 0.3, "label": "clutter"},
|
| 35 |
+
],
|
| 36 |
+
max_order=3,
|
| 37 |
+
)
|
| 38 |
+
fused = fuse_reports(audio_rep.to_dict(), {**vision_rep.to_dict(), "one_liner": vision_rep.one_liner()})
|
| 39 |
+
|
| 40 |
+
print("AUDIO ", audio_rep.one_liner())
|
| 41 |
+
print("VISION", vision_rep.one_liner())
|
| 42 |
+
print("FUSE ", fused["one_liner"])
|
| 43 |
+
print(json.dumps({"agreement": fused["agreement"], "sep_deg": fused["angular_separation_deg"]}, indent=2))
|
| 44 |
+
|
| 45 |
+
# Optional live API check
|
| 46 |
+
try:
|
| 47 |
+
req = urllib.request.Request(
|
| 48 |
+
"http://127.0.0.1:8765/health",
|
| 49 |
+
method="GET",
|
| 50 |
+
)
|
| 51 |
+
with urllib.request.urlopen(req, timeout=1) as r:
|
| 52 |
+
health = json.loads(r.read().decode())
|
| 53 |
+
print("API ", health)
|
| 54 |
+
req = urllib.request.Request(
|
| 55 |
+
"http://127.0.0.1:8765/v1/spatial/analyze",
|
| 56 |
+
data=json.dumps({"mode": "vision", "order": 3, "boxes": [{"az": 0, "el": 0, "kind": "point"}]}).encode(),
|
| 57 |
+
headers={"Content-Type": "application/json"},
|
| 58 |
+
method="POST",
|
| 59 |
+
)
|
| 60 |
+
with urllib.request.urlopen(req, timeout=5) as r:
|
| 61 |
+
api = json.loads(r.read().decode())
|
| 62 |
+
print("API vision one_liner:", api.get("one_liner"))
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"API not running yet ({e}); start: systemctl --user start spatial-hoa")
|
| 65 |
+
|
| 66 |
+
print("\nPhase 3 vision + fuse ready. Qwythos: curl :8765 or spatial-report CLI.")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
hoa64/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""hoa64 — spatial calculator: 7th-order Ambisonics (Ambix ACN / SN3D).
|
| 2 |
+
|
| 3 |
+
Fixed spherical-harmonic geometry (Farina / Ambix), not a learned model.
|
| 4 |
+
State dimension is exactly 64 = (7+1)**2.
|
| 5 |
+
|
| 6 |
+
Phases:
|
| 7 |
+
0 — basis, encode/decode, rotate, intensity DOA, RNN pose stub
|
| 8 |
+
1 — WAV I/O, HOA streams, STFT bands, JSON spatial reports for other AIs
|
| 9 |
+
2 — Fast Wigner-D HOA rotation (default)
|
| 10 |
+
3 — Vision boxes/rays on sphere + A/V fuse + Qwythos API
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from .basis import (
|
| 14 |
+
MAX_ORDER,
|
| 15 |
+
N_CHANNELS,
|
| 16 |
+
acn_index,
|
| 17 |
+
acn_nm,
|
| 18 |
+
channel_names,
|
| 19 |
+
sh_sn3d,
|
| 20 |
+
sh_sn3d_batch,
|
| 21 |
+
unit_vector,
|
| 22 |
+
az_el_from_unit,
|
| 23 |
+
)
|
| 24 |
+
from .encode import encode_points, encode_plane_waves, mix
|
| 25 |
+
from .decode import decode_directions, decode_grid, beamform
|
| 26 |
+
from .rotate import rotate_yaw_pitch_roll, rotate_matrix_order1
|
| 27 |
+
from .wigner import hoa_rotation_matrix, apply_hoa_rotation, rotation_matrix_zyx
|
| 28 |
+
from .analysis import (
|
| 29 |
+
field_energy,
|
| 30 |
+
intensity_vector,
|
| 31 |
+
doa_from_intensity,
|
| 32 |
+
directional_power,
|
| 33 |
+
peak_direction,
|
| 34 |
+
)
|
| 35 |
+
from .report import (
|
| 36 |
+
SpatialReport,
|
| 37 |
+
report_from_hoa,
|
| 38 |
+
report_from_mono_wav,
|
| 39 |
+
report_from_ambix_wav,
|
| 40 |
+
report_from_scene,
|
| 41 |
+
)
|
| 42 |
+
from .stream import SourceSpec, encode_mono_plane_wave, encode_scene, analyze_hoa_frames
|
| 43 |
+
from .vision import report_from_boxes, fuse_reports, encode_boxes_to_hoa
|
| 44 |
+
from .detector import detections_to_sphere_boxes, detect_to_sphere
|
| 45 |
+
from .conditioning import build_conditioning, panner_report, spatial_prompt_fragment
|
| 46 |
+
|
| 47 |
+
__all__ = [
|
| 48 |
+
"MAX_ORDER",
|
| 49 |
+
"N_CHANNELS",
|
| 50 |
+
"acn_index",
|
| 51 |
+
"acn_nm",
|
| 52 |
+
"channel_names",
|
| 53 |
+
"sh_sn3d",
|
| 54 |
+
"sh_sn3d_batch",
|
| 55 |
+
"unit_vector",
|
| 56 |
+
"az_el_from_unit",
|
| 57 |
+
"encode_points",
|
| 58 |
+
"encode_plane_waves",
|
| 59 |
+
"mix",
|
| 60 |
+
"decode_directions",
|
| 61 |
+
"decode_grid",
|
| 62 |
+
"beamform",
|
| 63 |
+
"rotate_yaw_pitch_roll",
|
| 64 |
+
"rotate_matrix_order1",
|
| 65 |
+
"hoa_rotation_matrix",
|
| 66 |
+
"apply_hoa_rotation",
|
| 67 |
+
"rotation_matrix_zyx",
|
| 68 |
+
"field_energy",
|
| 69 |
+
"intensity_vector",
|
| 70 |
+
"doa_from_intensity",
|
| 71 |
+
"directional_power",
|
| 72 |
+
"peak_direction",
|
| 73 |
+
"SpatialReport",
|
| 74 |
+
"report_from_hoa",
|
| 75 |
+
"report_from_mono_wav",
|
| 76 |
+
"report_from_ambix_wav",
|
| 77 |
+
"report_from_scene",
|
| 78 |
+
"SourceSpec",
|
| 79 |
+
"encode_mono_plane_wave",
|
| 80 |
+
"encode_scene",
|
| 81 |
+
"analyze_hoa_frames",
|
| 82 |
+
"report_from_boxes",
|
| 83 |
+
"fuse_reports",
|
| 84 |
+
"encode_boxes_to_hoa",
|
| 85 |
+
"detections_to_sphere_boxes",
|
| 86 |
+
"detect_to_sphere",
|
| 87 |
+
"build_conditioning",
|
| 88 |
+
"panner_report",
|
| 89 |
+
"spatial_prompt_fragment",
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
__version__ = "0.5.0"
|
hoa64/__main__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .cli import main
|
| 2 |
+
|
| 3 |
+
raise SystemExit(main())
|
hoa64/analysis.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spatial field analysis on HOA-7 coefficients (no learning)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from typing import Tuple
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from .basis import MAX_ORDER, N_CHANNELS, az_el_from_unit, unit_vector
|
| 11 |
+
from .decode import decode_directions, decode_grid
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def field_energy(hoa: np.ndarray, max_order: int | None = None) -> float | np.ndarray:
|
| 15 |
+
"""Sum of squares of channels (proxy energy; SN3D-weighted optional later).
|
| 16 |
+
|
| 17 |
+
hoa: (C,) → scalar; (C,T) → (T,)
|
| 18 |
+
"""
|
| 19 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 20 |
+
if max_order is not None:
|
| 21 |
+
nch = (max_order + 1) ** 2
|
| 22 |
+
a = a[..., :nch] if a.ndim == 1 else a[:nch, :]
|
| 23 |
+
if a.ndim == 1:
|
| 24 |
+
return float(np.dot(a, a))
|
| 25 |
+
return np.sum(a * a, axis=0)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def intensity_vector(hoa: np.ndarray) -> np.ndarray:
|
| 29 |
+
"""Pseudo-intensity from order-1 SN3D (Ambix).
|
| 30 |
+
|
| 31 |
+
For SN3D B-format-like (W,Y,Z,X) = (a0,a1,a2,a3):
|
| 32 |
+
I ∝ W * (X, Y, Z) in Cartesian (front, left, up).
|
| 33 |
+
|
| 34 |
+
Returns (3,) or (3, T) as [Ix, Iy, Iz].
|
| 35 |
+
"""
|
| 36 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 37 |
+
if a.ndim == 1:
|
| 38 |
+
W, Y, Z, X = a[0], a[1], a[2], a[3]
|
| 39 |
+
return np.array([W * X, W * Y, W * Z], dtype=np.float64)
|
| 40 |
+
W, Y, Z, X = a[0], a[1], a[2], a[3]
|
| 41 |
+
return np.stack([W * X, W * Y, W * Z], axis=0)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def doa_from_intensity(
|
| 45 |
+
hoa: np.ndarray,
|
| 46 |
+
*,
|
| 47 |
+
degrees: bool = True,
|
| 48 |
+
) -> Tuple[float, float] | Tuple[np.ndarray, np.ndarray]:
|
| 49 |
+
"""Direction of arrival from order-1 intensity vector.
|
| 50 |
+
|
| 51 |
+
Returns (azimuth, elevation).
|
| 52 |
+
"""
|
| 53 |
+
I = intensity_vector(hoa)
|
| 54 |
+
if I.ndim == 1:
|
| 55 |
+
n = np.linalg.norm(I)
|
| 56 |
+
if n < 1e-15:
|
| 57 |
+
return (0.0, 0.0) if degrees else (0.0, 0.0)
|
| 58 |
+
u = I / n
|
| 59 |
+
az, el = az_el_from_unit(u, degrees=degrees)
|
| 60 |
+
return float(az), float(el)
|
| 61 |
+
# (3, T)
|
| 62 |
+
n = np.linalg.norm(I, axis=0, keepdims=True)
|
| 63 |
+
n = np.maximum(n, 1e-15)
|
| 64 |
+
u = (I / n).T # (T, 3)
|
| 65 |
+
az, el = az_el_from_unit(u, degrees=degrees)
|
| 66 |
+
return az, el
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def directional_power(
|
| 70 |
+
hoa: np.ndarray,
|
| 71 |
+
n_azi: int = 72,
|
| 72 |
+
n_el: int = 36,
|
| 73 |
+
*,
|
| 74 |
+
max_order: int = MAX_ORDER,
|
| 75 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 76 |
+
"""Power map |a · Y(Ω)|² on a sphere grid.
|
| 77 |
+
|
| 78 |
+
Returns azi, el, power[azi, el].
|
| 79 |
+
"""
|
| 80 |
+
azi, el, samp = decode_grid(
|
| 81 |
+
hoa, n_azi=n_azi, n_el=n_el, degrees=True, max_order=max_order
|
| 82 |
+
)
|
| 83 |
+
if samp.ndim == 2:
|
| 84 |
+
power = samp * samp
|
| 85 |
+
else:
|
| 86 |
+
# (A, E, T) → average over time
|
| 87 |
+
power = np.mean(samp * samp, axis=-1)
|
| 88 |
+
return azi, el, power
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def peak_direction(
|
| 92 |
+
hoa: np.ndarray,
|
| 93 |
+
n_azi: int = 96,
|
| 94 |
+
n_el: int = 48,
|
| 95 |
+
*,
|
| 96 |
+
max_order: int = MAX_ORDER,
|
| 97 |
+
degrees: bool = True,
|
| 98 |
+
) -> Tuple[float, float, float]:
|
| 99 |
+
"""Argmax of directional power (azimuth, elevation, peak_value)."""
|
| 100 |
+
azi, el, power = directional_power(
|
| 101 |
+
hoa, n_azi=n_azi, n_el=n_el, max_order=max_order
|
| 102 |
+
)
|
| 103 |
+
idx = np.unravel_index(int(np.argmax(power)), power.shape)
|
| 104 |
+
az = float(azi[idx[0]])
|
| 105 |
+
e = float(el[idx[1]])
|
| 106 |
+
if not degrees:
|
| 107 |
+
az, e = math.radians(az), math.radians(e)
|
| 108 |
+
return az, e, float(power[idx])
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def angular_error_deg(
|
| 112 |
+
az0: float, el0: float, az1: float, el1: float
|
| 113 |
+
) -> float:
|
| 114 |
+
"""Great-circle angle between two az/el directions (degrees)."""
|
| 115 |
+
u = unit_vector(az0, el0, degrees=True)
|
| 116 |
+
v = unit_vector(az1, el1, degrees=True)
|
| 117 |
+
c = float(np.clip(np.dot(u, v), -1.0, 1.0))
|
| 118 |
+
return math.degrees(math.acos(c))
|
hoa64/audio_io.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WAV read/write without scipy/soundfile (stdlib wave + numpy).
|
| 2 |
+
|
| 3 |
+
Supports mono, multi-channel (e.g. Ambix 4/16/64), and float32/int16.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import struct
|
| 9 |
+
import wave
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Tuple, Union
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
PathLike = Union[str, Path]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def read_wav(path: PathLike) -> Tuple[np.ndarray, int]:
|
| 19 |
+
"""Read a WAV file.
|
| 20 |
+
|
| 21 |
+
Returns
|
| 22 |
+
-------
|
| 23 |
+
audio : np.ndarray
|
| 24 |
+
Shape (n_channels, n_samples), float64 in roughly [-1, 1].
|
| 25 |
+
sample_rate : int
|
| 26 |
+
"""
|
| 27 |
+
path = Path(path)
|
| 28 |
+
with wave.open(str(path), "rb") as wf:
|
| 29 |
+
nch = wf.getnchannels()
|
| 30 |
+
sw = wf.getsampwidth()
|
| 31 |
+
sr = wf.getframerate()
|
| 32 |
+
nframes = wf.getnframes()
|
| 33 |
+
raw = wf.readframes(nframes)
|
| 34 |
+
|
| 35 |
+
if sw == 2:
|
| 36 |
+
mono = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
|
| 37 |
+
elif sw == 4:
|
| 38 |
+
# Try 32-bit int first; if file is float32 PCM some writers use WAVE_FORMAT_IEEE_FLOAT
|
| 39 |
+
# which wave may still hand us as bytes — detect by scale.
|
| 40 |
+
arr_i = np.frombuffer(raw, dtype="<i4")
|
| 41 |
+
# Heuristic: if max abs > 2^30-ish it's int; else might be float bits misread.
|
| 42 |
+
# Prefer IEEE float if values look like floats when reinterpreted.
|
| 43 |
+
arr_f = np.frombuffer(raw, dtype="<f4").astype(np.float64)
|
| 44 |
+
if np.max(np.abs(arr_f)) <= 8.0 and np.max(np.abs(arr_i)) > 1000:
|
| 45 |
+
mono = arr_f
|
| 46 |
+
else:
|
| 47 |
+
mono = arr_i.astype(np.float64) / 2147483648.0
|
| 48 |
+
elif sw == 3:
|
| 49 |
+
# 24-bit packed little-endian
|
| 50 |
+
a = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
|
| 51 |
+
vals = (
|
| 52 |
+
a[:, 0].astype(np.int32)
|
| 53 |
+
| (a[:, 1].astype(np.int32) << 8)
|
| 54 |
+
| (a[:, 2].astype(np.int32) << 16)
|
| 55 |
+
)
|
| 56 |
+
vals = np.where(vals >= 0x800000, vals - 0x1000000, vals)
|
| 57 |
+
mono = vals.astype(np.float64) / 8388608.0
|
| 58 |
+
elif sw == 1:
|
| 59 |
+
mono = (np.frombuffer(raw, dtype=np.uint8).astype(np.float64) - 128.0) / 128.0
|
| 60 |
+
else:
|
| 61 |
+
raise ValueError(f"unsupported sample width {sw}")
|
| 62 |
+
|
| 63 |
+
if nch == 1:
|
| 64 |
+
audio = mono.reshape(1, -1)
|
| 65 |
+
else:
|
| 66 |
+
audio = mono.reshape(-1, nch).T.copy()
|
| 67 |
+
return audio, int(sr)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def write_wav(
|
| 71 |
+
path: PathLike,
|
| 72 |
+
audio: np.ndarray,
|
| 73 |
+
sample_rate: int,
|
| 74 |
+
*,
|
| 75 |
+
subtype: str = "pcm16",
|
| 76 |
+
) -> None:
|
| 77 |
+
"""Write WAV. audio shape (n_channels, n_samples) or (n_samples,)."""
|
| 78 |
+
path = Path(path)
|
| 79 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 80 |
+
a = np.asarray(audio, dtype=np.float64)
|
| 81 |
+
if a.ndim == 1:
|
| 82 |
+
a = a.reshape(1, -1)
|
| 83 |
+
if a.ndim != 2:
|
| 84 |
+
raise ValueError("audio must be (C,T) or (T,)")
|
| 85 |
+
nch, n_samples = a.shape
|
| 86 |
+
a = np.clip(a, -1.0, 1.0)
|
| 87 |
+
|
| 88 |
+
if subtype == "pcm16":
|
| 89 |
+
pcm = (a.T.reshape(-1) * 32767.0).astype("<i2")
|
| 90 |
+
sw = 2
|
| 91 |
+
raw = pcm.tobytes()
|
| 92 |
+
elif subtype == "float32":
|
| 93 |
+
pcm = a.T.reshape(-1).astype("<f4")
|
| 94 |
+
sw = 4
|
| 95 |
+
raw = pcm.tobytes()
|
| 96 |
+
else:
|
| 97 |
+
raise ValueError("subtype must be pcm16 or float32")
|
| 98 |
+
|
| 99 |
+
with wave.open(str(path), "wb") as wf:
|
| 100 |
+
wf.setnchannels(nch)
|
| 101 |
+
wf.setsampwidth(sw)
|
| 102 |
+
wf.setframerate(int(sample_rate))
|
| 103 |
+
# wave module doesn't set IEEE float format tag; pcm16 is portable.
|
| 104 |
+
if subtype == "float32":
|
| 105 |
+
# Still write bytes; many tools accept float32 wav with format 3
|
| 106 |
+
# but stdlib wave always writes PCM. Prefer pcm16 for portability.
|
| 107 |
+
pass
|
| 108 |
+
wf.writeframes(raw)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def ensure_hoa_channels(audio: np.ndarray, max_order: int = 7) -> np.ndarray:
|
| 112 |
+
"""Pad or truncate multi-channel audio to (max_order+1)**2 Ambix channels."""
|
| 113 |
+
nch = (max_order + 1) ** 2
|
| 114 |
+
a = np.asarray(audio, dtype=np.float64)
|
| 115 |
+
if a.ndim == 1:
|
| 116 |
+
a = a.reshape(1, -1)
|
| 117 |
+
out = np.zeros((nch, a.shape[1]), dtype=np.float64)
|
| 118 |
+
n = min(nch, a.shape[0])
|
| 119 |
+
out[:n] = a[:n]
|
| 120 |
+
return out
|
hoa64/basis.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ambix ACN + SN3D real spherical harmonics up to order 7.
|
| 2 |
+
|
| 3 |
+
Conventions (Angelo Farina / ISO-style Ambix):
|
| 4 |
+
- Cartesian: +X front, +Y left, +Z up (ISO 2631-style).
|
| 5 |
+
- Spherical: azimuth a in XY from +X toward +Y (0=front, +90=left);
|
| 6 |
+
elevation e from horizontal (+90=zenith, -90=nadir).
|
| 7 |
+
- Channel order: ACN n*(n+1)+m for m = -n .. +n
|
| 8 |
+
- Normalization: SN3D (Ambix). Order-0 channel W = 1.
|
| 9 |
+
|
| 10 |
+
Evaluation uses associated Legendre functions (no Condon–Shortley phase)
|
| 11 |
+
with Schmidt/SN3D scaling, matching Farina's explicit Ambix formulas for
|
| 12 |
+
orders 0–5 and 7. Order 6 is generated by the same recurrence (Farina's
|
| 13 |
+
Cartesian transcription for n=6 had inconsistent norms).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import math
|
| 19 |
+
from typing import Dict, Tuple
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
MAX_ORDER = 7
|
| 24 |
+
N_CHANNELS = (MAX_ORDER + 1) ** 2 # 64
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def acn_index(n: int, m: int) -> int:
|
| 28 |
+
"""Ambix ACN index for degree n and order m ∈ [-n, n]."""
|
| 29 |
+
if n < 0 or abs(m) > n:
|
| 30 |
+
raise ValueError(f"invalid (n,m)=({n},{m})")
|
| 31 |
+
return n * (n + 1) + m
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def acn_nm(acn: int) -> Tuple[int, int]:
|
| 35 |
+
"""Inverse of acn_index."""
|
| 36 |
+
if acn < 0:
|
| 37 |
+
raise ValueError(acn)
|
| 38 |
+
n = int(math.floor(math.sqrt(acn)))
|
| 39 |
+
m = acn - n * (n + 1)
|
| 40 |
+
return n, m
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
_NAME_N3 = {
|
| 44 |
+
(0, 0): "W",
|
| 45 |
+
(1, -1): "Y",
|
| 46 |
+
(1, 0): "Z",
|
| 47 |
+
(1, 1): "X",
|
| 48 |
+
(2, -2): "V",
|
| 49 |
+
(2, -1): "T",
|
| 50 |
+
(2, 0): "R",
|
| 51 |
+
(2, 1): "S",
|
| 52 |
+
(2, 2): "U",
|
| 53 |
+
(3, -3): "Q",
|
| 54 |
+
(3, -2): "O",
|
| 55 |
+
(3, -1): "M",
|
| 56 |
+
(3, 0): "K",
|
| 57 |
+
(3, 1): "L",
|
| 58 |
+
(3, 2): "N",
|
| 59 |
+
(3, 3): "P",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def channel_names(max_order: int = MAX_ORDER) -> list[str]:
|
| 64 |
+
names: list[str] = []
|
| 65 |
+
for n in range(max_order + 1):
|
| 66 |
+
for m in range(-n, n + 1):
|
| 67 |
+
names.append(_NAME_N3.get((n, m), f"Y{n}_{m:+d}"))
|
| 68 |
+
return names
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def unit_vector(
|
| 72 |
+
azimuth: float | np.ndarray,
|
| 73 |
+
elevation: float | np.ndarray,
|
| 74 |
+
degrees: bool = True,
|
| 75 |
+
) -> np.ndarray:
|
| 76 |
+
"""Direction → unit vector (x, y, z). Broadcasts over arrays. Shape (..., 3)."""
|
| 77 |
+
a = np.asarray(azimuth, dtype=np.float64)
|
| 78 |
+
e = np.asarray(elevation, dtype=np.float64)
|
| 79 |
+
if degrees:
|
| 80 |
+
a = np.deg2rad(a)
|
| 81 |
+
e = np.deg2rad(e)
|
| 82 |
+
ce = np.cos(e)
|
| 83 |
+
x = np.cos(a) * ce
|
| 84 |
+
y = np.sin(a) * ce
|
| 85 |
+
z = np.sin(e)
|
| 86 |
+
return np.stack([x, y, z], axis=-1)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def az_el_from_unit(vec: np.ndarray, degrees: bool = True) -> Tuple[np.ndarray, np.ndarray]:
|
| 90 |
+
"""Unit vector(s) → azimuth, elevation."""
|
| 91 |
+
v = np.asarray(vec, dtype=np.float64)
|
| 92 |
+
x, y, z = v[..., 0], v[..., 1], v[..., 2]
|
| 93 |
+
z = np.clip(z, -1.0, 1.0)
|
| 94 |
+
el = np.arcsin(z)
|
| 95 |
+
az = np.arctan2(y, x)
|
| 96 |
+
if degrees:
|
| 97 |
+
return np.rad2deg(az), np.rad2deg(el)
|
| 98 |
+
return az, el
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _associated_legendre_no_cs(n_max: int, z: float) -> Dict[Tuple[int, int], float]:
|
| 102 |
+
"""P_n^m(z) without Condon–Shortley phase, 0 ≤ m ≤ n ≤ n_max."""
|
| 103 |
+
z = float(np.clip(z, -1.0, 1.0))
|
| 104 |
+
st = math.sqrt(max(0.0, 1.0 - z * z))
|
| 105 |
+
P: Dict[Tuple[int, int], float] = {(0, 0): 1.0}
|
| 106 |
+
if n_max >= 1:
|
| 107 |
+
P[(1, 0)] = z
|
| 108 |
+
P[(1, 1)] = st
|
| 109 |
+
for n in range(2, n_max + 1):
|
| 110 |
+
for m in range(0, n + 1):
|
| 111 |
+
if m == n:
|
| 112 |
+
P[(n, n)] = (2 * n - 1) * st * P[(n - 1, n - 1)]
|
| 113 |
+
elif m == n - 1:
|
| 114 |
+
P[(n, n - 1)] = (2 * n - 1) * z * P[(n - 1, n - 1)]
|
| 115 |
+
else:
|
| 116 |
+
P[(n, m)] = (
|
| 117 |
+
(2 * n - 1) * z * P[(n - 1, m)] - (n + m - 1) * P[(n - 2, m)]
|
| 118 |
+
) / (n - m)
|
| 119 |
+
return P
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _sn3d_one(x: float, y: float, z: float, max_order: int) -> np.ndarray:
|
| 123 |
+
"""SN3D ACN vector at one unit direction."""
|
| 124 |
+
r = math.sqrt(x * x + y * y + z * z)
|
| 125 |
+
if r == 0.0:
|
| 126 |
+
r = 1.0
|
| 127 |
+
x, y, z = x / r, y / r, z / r
|
| 128 |
+
az = math.atan2(y, x)
|
| 129 |
+
P = _associated_legendre_no_cs(max_order, z)
|
| 130 |
+
nch = (max_order + 1) ** 2
|
| 131 |
+
out = np.empty(nch, dtype=np.float64)
|
| 132 |
+
k = 0
|
| 133 |
+
for n in range(max_order + 1):
|
| 134 |
+
for m in range(-n, n + 1):
|
| 135 |
+
if m == 0:
|
| 136 |
+
val = P[(n, 0)]
|
| 137 |
+
else:
|
| 138 |
+
am = abs(m)
|
| 139 |
+
norm = math.sqrt(2.0 * math.factorial(n - am) / math.factorial(n + am))
|
| 140 |
+
if m > 0:
|
| 141 |
+
val = norm * P[(n, am)] * math.cos(am * az)
|
| 142 |
+
else:
|
| 143 |
+
val = norm * P[(n, am)] * math.sin(am * az)
|
| 144 |
+
out[k] = val
|
| 145 |
+
k += 1
|
| 146 |
+
return out
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _eval_sn3d_cartesian(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray:
|
| 150 |
+
"""Evaluate all 64 SN3D ACN channels at unit direction(s). Output (..., 64)."""
|
| 151 |
+
x = np.asarray(x, dtype=np.float64)
|
| 152 |
+
y = np.asarray(y, dtype=np.float64)
|
| 153 |
+
z = np.asarray(z, dtype=np.float64)
|
| 154 |
+
shape = np.broadcast(x, y, z).shape
|
| 155 |
+
x, y, z = np.broadcast_arrays(x, y, z)
|
| 156 |
+
flat = x.size
|
| 157 |
+
out = np.empty((flat, N_CHANNELS), dtype=np.float64)
|
| 158 |
+
xf, yf, zf = x.reshape(-1), y.reshape(-1), z.reshape(-1)
|
| 159 |
+
for i in range(flat):
|
| 160 |
+
out[i] = _sn3d_one(float(xf[i]), float(yf[i]), float(zf[i]), MAX_ORDER)
|
| 161 |
+
return out.reshape(shape + (N_CHANNELS,))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def sh_sn3d(
|
| 165 |
+
azimuth: float | np.ndarray,
|
| 166 |
+
elevation: float | np.ndarray,
|
| 167 |
+
degrees: bool = True,
|
| 168 |
+
max_order: int = MAX_ORDER,
|
| 169 |
+
) -> np.ndarray:
|
| 170 |
+
"""Spherical harmonics Y (Ambix SN3D) at direction(s). Shape (..., n_channels)."""
|
| 171 |
+
if max_order < 0 or max_order > MAX_ORDER:
|
| 172 |
+
raise ValueError(f"max_order must be in 0..{MAX_ORDER}")
|
| 173 |
+
uv = unit_vector(azimuth, elevation, degrees=degrees)
|
| 174 |
+
y = _eval_sn3d_cartesian(uv[..., 0], uv[..., 1], uv[..., 2])
|
| 175 |
+
nch = (max_order + 1) ** 2
|
| 176 |
+
return y[..., :nch]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def sh_sn3d_batch(
|
| 180 |
+
directions_xyz: np.ndarray,
|
| 181 |
+
max_order: int = MAX_ORDER,
|
| 182 |
+
) -> np.ndarray:
|
| 183 |
+
"""Y at unit vectors. directions_xyz: (..., 3) → (..., n_channels)."""
|
| 184 |
+
d = np.asarray(directions_xyz, dtype=np.float64)
|
| 185 |
+
if d.shape[-1] != 3:
|
| 186 |
+
raise ValueError("last dim must be 3")
|
| 187 |
+
y = _eval_sn3d_cartesian(d[..., 0], d[..., 1], d[..., 2])
|
| 188 |
+
nch = (max_order + 1) ** 2
|
| 189 |
+
return y[..., :nch]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def sphere_grid(
|
| 193 |
+
n_azi: int = 72,
|
| 194 |
+
n_el: int = 36,
|
| 195 |
+
degrees: bool = True,
|
| 196 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 197 |
+
"""Product azimuth × elevation grid with integration weights ≈ cos(e) de da."""
|
| 198 |
+
if degrees:
|
| 199 |
+
azi = np.linspace(-180.0, 180.0, n_azi, endpoint=False)
|
| 200 |
+
el = np.linspace(-90.0, 90.0, n_el)
|
| 201 |
+
el_r = np.deg2rad(el)
|
| 202 |
+
da = 2.0 * math.pi / n_azi
|
| 203 |
+
de = math.pi / max(n_el - 1, 1)
|
| 204 |
+
w_el = np.cos(el_r) * de
|
| 205 |
+
weights = np.outer(np.full(n_azi, da), np.maximum(w_el, 0.0))
|
| 206 |
+
else:
|
| 207 |
+
azi = np.linspace(-math.pi, math.pi, n_azi, endpoint=False)
|
| 208 |
+
el = np.linspace(-0.5 * math.pi, 0.5 * math.pi, n_el)
|
| 209 |
+
da = 2.0 * math.pi / n_azi
|
| 210 |
+
de = math.pi / max(n_el - 1, 1)
|
| 211 |
+
w_el = np.cos(el) * de
|
| 212 |
+
weights = np.outer(np.full(n_azi, da), np.maximum(w_el, 0.0))
|
| 213 |
+
return azi, el, weights
|
hoa64/cli.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI: spatial-hoa analyze — produce JSON reports for other models.
|
| 2 |
+
|
| 3 |
+
Examples
|
| 4 |
+
--------
|
| 5 |
+
python -m hoa64.cli analyze scene.wav --ambix
|
| 6 |
+
python -m hoa64.cli analyze mono.wav --az 30 --el 0 -o report.json
|
| 7 |
+
python -m hoa64.cli demo-scene -o /tmp/scene_report.json
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
from . import __version__
|
| 19 |
+
from .audio_io import write_wav
|
| 20 |
+
from .report import (
|
| 21 |
+
report_from_ambix_wav,
|
| 22 |
+
report_from_mono_wav,
|
| 23 |
+
report_from_scene,
|
| 24 |
+
)
|
| 25 |
+
from .stream import SourceSpec, encode_scene
|
| 26 |
+
from .synth import envelope_adsr, tone
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _cmd_analyze(args: argparse.Namespace) -> int:
|
| 30 |
+
path = Path(args.input)
|
| 31 |
+
if not path.is_file():
|
| 32 |
+
print(f"error: file not found: {path}", file=sys.stderr)
|
| 33 |
+
return 2
|
| 34 |
+
|
| 35 |
+
kwargs = dict(
|
| 36 |
+
max_order=args.order,
|
| 37 |
+
frame_ms=args.frame_ms,
|
| 38 |
+
hop_ms=args.hop_ms,
|
| 39 |
+
include_frames=not args.no_frames,
|
| 40 |
+
include_bands=not args.no_bands,
|
| 41 |
+
)
|
| 42 |
+
if args.ambix:
|
| 43 |
+
rep = report_from_ambix_wav(path, **kwargs)
|
| 44 |
+
else:
|
| 45 |
+
if args.az is None:
|
| 46 |
+
print(
|
| 47 |
+
"error: mono plane-wave encode needs --az (or pass --ambix)",
|
| 48 |
+
file=sys.stderr,
|
| 49 |
+
)
|
| 50 |
+
return 2
|
| 51 |
+
rep = report_from_mono_wav(
|
| 52 |
+
path, args.az, args.el if args.el is not None else 0.0, **kwargs
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
text = rep.to_json(indent=2 if not args.compact else None)
|
| 56 |
+
if args.output:
|
| 57 |
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
| 58 |
+
print(rep.one_liner())
|
| 59 |
+
print(f"wrote {args.output}")
|
| 60 |
+
else:
|
| 61 |
+
print(text)
|
| 62 |
+
return 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _cmd_demo_scene(args: argparse.Namespace) -> int:
|
| 66 |
+
sr = args.sr
|
| 67 |
+
dur = args.duration
|
| 68 |
+
n = int(sr * dur)
|
| 69 |
+
env = envelope_adsr(n, sr)
|
| 70 |
+
s1 = tone(440.0, dur, sr, amplitude=0.4) * env
|
| 71 |
+
s2 = tone(660.0, dur, sr, amplitude=0.25) * env
|
| 72 |
+
sources = [
|
| 73 |
+
SourceSpec(0.0, 0.0, s1, label="front_A4"),
|
| 74 |
+
SourceSpec(90.0, 15.0, s2, label="left_E5"),
|
| 75 |
+
]
|
| 76 |
+
rep = report_from_scene(
|
| 77 |
+
sources,
|
| 78 |
+
sr,
|
| 79 |
+
max_order=args.order,
|
| 80 |
+
frame_ms=args.frame_ms,
|
| 81 |
+
hop_ms=args.hop_ms,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if args.write_wav:
|
| 85 |
+
hoa = encode_scene(sources, max_order=min(args.order, 3))
|
| 86 |
+
# Write order-1 B-format-ish (first 4 ch) for portability
|
| 87 |
+
write_wav(args.write_wav, hoa[:4], sr)
|
| 88 |
+
print(f"wrote ambix-lite WAV {args.write_wav} (4 ch)")
|
| 89 |
+
|
| 90 |
+
text = rep.to_json(indent=2 if not args.compact else None)
|
| 91 |
+
if args.output:
|
| 92 |
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
| 93 |
+
print(rep.one_liner())
|
| 94 |
+
print(f"wrote {args.output}")
|
| 95 |
+
else:
|
| 96 |
+
print(text)
|
| 97 |
+
return 0
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
| 101 |
+
from .server import serve
|
| 102 |
+
|
| 103 |
+
serve(args.host, args.port)
|
| 104 |
+
return 0
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _cmd_detect(args: argparse.Namespace) -> int:
|
| 108 |
+
import json
|
| 109 |
+
from .detector import (
|
| 110 |
+
detect_to_sphere,
|
| 111 |
+
detections_to_sphere_boxes,
|
| 112 |
+
load_boxes_json,
|
| 113 |
+
load_yolo_labels,
|
| 114 |
+
write_demo_image_with_box,
|
| 115 |
+
Detection,
|
| 116 |
+
)
|
| 117 |
+
from .vision import report_from_boxes
|
| 118 |
+
|
| 119 |
+
if args.demo_image:
|
| 120 |
+
path = Path(args.demo_image)
|
| 121 |
+
det = write_demo_image_with_box(path)
|
| 122 |
+
boxes = detections_to_sphere_boxes([det], hfov_deg=args.hfov, vfov_deg=args.vfov)
|
| 123 |
+
print(f"wrote demo image {path}")
|
| 124 |
+
elif args.yolo_labels:
|
| 125 |
+
dets = load_yolo_labels(args.yolo_labels)
|
| 126 |
+
boxes = detections_to_sphere_boxes(dets, hfov_deg=args.hfov, vfov_deg=args.vfov)
|
| 127 |
+
elif args.boxes_json:
|
| 128 |
+
boxes = load_boxes_json(args.boxes_json)
|
| 129 |
+
elif args.image:
|
| 130 |
+
img_path = Path(args.image)
|
| 131 |
+
if not img_path.is_file():
|
| 132 |
+
print(
|
| 133 |
+
f"error: image not found: {img_path}\n"
|
| 134 |
+
" /path/to/photo.jpg was only an example placeholder.\n"
|
| 135 |
+
" Try a real file, or:\n"
|
| 136 |
+
" spatial-report detect --demo-image /tmp/demo.png -o /tmp/det.json\n"
|
| 137 |
+
" spatial-report detect --image /tmp/spatial_hoa_e2e/demo_frame.png -o /tmp/det.json",
|
| 138 |
+
file=sys.stderr,
|
| 139 |
+
)
|
| 140 |
+
return 2
|
| 141 |
+
try:
|
| 142 |
+
boxes = detect_to_sphere(
|
| 143 |
+
img_path,
|
| 144 |
+
backend=args.backend,
|
| 145 |
+
score_thresh=args.score,
|
| 146 |
+
hfov_deg=args.hfov,
|
| 147 |
+
vfov_deg=args.vfov,
|
| 148 |
+
)
|
| 149 |
+
except Exception as e:
|
| 150 |
+
print(f"error: detector failed: {e}", file=sys.stderr)
|
| 151 |
+
print(
|
| 152 |
+
" Fallback without neural net:\n"
|
| 153 |
+
" spatial-report detect --demo-image /tmp/demo.png -o /tmp/det.json",
|
| 154 |
+
file=sys.stderr,
|
| 155 |
+
)
|
| 156 |
+
return 1
|
| 157 |
+
if not boxes:
|
| 158 |
+
print(
|
| 159 |
+
"warning: no detections above score threshold "
|
| 160 |
+
"(try --score 0.2, or pass --boxes-json / --demo-image)",
|
| 161 |
+
file=sys.stderr,
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
print(
|
| 165 |
+
"error: need --image, --boxes-json, --yolo-labels, or --demo-image\n"
|
| 166 |
+
" Example: spatial-report detect --demo-image /tmp/demo.png -o /tmp/det.json",
|
| 167 |
+
file=sys.stderr,
|
| 168 |
+
)
|
| 169 |
+
return 2
|
| 170 |
+
|
| 171 |
+
rep = report_from_boxes(boxes, max_order=args.order)
|
| 172 |
+
text = rep.to_json(indent=2 if not args.compact else None)
|
| 173 |
+
if args.output:
|
| 174 |
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
| 175 |
+
print(rep.one_liner())
|
| 176 |
+
print(f"wrote {args.output}")
|
| 177 |
+
else:
|
| 178 |
+
print(text)
|
| 179 |
+
if args.write_boxes:
|
| 180 |
+
Path(args.write_boxes).write_text(json.dumps(boxes, indent=2) + "\n")
|
| 181 |
+
print(f"wrote boxes {args.write_boxes}")
|
| 182 |
+
return 0
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _cmd_live(args: argparse.Namespace) -> int:
|
| 186 |
+
from .live_audio import live_report, list_pulse_sources
|
| 187 |
+
|
| 188 |
+
if args.list_sources:
|
| 189 |
+
for s in list_pulse_sources():
|
| 190 |
+
print(s)
|
| 191 |
+
return 0
|
| 192 |
+
rep = live_report(
|
| 193 |
+
duration_sec=args.duration,
|
| 194 |
+
sample_rate=args.sr,
|
| 195 |
+
channels=args.channels,
|
| 196 |
+
source=args.source,
|
| 197 |
+
az_deg=args.az,
|
| 198 |
+
el_deg=args.el,
|
| 199 |
+
max_order=args.order,
|
| 200 |
+
keep_wav=args.write_wav,
|
| 201 |
+
)
|
| 202 |
+
text = rep.to_json(indent=2 if not args.compact else None)
|
| 203 |
+
if args.output:
|
| 204 |
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
| 205 |
+
print(rep.one_liner())
|
| 206 |
+
print(f"wrote {args.output}")
|
| 207 |
+
else:
|
| 208 |
+
print(text)
|
| 209 |
+
return 0
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _cmd_condition(args: argparse.Namespace) -> int:
|
| 213 |
+
import json
|
| 214 |
+
from .conditioning import (
|
| 215 |
+
build_conditioning,
|
| 216 |
+
comfy_txt2img_payload,
|
| 217 |
+
condition_from_report_file,
|
| 218 |
+
save_conditioning,
|
| 219 |
+
submit_comfy_prompt,
|
| 220 |
+
load_report,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
rep = load_report(args.report)
|
| 224 |
+
cond = build_conditioning(
|
| 225 |
+
rep, base_prompt=args.prompt or "", style=args.style
|
| 226 |
+
)
|
| 227 |
+
if args.output:
|
| 228 |
+
save_conditioning(cond, args.output)
|
| 229 |
+
print(cond["positive_prompt"])
|
| 230 |
+
print(f"wrote {args.output}")
|
| 231 |
+
else:
|
| 232 |
+
print(json.dumps(cond, indent=2))
|
| 233 |
+
|
| 234 |
+
if args.comfy or args.write_workflow:
|
| 235 |
+
from .conditioning import resolve_comfy_checkpoint, list_comfy_checkpoints
|
| 236 |
+
|
| 237 |
+
ckpt = args.checkpoint
|
| 238 |
+
if not ckpt or args.auto_checkpoint:
|
| 239 |
+
ckpt = resolve_comfy_checkpoint(ckpt, base_url=args.comfy_url)
|
| 240 |
+
print(f"comfy checkpoint: {ckpt}")
|
| 241 |
+
wf = comfy_txt2img_payload(
|
| 242 |
+
cond,
|
| 243 |
+
checkpoint=ckpt,
|
| 244 |
+
width=args.width,
|
| 245 |
+
height=args.height,
|
| 246 |
+
steps=args.steps,
|
| 247 |
+
seed=args.seed,
|
| 248 |
+
base_url=args.comfy_url,
|
| 249 |
+
auto_checkpoint=False,
|
| 250 |
+
)
|
| 251 |
+
if args.write_workflow:
|
| 252 |
+
# Write API-ready payload so `curl -d @file` works
|
| 253 |
+
api_body = {"prompt": wf, "client_id": "spatial-hoa"}
|
| 254 |
+
Path(args.write_workflow).write_text(
|
| 255 |
+
json.dumps(api_body, indent=2) + "\n"
|
| 256 |
+
)
|
| 257 |
+
print(f"wrote API workflow {args.write_workflow}")
|
| 258 |
+
if args.comfy:
|
| 259 |
+
result = submit_comfy_prompt(wf, base_url=args.comfy_url)
|
| 260 |
+
print(json.dumps(result, indent=2))
|
| 261 |
+
if result.get("error") and result.get("available_checkpoints"):
|
| 262 |
+
print(
|
| 263 |
+
"available checkpoints:\n "
|
| 264 |
+
+ "\n ".join(result["available_checkpoints"]),
|
| 265 |
+
file=sys.stderr,
|
| 266 |
+
)
|
| 267 |
+
return 0
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _cmd_vision(args: argparse.Namespace) -> int:
|
| 271 |
+
import json
|
| 272 |
+
from .vision import report_from_boxes
|
| 273 |
+
|
| 274 |
+
if args.boxes_json:
|
| 275 |
+
boxes = json.loads(Path(args.boxes_json).read_text(encoding="utf-8"))
|
| 276 |
+
elif args.boxes:
|
| 277 |
+
boxes = json.loads(args.boxes)
|
| 278 |
+
else:
|
| 279 |
+
# demo boxes
|
| 280 |
+
boxes = [
|
| 281 |
+
{"az": 0, "el": 0, "w_deg": 10, "h_deg": 10, "weight": 1.0, "label": "front"},
|
| 282 |
+
{"az": 90, "el": 5, "w_deg": 12, "h_deg": 12, "weight": 0.7, "label": "left"},
|
| 283 |
+
]
|
| 284 |
+
if isinstance(boxes, dict) and "boxes" in boxes:
|
| 285 |
+
boxes = boxes["boxes"]
|
| 286 |
+
rep = report_from_boxes(boxes, max_order=args.order)
|
| 287 |
+
text = rep.to_json(indent=2 if not args.compact else None)
|
| 288 |
+
if args.output:
|
| 289 |
+
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
| 290 |
+
print(rep.one_liner())
|
| 291 |
+
print(f"wrote {args.output}")
|
| 292 |
+
else:
|
| 293 |
+
print(text)
|
| 294 |
+
return 0
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 298 |
+
p = argparse.ArgumentParser(
|
| 299 |
+
prog="hoa64",
|
| 300 |
+
description="HOA-7 spatial calculator (audio + vision + JSON for Qwythos)",
|
| 301 |
+
)
|
| 302 |
+
p.add_argument("--version", action="version", version=f"hoa64 {__version__}")
|
| 303 |
+
sub = p.add_subparsers(dest="cmd", required=True)
|
| 304 |
+
|
| 305 |
+
a = sub.add_parser("analyze", help="Analyze a WAV → spatial JSON report")
|
| 306 |
+
a.add_argument("input", help="Path to WAV")
|
| 307 |
+
a.add_argument(
|
| 308 |
+
"--ambix",
|
| 309 |
+
action="store_true",
|
| 310 |
+
help="Input is multi-channel Ambix ACN (not mono plane-wave)",
|
| 311 |
+
)
|
| 312 |
+
a.add_argument("--az", type=float, default=None, help="Plane-wave azimuth (deg)")
|
| 313 |
+
a.add_argument("--el", type=float, default=0.0, help="Plane-wave elevation (deg)")
|
| 314 |
+
a.add_argument("--order", type=int, default=7, help="Max HOA order (default 7)")
|
| 315 |
+
a.add_argument("--frame-ms", type=float, default=40.0)
|
| 316 |
+
a.add_argument("--hop-ms", type=float, default=20.0)
|
| 317 |
+
a.add_argument("--no-frames", action="store_true")
|
| 318 |
+
a.add_argument("--no-bands", action="store_true")
|
| 319 |
+
a.add_argument("-o", "--output", help="Write JSON to path")
|
| 320 |
+
a.add_argument("--compact", action="store_true", help="Minified JSON")
|
| 321 |
+
a.set_defaults(func=_cmd_analyze)
|
| 322 |
+
|
| 323 |
+
d = sub.add_parser("demo-scene", help="Synthetic 2-source scene → report")
|
| 324 |
+
d.add_argument("--sr", type=int, default=48000)
|
| 325 |
+
d.add_argument("--duration", type=float, default=0.5)
|
| 326 |
+
d.add_argument("--order", type=int, default=7)
|
| 327 |
+
d.add_argument("--frame-ms", type=float, default=40.0)
|
| 328 |
+
d.add_argument("--hop-ms", type=float, default=20.0)
|
| 329 |
+
d.add_argument("-o", "--output", help="Write JSON to path")
|
| 330 |
+
d.add_argument("--write-wav", help="Also write 4-ch Ambix-lite WAV")
|
| 331 |
+
d.add_argument("--compact", action="store_true")
|
| 332 |
+
d.set_defaults(func=_cmd_demo_scene)
|
| 333 |
+
|
| 334 |
+
v = sub.add_parser("vision", help="Vision boxes/rays → spatial JSON report")
|
| 335 |
+
v.add_argument(
|
| 336 |
+
"--boxes",
|
| 337 |
+
help='JSON array of boxes, e.g. \'[{"az":0,"el":0,"weight":1}]\'',
|
| 338 |
+
)
|
| 339 |
+
v.add_argument("--boxes-json", help="Path to JSON file with boxes array")
|
| 340 |
+
v.add_argument("--order", type=int, default=3)
|
| 341 |
+
v.add_argument("-o", "--output", help="Write JSON to path")
|
| 342 |
+
v.add_argument("--compact", action="store_true")
|
| 343 |
+
v.set_defaults(func=_cmd_vision)
|
| 344 |
+
|
| 345 |
+
s = sub.add_parser("serve", help="HTTP API for Qwythos/agents (default :8765)")
|
| 346 |
+
s.add_argument("--host", default="127.0.0.1")
|
| 347 |
+
s.add_argument("--port", type=int, default=8765)
|
| 348 |
+
s.set_defaults(func=_cmd_serve)
|
| 349 |
+
|
| 350 |
+
det = sub.add_parser("detect", help="Image/YOLO/JSON → vision spatial report")
|
| 351 |
+
det.add_argument("--image", help="Image path (optional torchvision detector)")
|
| 352 |
+
det.add_argument("--boxes-json", help="Precomputed boxes or detections JSON")
|
| 353 |
+
det.add_argument("--yolo-labels", help="YOLO .txt labels for an image")
|
| 354 |
+
det.add_argument("--demo-image", help="Write synthetic image+box to this path and analyze")
|
| 355 |
+
det.add_argument("--backend", default="auto", choices=["auto", "torchvision", "none"])
|
| 356 |
+
det.add_argument("--score", type=float, default=0.5)
|
| 357 |
+
det.add_argument("--hfov", type=float, default=90.0)
|
| 358 |
+
det.add_argument("--vfov", type=float, default=60.0)
|
| 359 |
+
det.add_argument("--order", type=int, default=3)
|
| 360 |
+
det.add_argument("-o", "--output", help="Spatial report JSON")
|
| 361 |
+
det.add_argument("--write-boxes", help="Write sphere boxes JSON")
|
| 362 |
+
det.add_argument("--compact", action="store_true")
|
| 363 |
+
det.set_defaults(func=_cmd_detect)
|
| 364 |
+
|
| 365 |
+
live = sub.add_parser("live", help="Capture mic → spatial report")
|
| 366 |
+
live.add_argument("--duration", type=float, default=2.0)
|
| 367 |
+
live.add_argument("--sr", type=int, default=48000)
|
| 368 |
+
live.add_argument("--channels", type=int, default=1)
|
| 369 |
+
live.add_argument("--source", help="Pulse source name (pactl list short sources)")
|
| 370 |
+
live.add_argument("--list-sources", action="store_true")
|
| 371 |
+
live.add_argument("--az", type=float, default=0.0, help="Plane-wave az if mono")
|
| 372 |
+
live.add_argument("--el", type=float, default=0.0)
|
| 373 |
+
live.add_argument("--order", type=int, default=3)
|
| 374 |
+
live.add_argument("-o", "--output")
|
| 375 |
+
live.add_argument("--write-wav", help="Keep captured WAV")
|
| 376 |
+
live.add_argument("--compact", action="store_true")
|
| 377 |
+
live.set_defaults(func=_cmd_live)
|
| 378 |
+
|
| 379 |
+
cond = sub.add_parser("condition", help="Spatial report → diffusion conditioning")
|
| 380 |
+
cond.add_argument("report", help="Path to spatial/fuse report JSON")
|
| 381 |
+
cond.add_argument("--prompt", default="cinematic still, photoreal")
|
| 382 |
+
cond.add_argument("--style", default="natural", choices=["natural", "tags", "technical"])
|
| 383 |
+
cond.add_argument("-o", "--output", help="Write conditioning JSON")
|
| 384 |
+
cond.add_argument("--comfy", action="store_true", help="Submit minimal workflow to ComfyUI")
|
| 385 |
+
cond.add_argument("--comfy-url", default="http://127.0.0.1:8188")
|
| 386 |
+
cond.add_argument(
|
| 387 |
+
"--checkpoint",
|
| 388 |
+
default=None,
|
| 389 |
+
help="ComfyUI ckpt_name (default: auto-detect from /object_info)",
|
| 390 |
+
)
|
| 391 |
+
cond.add_argument(
|
| 392 |
+
"--auto-checkpoint",
|
| 393 |
+
action=argparse.BooleanOptionalAction,
|
| 394 |
+
default=True,
|
| 395 |
+
help="Resolve checkpoint against ComfyUI's installed list (default: true)",
|
| 396 |
+
)
|
| 397 |
+
cond.add_argument("--width", type=int, default=None, help="Latent width (default: 1024 XL / 512 SD)")
|
| 398 |
+
cond.add_argument("--height", type=int, default=None)
|
| 399 |
+
cond.add_argument("--steps", type=int, default=20)
|
| 400 |
+
cond.add_argument("--seed", type=int, default=0)
|
| 401 |
+
cond.add_argument(
|
| 402 |
+
"--write-workflow",
|
| 403 |
+
help="Save ComfyUI API JSON ({prompt: graph}) for curl -d @file",
|
| 404 |
+
)
|
| 405 |
+
cond.set_defaults(func=_cmd_condition)
|
| 406 |
+
|
| 407 |
+
return p
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def main(argv: list[str] | None = None) -> int:
|
| 411 |
+
parser = build_parser()
|
| 412 |
+
args = parser.parse_args(argv)
|
| 413 |
+
return int(args.func(args))
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
if __name__ == "__main__":
|
| 417 |
+
raise SystemExit(main())
|
hoa64/conditioning.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Diffusion / generative conditioning from spatial reports.
|
| 2 |
+
|
| 3 |
+
Turns HOA calculator output into:
|
| 4 |
+
* plain-text control lines for T2I / T2V prompts
|
| 5 |
+
* structured JSON for ControlNet-style / custom nodes
|
| 6 |
+
* optional ComfyUI API prompt payload (if Comfy is running on :8188)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import urllib.error
|
| 13 |
+
import urllib.request
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Mapping, Optional, Union
|
| 16 |
+
|
| 17 |
+
PathLike = Union[str, Path]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get(d: Mapping[str, Any], *keys: str, default: Any = None) -> Any:
|
| 21 |
+
for k in keys:
|
| 22 |
+
if k in d and d[k] is not None:
|
| 23 |
+
return d[k]
|
| 24 |
+
return default
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def panner_report(
|
| 28 |
+
az_deg: float = 0.0,
|
| 29 |
+
el_deg: float = 0.0,
|
| 30 |
+
w_amplitude: float = 0.5,
|
| 31 |
+
) -> dict:
|
| 32 |
+
"""Synthetic spatial report from a UI spherical panner + W gain.
|
| 33 |
+
|
| 34 |
+
W (omnidirectional HOA channel) maps to field size / POV:
|
| 35 |
+
low W → tight / subject-focused / narrow FOV
|
| 36 |
+
high W → wide / environmental / immersive FOV
|
| 37 |
+
"""
|
| 38 |
+
w = max(0.0, float(w_amplitude))
|
| 39 |
+
return {
|
| 40 |
+
"kind": "spatial_panner",
|
| 41 |
+
"doa_az_deg": float(az_deg),
|
| 42 |
+
"doa_el_deg": float(el_deg),
|
| 43 |
+
"w_amplitude": w,
|
| 44 |
+
"energy": w,
|
| 45 |
+
"one_liner": (
|
| 46 |
+
f"panner az={float(az_deg):.0f}° el={float(el_deg):.0f}° W={w:.2f}"
|
| 47 |
+
),
|
| 48 |
+
"meta": {
|
| 49 |
+
"source": "ui_panner",
|
| 50 |
+
"field_width_deg": _w_to_field_width_deg(w),
|
| 51 |
+
},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _w_to_field_width_deg(w: float) -> float:
|
| 56 |
+
"""Map W amplitude (0..1+) to an angular field width in degrees."""
|
| 57 |
+
w = max(0.0, min(2.0, float(w)))
|
| 58 |
+
# 8° pin-point → ~160° ultra-wide at W=1, and beyond at W>1
|
| 59 |
+
return 8.0 + 152.0 * min(1.0, w) + 40.0 * max(0.0, w - 1.0)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _w_to_field_language(w: float, *, style: str = "natural") -> str:
|
| 63 |
+
"""Natural language for field size / camera POV from W amplitude."""
|
| 64 |
+
w = max(0.0, float(w))
|
| 65 |
+
width = _w_to_field_width_deg(w)
|
| 66 |
+
if style == "tags":
|
| 67 |
+
if w < 0.25:
|
| 68 |
+
return f"spatial-fov-tight, spatial-w-{w:.2f}, field-{width:.0f}deg"
|
| 69 |
+
if w < 0.5:
|
| 70 |
+
return f"spatial-fov-medium, spatial-w-{w:.2f}, field-{width:.0f}deg"
|
| 71 |
+
if w < 0.75:
|
| 72 |
+
return f"spatial-fov-wide, spatial-w-{w:.2f}, field-{width:.0f}deg"
|
| 73 |
+
return f"spatial-fov-immersive, spatial-w-{w:.2f}, field-{width:.0f}deg"
|
| 74 |
+
if style == "technical":
|
| 75 |
+
return f"W={w:.3f} field_width_deg={width:.1f}"
|
| 76 |
+
if w < 0.2:
|
| 77 |
+
return (
|
| 78 |
+
f"tight close-up POV, narrow field of view (~{width:.0f}°), "
|
| 79 |
+
"subject fills the frame, shallow spatial field"
|
| 80 |
+
)
|
| 81 |
+
if w < 0.4:
|
| 82 |
+
return (
|
| 83 |
+
f"medium-close framing, moderate field of view (~{width:.0f}°), "
|
| 84 |
+
"subject-focused with limited environment"
|
| 85 |
+
)
|
| 86 |
+
if w < 0.6:
|
| 87 |
+
return (
|
| 88 |
+
f"natural mid-shot POV, balanced field of view (~{width:.0f}°), "
|
| 89 |
+
"subject and surrounding space equally present"
|
| 90 |
+
)
|
| 91 |
+
if w < 0.8:
|
| 92 |
+
return (
|
| 93 |
+
f"wide environmental framing (~{width:.0f}°), expansive field, "
|
| 94 |
+
"subject placed in a larger spatial context"
|
| 95 |
+
)
|
| 96 |
+
return (
|
| 97 |
+
f"ultra-wide immersive POV (~{width:.0f}°), large ambient field, "
|
| 98 |
+
"surrounding space dominates over any single subject"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def spatial_prompt_fragment(
|
| 103 |
+
report: Mapping[str, Any],
|
| 104 |
+
*,
|
| 105 |
+
style: str = "natural",
|
| 106 |
+
) -> str:
|
| 107 |
+
"""Short natural-language spatial control for diffusion prompts.
|
| 108 |
+
|
| 109 |
+
style: natural | tags | technical
|
| 110 |
+
|
| 111 |
+
When ``w_amplitude`` (or fallback ``energy``) is present, appends field
|
| 112 |
+
size / POV language so UI panners can drive framing as well as direction.
|
| 113 |
+
"""
|
| 114 |
+
kind = str(report.get("kind", "spatial_field"))
|
| 115 |
+
w_raw = _get(report, "w_amplitude", default=None)
|
| 116 |
+
# Only treat energy as W when the report is from a UI panner (or W is explicit).
|
| 117 |
+
if w_raw is None and kind == "spatial_panner":
|
| 118 |
+
w_raw = _get(report, "energy", default=None)
|
| 119 |
+
|
| 120 |
+
if kind == "spatial_av_fuse" or "blend_az_deg" in report:
|
| 121 |
+
a_az = float(_get(report, "audio_doa_az_deg", default=0))
|
| 122 |
+
a_el = float(_get(report, "audio_doa_el_deg", default=0))
|
| 123 |
+
v_az = float(_get(report, "vision_doa_az_deg", default=0))
|
| 124 |
+
v_el = float(_get(report, "vision_doa_el_deg", default=0))
|
| 125 |
+
sep = float(_get(report, "angular_separation_deg", default=0))
|
| 126 |
+
agree = bool(_get(report, "agreement", default=False))
|
| 127 |
+
b_az = float(_get(report, "blend_az_deg", default=a_az))
|
| 128 |
+
b_el = float(_get(report, "blend_el_deg", default=a_el))
|
| 129 |
+
if style == "tags":
|
| 130 |
+
base = (
|
| 131 |
+
f"spatial-az-{b_az:.0f}, spatial-el-{b_el:.0f}, "
|
| 132 |
+
f"av-{'aligned' if agree else 'offset'}-{sep:.0f}deg"
|
| 133 |
+
)
|
| 134 |
+
elif style == "technical":
|
| 135 |
+
base = (
|
| 136 |
+
f"HOA control: blend_az={b_az:.1f} blend_el={b_el:.1f} "
|
| 137 |
+
f"audio=({a_az:.1f},{a_el:.1f}) vision=({v_az:.1f},{v_el:.1f}) "
|
| 138 |
+
f"sep={sep:.1f} agree={agree}"
|
| 139 |
+
)
|
| 140 |
+
else:
|
| 141 |
+
side = _az_to_side(b_az)
|
| 142 |
+
height = _el_to_height(b_el)
|
| 143 |
+
align = (
|
| 144 |
+
"sound and subject co-located"
|
| 145 |
+
if agree
|
| 146 |
+
else f"sound and subject separated by {sep:.0f} degrees"
|
| 147 |
+
)
|
| 148 |
+
base = (
|
| 149 |
+
f"camera/listener facing forward; primary subject {side}, {height}; "
|
| 150 |
+
f"{align}; spatial azimuth {b_az:.0f}°, elevation {b_el:.0f}°"
|
| 151 |
+
)
|
| 152 |
+
if w_raw is not None:
|
| 153 |
+
base = f"{base}; {_w_to_field_language(float(w_raw), style=style)}"
|
| 154 |
+
return base
|
| 155 |
+
|
| 156 |
+
az = float(_get(report, "doa_az_deg", "peak_az_deg", default=0))
|
| 157 |
+
el = float(_get(report, "doa_el_deg", "peak_el_deg", default=0))
|
| 158 |
+
if style == "tags":
|
| 159 |
+
base = f"spatial-az-{az:.0f}, spatial-el-{el:.0f}"
|
| 160 |
+
elif style == "technical":
|
| 161 |
+
base = f"HOA control: az={az:.1f} el={el:.1f} kind={kind}"
|
| 162 |
+
else:
|
| 163 |
+
base = (
|
| 164 |
+
f"primary direction {_az_to_side(az)}, {_el_to_height(el)}; "
|
| 165 |
+
f"azimuth {az:.0f}°, elevation {el:.0f}°"
|
| 166 |
+
)
|
| 167 |
+
if w_raw is not None:
|
| 168 |
+
base = f"{base}; {_w_to_field_language(float(w_raw), style=style)}"
|
| 169 |
+
return base
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _az_to_side(az: float) -> str:
|
| 173 |
+
# Ambix: +az = left
|
| 174 |
+
if -20 <= az <= 20:
|
| 175 |
+
return "in front of the camera"
|
| 176 |
+
if 20 < az <= 70:
|
| 177 |
+
return "to the front-left"
|
| 178 |
+
if 70 < az <= 110:
|
| 179 |
+
return "on the left"
|
| 180 |
+
if az > 110 or az < -110:
|
| 181 |
+
return "behind the camera"
|
| 182 |
+
if -70 <= az < -20:
|
| 183 |
+
return "to the front-right"
|
| 184 |
+
return "on the right"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _el_to_height(el: float) -> str:
|
| 188 |
+
if el > 25:
|
| 189 |
+
return "above eye level"
|
| 190 |
+
if el < -25:
|
| 191 |
+
return "below eye level"
|
| 192 |
+
return "near eye level"
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def build_conditioning(
|
| 196 |
+
report: Mapping[str, Any],
|
| 197 |
+
*,
|
| 198 |
+
base_prompt: str = "",
|
| 199 |
+
negative_prompt: str = "",
|
| 200 |
+
style: str = "natural",
|
| 201 |
+
) -> dict:
|
| 202 |
+
"""Structured conditioning payload for generative pipelines."""
|
| 203 |
+
frag = spatial_prompt_fragment(report, style=style)
|
| 204 |
+
if base_prompt:
|
| 205 |
+
positive = f"{base_prompt.rstrip(', ').rstrip()}, {frag}"
|
| 206 |
+
else:
|
| 207 |
+
positive = frag
|
| 208 |
+
control = {
|
| 209 |
+
"schema": "spatial-hoa.conditioning.v1",
|
| 210 |
+
"spatial_fragment": frag,
|
| 211 |
+
"positive_prompt": positive,
|
| 212 |
+
"negative_prompt": negative_prompt,
|
| 213 |
+
"control_vector": {
|
| 214 |
+
"az_deg": float(
|
| 215 |
+
_get(
|
| 216 |
+
report,
|
| 217 |
+
"blend_az_deg",
|
| 218 |
+
"doa_az_deg",
|
| 219 |
+
"peak_az_deg",
|
| 220 |
+
default=0.0,
|
| 221 |
+
)
|
| 222 |
+
),
|
| 223 |
+
"el_deg": float(
|
| 224 |
+
_get(
|
| 225 |
+
report,
|
| 226 |
+
"blend_el_deg",
|
| 227 |
+
"doa_el_deg",
|
| 228 |
+
"peak_el_deg",
|
| 229 |
+
default=0.0,
|
| 230 |
+
)
|
| 231 |
+
),
|
| 232 |
+
"energy": float(_get(report, "energy", "audio_energy", default=0.0)),
|
| 233 |
+
"agreement": _get(report, "agreement", default=None),
|
| 234 |
+
"angular_separation_deg": _get(
|
| 235 |
+
report, "angular_separation_deg", default=None
|
| 236 |
+
),
|
| 237 |
+
},
|
| 238 |
+
"source_report_kind": report.get("kind"),
|
| 239 |
+
"one_liner": report.get("one_liner") or frag,
|
| 240 |
+
}
|
| 241 |
+
return control
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def list_comfy_checkpoints(base_url: str = "http://127.0.0.1:8188") -> list[str]:
|
| 245 |
+
"""Ask ComfyUI which ckpt_name values are valid."""
|
| 246 |
+
url = base_url.rstrip("/") + "/object_info/CheckpointLoaderSimple"
|
| 247 |
+
try:
|
| 248 |
+
with urllib.request.urlopen(url, timeout=5) as r:
|
| 249 |
+
data = json.loads(r.read().decode())
|
| 250 |
+
node = data.get("CheckpointLoaderSimple") or data
|
| 251 |
+
choices = (
|
| 252 |
+
node.get("input", {})
|
| 253 |
+
.get("required", {})
|
| 254 |
+
.get("ckpt_name", [[]])[0]
|
| 255 |
+
)
|
| 256 |
+
# filter non-checkpoint junk (e.g. sam *.pth)
|
| 257 |
+
return [
|
| 258 |
+
c
|
| 259 |
+
for c in choices
|
| 260 |
+
if isinstance(c, str)
|
| 261 |
+
and c.endswith((".safetensors", ".ckpt"))
|
| 262 |
+
and "sam_" not in c.lower()
|
| 263 |
+
]
|
| 264 |
+
except Exception:
|
| 265 |
+
return []
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def resolve_comfy_checkpoint(
|
| 269 |
+
preferred: str | None = None,
|
| 270 |
+
*,
|
| 271 |
+
base_url: str = "http://127.0.0.1:8188",
|
| 272 |
+
) -> str:
|
| 273 |
+
"""Pick a checkpoint that exists on this ComfyUI install."""
|
| 274 |
+
available = list_comfy_checkpoints(base_url)
|
| 275 |
+
if preferred and preferred in available:
|
| 276 |
+
return preferred
|
| 277 |
+
# Prefer SDXL base, then any non-pony XL, then first available
|
| 278 |
+
for name in available:
|
| 279 |
+
if name == "sd_xl_base_1.0.safetensors":
|
| 280 |
+
return name
|
| 281 |
+
for name in available:
|
| 282 |
+
if "xl" in name.lower() or "sdxl" in name.lower():
|
| 283 |
+
return name
|
| 284 |
+
if available:
|
| 285 |
+
return available[0]
|
| 286 |
+
# Offline fallback — may 400 if not installed
|
| 287 |
+
return preferred or "sd_xl_base_1.0.safetensors"
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def comfy_txt2img_payload(
|
| 291 |
+
conditioning: Mapping[str, Any],
|
| 292 |
+
*,
|
| 293 |
+
checkpoint: str | None = None,
|
| 294 |
+
width: int | None = None,
|
| 295 |
+
height: int | None = None,
|
| 296 |
+
steps: int = 20,
|
| 297 |
+
seed: int = 0,
|
| 298 |
+
cfg: float = 7.0,
|
| 299 |
+
base_url: str = "http://127.0.0.1:8188",
|
| 300 |
+
auto_checkpoint: bool = True,
|
| 301 |
+
) -> dict:
|
| 302 |
+
"""Minimal ComfyUI API workflow dict (checkpoint + CLIP + KSampler).
|
| 303 |
+
|
| 304 |
+
Load via: POST http://127.0.0.1:8188/prompt {"prompt": <this>}
|
| 305 |
+
"""
|
| 306 |
+
if auto_checkpoint or not checkpoint:
|
| 307 |
+
checkpoint = resolve_comfy_checkpoint(checkpoint, base_url=base_url)
|
| 308 |
+
# SDXL wants larger latents; SD1.5 512 is fine
|
| 309 |
+
is_xl = any(t in checkpoint.lower() for t in ("xl", "sdxl", "pony", "zimage"))
|
| 310 |
+
if width is None:
|
| 311 |
+
width = 1024 if is_xl else 512
|
| 312 |
+
if height is None:
|
| 313 |
+
height = 1024 if is_xl else 512
|
| 314 |
+
|
| 315 |
+
positive = conditioning.get("positive_prompt", "")
|
| 316 |
+
negative = conditioning.get("negative_prompt", "") or (
|
| 317 |
+
"blurry, low quality, deformed, watermark"
|
| 318 |
+
)
|
| 319 |
+
return {
|
| 320 |
+
"3": {
|
| 321 |
+
"class_type": "KSampler",
|
| 322 |
+
"inputs": {
|
| 323 |
+
"seed": int(seed),
|
| 324 |
+
"steps": int(steps),
|
| 325 |
+
"cfg": float(cfg),
|
| 326 |
+
"sampler_name": "euler",
|
| 327 |
+
"scheduler": "normal",
|
| 328 |
+
"denoise": 1.0,
|
| 329 |
+
"model": ["4", 0],
|
| 330 |
+
"positive": ["6", 0],
|
| 331 |
+
"negative": ["7", 0],
|
| 332 |
+
"latent_image": ["5", 0],
|
| 333 |
+
},
|
| 334 |
+
},
|
| 335 |
+
"4": {
|
| 336 |
+
"class_type": "CheckpointLoaderSimple",
|
| 337 |
+
"inputs": {"ckpt_name": checkpoint},
|
| 338 |
+
},
|
| 339 |
+
"5": {
|
| 340 |
+
"class_type": "EmptyLatentImage",
|
| 341 |
+
"inputs": {"width": int(width), "height": int(height), "batch_size": 1},
|
| 342 |
+
},
|
| 343 |
+
"6": {
|
| 344 |
+
"class_type": "CLIPTextEncode",
|
| 345 |
+
"inputs": {"text": positive, "clip": ["4", 1]},
|
| 346 |
+
},
|
| 347 |
+
"7": {
|
| 348 |
+
"class_type": "CLIPTextEncode",
|
| 349 |
+
"inputs": {"text": negative, "clip": ["4", 1]},
|
| 350 |
+
},
|
| 351 |
+
"8": {
|
| 352 |
+
"class_type": "VAEDecode",
|
| 353 |
+
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
| 354 |
+
},
|
| 355 |
+
"9": {
|
| 356 |
+
"class_type": "SaveImage",
|
| 357 |
+
"inputs": {"filename_prefix": "spatial_hoa", "images": ["8", 0]},
|
| 358 |
+
},
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def submit_comfy_prompt(
|
| 363 |
+
workflow: Mapping[str, Any],
|
| 364 |
+
*,
|
| 365 |
+
base_url: str = "http://127.0.0.1:8188",
|
| 366 |
+
client_id: str = "spatial-hoa",
|
| 367 |
+
) -> dict:
|
| 368 |
+
"""POST workflow to ComfyUI. Returns API JSON or error dict with body."""
|
| 369 |
+
# Accept either raw graph or already-wrapped {"prompt": ...}
|
| 370 |
+
if "prompt" in workflow and isinstance(workflow.get("prompt"), dict):
|
| 371 |
+
payload = dict(workflow)
|
| 372 |
+
payload.setdefault("client_id", client_id)
|
| 373 |
+
else:
|
| 374 |
+
payload = {"prompt": dict(workflow), "client_id": client_id}
|
| 375 |
+
|
| 376 |
+
url = base_url.rstrip("/") + "/prompt"
|
| 377 |
+
body = json.dumps(payload).encode("utf-8")
|
| 378 |
+
req = urllib.request.Request(
|
| 379 |
+
url, data=body, headers={"Content-Type": "application/json"}, method="POST"
|
| 380 |
+
)
|
| 381 |
+
try:
|
| 382 |
+
with urllib.request.urlopen(req, timeout=30) as r:
|
| 383 |
+
return json.loads(r.read().decode())
|
| 384 |
+
except urllib.error.HTTPError as e:
|
| 385 |
+
err_body = e.read().decode("utf-8", errors="replace")
|
| 386 |
+
try:
|
| 387 |
+
detail = json.loads(err_body)
|
| 388 |
+
except Exception:
|
| 389 |
+
detail = {"raw": err_body[:2000]}
|
| 390 |
+
ckpts = list_comfy_checkpoints(base_url)
|
| 391 |
+
return {
|
| 392 |
+
"error": f"HTTP {e.code}: {e.reason}",
|
| 393 |
+
"detail": detail,
|
| 394 |
+
"available_checkpoints": ckpts,
|
| 395 |
+
"hint": (
|
| 396 |
+
"Use --checkpoint <name> from available_checkpoints, "
|
| 397 |
+
"or omit it to auto-select."
|
| 398 |
+
),
|
| 399 |
+
}
|
| 400 |
+
except urllib.error.URLError as e:
|
| 401 |
+
return {"error": str(e), "hint": "Is ComfyUI running on :8188?"}
|
| 402 |
+
except Exception as e:
|
| 403 |
+
return {"error": str(e)}
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def save_conditioning(cond: Mapping[str, Any], path: PathLike) -> None:
|
| 407 |
+
Path(path).write_text(json.dumps(cond, indent=2) + "\n", encoding="utf-8")
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def load_report(path: PathLike) -> dict:
|
| 411 |
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def condition_from_report_file(
|
| 415 |
+
report_path: PathLike,
|
| 416 |
+
*,
|
| 417 |
+
base_prompt: str = "cinematic still, photoreal",
|
| 418 |
+
style: str = "natural",
|
| 419 |
+
) -> dict:
|
| 420 |
+
rep = load_report(report_path)
|
| 421 |
+
return build_conditioning(rep, base_prompt=base_prompt, style=style)
|
hoa64/decode.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decode HOA-7 coefficients to samples on the sphere."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from .basis import MAX_ORDER, sh_sn3d, sh_sn3d_batch, sphere_grid
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def decode_directions(
|
| 11 |
+
hoa: np.ndarray,
|
| 12 |
+
azimuths: np.ndarray | float,
|
| 13 |
+
elevations: np.ndarray | float,
|
| 14 |
+
*,
|
| 15 |
+
degrees: bool = True,
|
| 16 |
+
max_order: int = MAX_ORDER,
|
| 17 |
+
) -> np.ndarray:
|
| 18 |
+
"""Sample field a · Y(Ω) at given directions.
|
| 19 |
+
|
| 20 |
+
hoa: (C,) or (C, T)
|
| 21 |
+
returns: (...) or (..., T)
|
| 22 |
+
"""
|
| 23 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 24 |
+
nch = (max_order + 1) ** 2
|
| 25 |
+
# Allow shorter coefficient vectors (truncated order) or longer (ignore tail).
|
| 26 |
+
if a.ndim == 1:
|
| 27 |
+
aa = np.zeros(nch, dtype=np.float64)
|
| 28 |
+
n = min(nch, a.shape[0])
|
| 29 |
+
aa[:n] = a[:n]
|
| 30 |
+
Y = sh_sn3d(azimuths, elevations, degrees=degrees, max_order=max_order)
|
| 31 |
+
return np.einsum("...c,c->...", Y[..., :nch], aa)
|
| 32 |
+
if a.ndim == 2:
|
| 33 |
+
aa = np.zeros((nch, a.shape[1]), dtype=np.float64)
|
| 34 |
+
n = min(nch, a.shape[0])
|
| 35 |
+
aa[:n] = a[:n, :]
|
| 36 |
+
Y = sh_sn3d(azimuths, elevations, degrees=degrees, max_order=max_order)
|
| 37 |
+
return np.einsum("...c,ct->...t", Y[..., :nch], aa)
|
| 38 |
+
raise ValueError("hoa must be (C,) or (C,T)")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def decode_grid(
|
| 42 |
+
hoa: np.ndarray,
|
| 43 |
+
n_azi: int = 72,
|
| 44 |
+
n_el: int = 36,
|
| 45 |
+
*,
|
| 46 |
+
degrees: bool = True,
|
| 47 |
+
max_order: int = MAX_ORDER,
|
| 48 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 49 |
+
"""Decode on a product sphere grid.
|
| 50 |
+
|
| 51 |
+
Returns azi, el, samples with samples shape (n_azi, n_el) or (n_azi, n_el, T).
|
| 52 |
+
"""
|
| 53 |
+
azi, el, _ = sphere_grid(n_azi, n_el, degrees=degrees)
|
| 54 |
+
AA, EE = np.meshgrid(azi, el, indexing="ij")
|
| 55 |
+
samp = decode_directions(hoa, AA, EE, degrees=degrees, max_order=max_order)
|
| 56 |
+
return azi, el, samp
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def beamform(
|
| 60 |
+
hoa: np.ndarray,
|
| 61 |
+
azimuth: float,
|
| 62 |
+
elevation: float,
|
| 63 |
+
*,
|
| 64 |
+
degrees: bool = True,
|
| 65 |
+
max_order: int = MAX_ORDER,
|
| 66 |
+
) -> float | np.ndarray:
|
| 67 |
+
"""Look / listen toward one direction: scalar (or time series) readout."""
|
| 68 |
+
return decode_directions(
|
| 69 |
+
hoa, azimuth, elevation, degrees=degrees, max_order=max_order
|
| 70 |
+
)
|
hoa64/detector.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Detector → sphere boxes adapter for the vision tower.
|
| 2 |
+
|
| 3 |
+
Supports:
|
| 4 |
+
* Precomputed boxes (JSON / dict list) — always available
|
| 5 |
+
* YOLO-format label files (class x_c y_c w h normalized)
|
| 6 |
+
* Optional torchvision detection (Faster R-CNN MobileNet) if weights load
|
| 7 |
+
|
| 8 |
+
No ultralytics/cv2 required. Image I/O via PIL + numpy.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
from dataclasses import dataclass, asdict
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Mapping, Optional, Sequence, Union
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
PathLike = Union[str, Path]
|
| 21 |
+
|
| 22 |
+
# COCO class names subset for friendlier labels (optional)
|
| 23 |
+
_COCO_NAMES = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _coco_names() -> list[str]:
|
| 27 |
+
global _COCO_NAMES
|
| 28 |
+
if _COCO_NAMES is not None:
|
| 29 |
+
return _COCO_NAMES
|
| 30 |
+
# Minimal common set; unknown ids → class_{id}
|
| 31 |
+
_COCO_NAMES = [
|
| 32 |
+
"__background__", "person", "bicycle", "car", "motorcycle", "airplane",
|
| 33 |
+
"bus", "train", "truck", "boat", "traffic light", "fire hydrant",
|
| 34 |
+
"stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse",
|
| 35 |
+
"sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack",
|
| 36 |
+
"umbrella", "handbag", "tie", "suitcase", "frisbee", "skis",
|
| 37 |
+
"snowboard", "sports ball", "kite", "baseball bat", "baseball glove",
|
| 38 |
+
"skateboard", "surfboard", "tennis racket", "bottle", "wine glass",
|
| 39 |
+
"cup", "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich",
|
| 40 |
+
"orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake",
|
| 41 |
+
"chair", "couch", "potted plant", "bed", "dining table", "toilet",
|
| 42 |
+
"tv", "laptop", "mouse", "remote", "keyboard", "cell phone", "microwave",
|
| 43 |
+
"oven", "toaster", "sink", "refrigerator", "book", "clock", "vase",
|
| 44 |
+
"scissors", "teddy bear", "hair drier", "toothbrush",
|
| 45 |
+
]
|
| 46 |
+
return _COCO_NAMES
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class Detection:
|
| 51 |
+
"""Axis-aligned box in normalized image coords (x,y center or corners)."""
|
| 52 |
+
|
| 53 |
+
# normalized [0,1] image: x right, y down; origin top-left
|
| 54 |
+
x1: float
|
| 55 |
+
y1: float
|
| 56 |
+
x2: float
|
| 57 |
+
y2: float
|
| 58 |
+
score: float = 1.0
|
| 59 |
+
label: str = ""
|
| 60 |
+
class_id: int = -1
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def cx(self) -> float:
|
| 64 |
+
return 0.5 * (self.x1 + self.x2)
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def cy(self) -> float:
|
| 68 |
+
return 0.5 * (self.y1 + self.y2)
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def w(self) -> float:
|
| 72 |
+
return max(0.0, self.x2 - self.x1)
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def h(self) -> float:
|
| 76 |
+
return max(0.0, self.y2 - self.y1)
|
| 77 |
+
|
| 78 |
+
def to_sphere_box(
|
| 79 |
+
self,
|
| 80 |
+
*,
|
| 81 |
+
hfov_deg: float = 90.0,
|
| 82 |
+
vfov_deg: float = 60.0,
|
| 83 |
+
sigma_scale: float = 1.0,
|
| 84 |
+
) -> dict:
|
| 85 |
+
"""Map image box → HOA vision box (az/el degrees, Ambix convention)."""
|
| 86 |
+
# center: image x=0 left → +az, x=1 right → -az (matches vision._as_box)
|
| 87 |
+
az = (0.5 - self.cx) * hfov_deg
|
| 88 |
+
el = (0.5 - self.cy) * vfov_deg
|
| 89 |
+
w_deg = max(2.0, self.w * hfov_deg * sigma_scale)
|
| 90 |
+
h_deg = max(2.0, self.h * vfov_deg * sigma_scale)
|
| 91 |
+
return {
|
| 92 |
+
"az": float(az),
|
| 93 |
+
"el": float(el),
|
| 94 |
+
"w_deg": float(w_deg),
|
| 95 |
+
"h_deg": float(h_deg),
|
| 96 |
+
"weight": float(self.score),
|
| 97 |
+
"label": self.label or (f"class_{self.class_id}" if self.class_id >= 0 else "det"),
|
| 98 |
+
"kind": "box",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def detections_to_sphere_boxes(
|
| 103 |
+
dets: Sequence[Detection],
|
| 104 |
+
*,
|
| 105 |
+
hfov_deg: float = 90.0,
|
| 106 |
+
vfov_deg: float = 60.0,
|
| 107 |
+
min_score: float = 0.25,
|
| 108 |
+
) -> list[dict]:
|
| 109 |
+
out = []
|
| 110 |
+
for d in dets:
|
| 111 |
+
if d.score < min_score:
|
| 112 |
+
continue
|
| 113 |
+
out.append(d.to_sphere_box(hfov_deg=hfov_deg, vfov_deg=vfov_deg))
|
| 114 |
+
return out
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def load_boxes_json(path: PathLike) -> list[dict]:
|
| 118 |
+
"""Load sphere boxes or detections JSON.
|
| 119 |
+
|
| 120 |
+
Accepts:
|
| 121 |
+
[{"az":..., "el":...}, ...]
|
| 122 |
+
{"boxes": [...]}
|
| 123 |
+
{"detections": [{"x1","y1","x2","y2",...}, ...]} # normalized
|
| 124 |
+
"""
|
| 125 |
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 126 |
+
if isinstance(data, list):
|
| 127 |
+
if data and ("x1" in data[0] or "bbox" in data[0]):
|
| 128 |
+
dets = []
|
| 129 |
+
for item in data:
|
| 130 |
+
if "bbox" in item:
|
| 131 |
+
x1, y1, x2, y2 = item["bbox"]
|
| 132 |
+
else:
|
| 133 |
+
x1, y1, x2, y2 = item["x1"], item["y1"], item["x2"], item["y2"]
|
| 134 |
+
dets.append(
|
| 135 |
+
Detection(
|
| 136 |
+
float(x1), float(y1), float(x2), float(y2),
|
| 137 |
+
score=float(item.get("score", item.get("confidence", 1.0))),
|
| 138 |
+
label=str(item.get("label", item.get("class", ""))),
|
| 139 |
+
class_id=int(item.get("class_id", -1)),
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
return detections_to_sphere_boxes(dets)
|
| 143 |
+
return list(data)
|
| 144 |
+
if isinstance(data, dict):
|
| 145 |
+
if "boxes" in data:
|
| 146 |
+
return list(data["boxes"])
|
| 147 |
+
if "detections" in data:
|
| 148 |
+
return load_boxes_json_from_obj(data["detections"])
|
| 149 |
+
raise ValueError(f"unrecognized boxes JSON shape in {path}")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def load_boxes_json_from_obj(obj: Any) -> list[dict]:
|
| 153 |
+
path_like = Path("/tmp/_unused")
|
| 154 |
+
# reuse logic
|
| 155 |
+
if isinstance(obj, list) and obj and "x1" in obj[0]:
|
| 156 |
+
dets = [
|
| 157 |
+
Detection(
|
| 158 |
+
float(i["x1"]), float(i["y1"]), float(i["x2"]), float(i["y2"]),
|
| 159 |
+
score=float(i.get("score", 1.0)),
|
| 160 |
+
label=str(i.get("label", "")),
|
| 161 |
+
)
|
| 162 |
+
for i in obj
|
| 163 |
+
]
|
| 164 |
+
return detections_to_sphere_boxes(dets)
|
| 165 |
+
if isinstance(obj, list):
|
| 166 |
+
return list(obj)
|
| 167 |
+
raise ValueError("bad detections object")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def load_yolo_labels(
|
| 171 |
+
path: PathLike,
|
| 172 |
+
*,
|
| 173 |
+
class_names: Optional[Sequence[str]] = None,
|
| 174 |
+
) -> list[Detection]:
|
| 175 |
+
"""YOLO txt: class x_center y_center width height (all normalized)."""
|
| 176 |
+
dets: list[Detection] = []
|
| 177 |
+
text = Path(path).read_text(encoding="utf-8").strip()
|
| 178 |
+
if not text:
|
| 179 |
+
return dets
|
| 180 |
+
for line in text.splitlines():
|
| 181 |
+
parts = line.split()
|
| 182 |
+
if len(parts) < 5:
|
| 183 |
+
continue
|
| 184 |
+
cid = int(float(parts[0]))
|
| 185 |
+
cx, cy, w, h = map(float, parts[1:5])
|
| 186 |
+
score = float(parts[5]) if len(parts) > 5 else 1.0
|
| 187 |
+
x1, y1 = cx - w / 2, cy - h / 2
|
| 188 |
+
x2, y2 = cx + w / 2, cy + h / 2
|
| 189 |
+
label = ""
|
| 190 |
+
if class_names and 0 <= cid < len(class_names):
|
| 191 |
+
label = class_names[cid]
|
| 192 |
+
else:
|
| 193 |
+
label = f"class_{cid}"
|
| 194 |
+
dets.append(Detection(x1, y1, x2, y2, score=score, label=label, class_id=cid))
|
| 195 |
+
return dets
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def detect_torchvision(
|
| 199 |
+
image_path: PathLike,
|
| 200 |
+
*,
|
| 201 |
+
score_thresh: float = 0.5,
|
| 202 |
+
device: Optional[str] = None,
|
| 203 |
+
max_dets: int = 32,
|
| 204 |
+
) -> list[Detection]:
|
| 205 |
+
"""Run torchvision Faster R-CNN MobileNet on an image.
|
| 206 |
+
|
| 207 |
+
First call may download weights (~50MB). Uses XPU if available.
|
| 208 |
+
"""
|
| 209 |
+
from PIL import Image
|
| 210 |
+
import torch
|
| 211 |
+
import torchvision
|
| 212 |
+
from torchvision.transforms import functional as F
|
| 213 |
+
|
| 214 |
+
img = Image.open(image_path).convert("RGB")
|
| 215 |
+
w, h = img.size
|
| 216 |
+
tensor = F.to_tensor(img)
|
| 217 |
+
|
| 218 |
+
if device is None:
|
| 219 |
+
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
| 220 |
+
device = "xpu"
|
| 221 |
+
elif torch.cuda.is_available():
|
| 222 |
+
device = "cuda"
|
| 223 |
+
else:
|
| 224 |
+
device = "cpu"
|
| 225 |
+
|
| 226 |
+
weights = torchvision.models.detection.FasterRCNN_MobileNet_V3_Large_FPN_Weights.DEFAULT
|
| 227 |
+
model = torchvision.models.detection.fasterrcnn_mobilenet_v3_large_fpn(weights=weights)
|
| 228 |
+
model.eval()
|
| 229 |
+
model.to(device)
|
| 230 |
+
with torch.no_grad():
|
| 231 |
+
out = model([tensor.to(device)])[0]
|
| 232 |
+
|
| 233 |
+
names = _coco_names()
|
| 234 |
+
boxes = out["boxes"].detach().cpu().numpy()
|
| 235 |
+
scores = out["scores"].detach().cpu().numpy()
|
| 236 |
+
labels = out["labels"].detach().cpu().numpy()
|
| 237 |
+
dets: list[Detection] = []
|
| 238 |
+
for box, sc, lab in zip(boxes, scores, labels):
|
| 239 |
+
if sc < score_thresh:
|
| 240 |
+
continue
|
| 241 |
+
x1, y1, x2, y2 = box
|
| 242 |
+
cid = int(lab)
|
| 243 |
+
label = names[cid] if cid < len(names) else f"class_{cid}"
|
| 244 |
+
dets.append(
|
| 245 |
+
Detection(
|
| 246 |
+
x1 / w, y1 / h, x2 / w, y2 / h,
|
| 247 |
+
score=float(sc),
|
| 248 |
+
label=label,
|
| 249 |
+
class_id=cid,
|
| 250 |
+
)
|
| 251 |
+
)
|
| 252 |
+
if len(dets) >= max_dets:
|
| 253 |
+
break
|
| 254 |
+
return dets
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def detect_to_sphere(
|
| 258 |
+
image_path: PathLike,
|
| 259 |
+
*,
|
| 260 |
+
backend: str = "auto",
|
| 261 |
+
score_thresh: float = 0.5,
|
| 262 |
+
hfov_deg: float = 90.0,
|
| 263 |
+
vfov_deg: float = 60.0,
|
| 264 |
+
) -> list[dict]:
|
| 265 |
+
"""Image → sphere boxes.
|
| 266 |
+
|
| 267 |
+
backend: auto | torchvision | none
|
| 268 |
+
auto tries torchvision, falls back to empty with a note if unavailable.
|
| 269 |
+
"""
|
| 270 |
+
if backend in ("auto", "torchvision"):
|
| 271 |
+
try:
|
| 272 |
+
dets = detect_torchvision(image_path, score_thresh=score_thresh)
|
| 273 |
+
return detections_to_sphere_boxes(
|
| 274 |
+
dets, hfov_deg=hfov_deg, vfov_deg=vfov_deg, min_score=score_thresh
|
| 275 |
+
)
|
| 276 |
+
except Exception as e:
|
| 277 |
+
if backend == "torchvision":
|
| 278 |
+
raise
|
| 279 |
+
return []
|
| 280 |
+
return []
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def write_demo_image_with_box(
|
| 284 |
+
path: PathLike,
|
| 285 |
+
*,
|
| 286 |
+
size: tuple[int, int] = (640, 480),
|
| 287 |
+
box_xyxy_norm: tuple[float, float, float, float] = (0.35, 0.35, 0.55, 0.65),
|
| 288 |
+
) -> Detection:
|
| 289 |
+
"""Create a simple synthetic image + known detection (no model)."""
|
| 290 |
+
from PIL import Image, ImageDraw
|
| 291 |
+
|
| 292 |
+
w, h = size
|
| 293 |
+
img = Image.new("RGB", (w, h), (30, 30, 40))
|
| 294 |
+
draw = ImageDraw.Draw(img)
|
| 295 |
+
x1, y1, x2, y2 = box_xyxy_norm
|
| 296 |
+
px = [x1 * w, y1 * h, x2 * w, y2 * h]
|
| 297 |
+
draw.rectangle(px, outline=(0, 255, 80), width=4)
|
| 298 |
+
draw.ellipse(
|
| 299 |
+
[px[0] + 10, px[1] + 10, px[2] - 10, px[3] - 10],
|
| 300 |
+
fill=(200, 80, 80),
|
| 301 |
+
)
|
| 302 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 303 |
+
img.save(path)
|
| 304 |
+
return Detection(x1, y1, x2, y2, score=0.99, label="demo_object", class_id=0)
|
hoa64/encode.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encode discrete sources into HOA-7 (Ambix SN3D) coefficient vectors."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Sequence
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from .basis import MAX_ORDER, N_CHANNELS, sh_sn3d, sh_sn3d_batch, unit_vector
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def encode_points(
|
| 13 |
+
azimuths: Sequence[float] | np.ndarray,
|
| 14 |
+
elevations: Sequence[float] | np.ndarray,
|
| 15 |
+
gains: Sequence[float] | np.ndarray | None = None,
|
| 16 |
+
*,
|
| 17 |
+
degrees: bool = True,
|
| 18 |
+
max_order: int = MAX_ORDER,
|
| 19 |
+
) -> np.ndarray:
|
| 20 |
+
"""Encode point / plane-wave sources on the sphere into HOA coeffs.
|
| 21 |
+
|
| 22 |
+
Each source at (az, el) with gain g contributes g * Y(az, el).
|
| 23 |
+
Returns shape (n_channels,) float64. Time-domain: call per sample
|
| 24 |
+
with complex gains or use encode_plane_waves for signals.
|
| 25 |
+
"""
|
| 26 |
+
az = np.atleast_1d(np.asarray(azimuths, dtype=np.float64))
|
| 27 |
+
el = np.atleast_1d(np.asarray(elevations, dtype=np.float64))
|
| 28 |
+
if az.shape != el.shape:
|
| 29 |
+
raise ValueError("azimuths and elevations must match")
|
| 30 |
+
if gains is None:
|
| 31 |
+
g = np.ones(az.shape, dtype=np.float64)
|
| 32 |
+
else:
|
| 33 |
+
g = np.asarray(gains, dtype=np.float64)
|
| 34 |
+
g = np.broadcast_to(g, az.shape)
|
| 35 |
+
Y = sh_sn3d(az, el, degrees=degrees, max_order=max_order) # (N, C)
|
| 36 |
+
# a = sum_i g_i Y_i
|
| 37 |
+
return np.einsum("n,nc->c", g.reshape(-1), Y.reshape(-1, Y.shape[-1]))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def encode_plane_waves(
|
| 41 |
+
azimuths: Sequence[float] | np.ndarray,
|
| 42 |
+
elevations: Sequence[float] | np.ndarray,
|
| 43 |
+
signals: np.ndarray,
|
| 44 |
+
*,
|
| 45 |
+
degrees: bool = True,
|
| 46 |
+
max_order: int = MAX_ORDER,
|
| 47 |
+
) -> np.ndarray:
|
| 48 |
+
"""Encode multi-channel time signals as plane waves.
|
| 49 |
+
|
| 50 |
+
signals: (n_sources, n_samples) or (n_sources,)
|
| 51 |
+
Returns: (n_channels, n_samples) or (n_channels,)
|
| 52 |
+
"""
|
| 53 |
+
az = np.atleast_1d(np.asarray(azimuths, dtype=np.float64))
|
| 54 |
+
el = np.atleast_1d(np.asarray(elevations, dtype=np.float64))
|
| 55 |
+
sig = np.asarray(signals, dtype=np.float64)
|
| 56 |
+
if sig.ndim == 1:
|
| 57 |
+
if sig.shape[0] == az.shape[0]:
|
| 58 |
+
# (n_sources,) static frame
|
| 59 |
+
return encode_points(az, el, sig, degrees=degrees, max_order=max_order)
|
| 60 |
+
raise ValueError("1-D signals length must equal n_sources")
|
| 61 |
+
if sig.ndim != 2 or sig.shape[0] != az.shape[0]:
|
| 62 |
+
raise ValueError("signals must be (n_sources, n_samples)")
|
| 63 |
+
Y = sh_sn3d(az, el, degrees=degrees, max_order=max_order) # (S, C)
|
| 64 |
+
# a[c,t] = sum_s Y[s,c] * sig[s,t]
|
| 65 |
+
return np.einsum("sc,st->ct", Y, sig)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def mix(*fields: np.ndarray) -> np.ndarray:
|
| 69 |
+
"""Superpose HOA fields (same channel count)."""
|
| 70 |
+
if not fields:
|
| 71 |
+
return np.zeros(N_CHANNELS, dtype=np.float64)
|
| 72 |
+
out = np.zeros_like(np.asarray(fields[0], dtype=np.float64))
|
| 73 |
+
for f in fields:
|
| 74 |
+
out = out + np.asarray(f, dtype=np.float64)
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def encode_xyz(
|
| 79 |
+
directions_xyz: np.ndarray,
|
| 80 |
+
gains: np.ndarray | None = None,
|
| 81 |
+
*,
|
| 82 |
+
max_order: int = MAX_ORDER,
|
| 83 |
+
) -> np.ndarray:
|
| 84 |
+
"""Encode sources given as unit (or non-unit) Cartesian directions."""
|
| 85 |
+
d = np.atleast_2d(np.asarray(directions_xyz, dtype=np.float64))
|
| 86 |
+
Y = sh_sn3d_batch(d, max_order=max_order)
|
| 87 |
+
if gains is None:
|
| 88 |
+
g = np.ones(d.shape[0], dtype=np.float64)
|
| 89 |
+
else:
|
| 90 |
+
g = np.asarray(gains, dtype=np.float64).reshape(-1)
|
| 91 |
+
return np.einsum("n,nc->c", g, Y)
|
hoa64/live_audio.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live microphone / PulseAudio capture → HOA spatial report.
|
| 2 |
+
|
| 3 |
+
Uses system tools (no sounddevice):
|
| 4 |
+
* ffmpeg -f pulse -i <source>
|
| 5 |
+
* arecord (ALSA fallback)
|
| 6 |
+
|
| 7 |
+
Mono capture is encoded as a plane wave from a configured look direction
|
| 8 |
+
(default: front). Multi-channel (2–4) maps L/R to ±az pseudo-Ambix order-1.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import shutil
|
| 14 |
+
import subprocess
|
| 15 |
+
import tempfile
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional, Sequence, Union
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from .audio_io import read_wav, write_wav
|
| 22 |
+
from .encode import encode_plane_waves
|
| 23 |
+
from .report import SpatialReport, report_from_hoa
|
| 24 |
+
from .stream import encode_mono_plane_wave
|
| 25 |
+
|
| 26 |
+
PathLike = Union[str, Path]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def list_pulse_sources() -> list[str]:
|
| 30 |
+
"""Return Pulse/PipeWire source names via pactl."""
|
| 31 |
+
if not shutil.which("pactl"):
|
| 32 |
+
return []
|
| 33 |
+
try:
|
| 34 |
+
out = subprocess.check_output(
|
| 35 |
+
["pactl", "list", "short", "sources"],
|
| 36 |
+
text=True,
|
| 37 |
+
stderr=subprocess.DEVNULL,
|
| 38 |
+
)
|
| 39 |
+
except subprocess.CalledProcessError:
|
| 40 |
+
return []
|
| 41 |
+
names = []
|
| 42 |
+
for line in out.splitlines():
|
| 43 |
+
parts = line.split("\t")
|
| 44 |
+
if len(parts) >= 2:
|
| 45 |
+
names.append(parts[1])
|
| 46 |
+
return names
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def default_mic_source() -> str:
|
| 50 |
+
"""Prefer a non-monitor mic source."""
|
| 51 |
+
sources = list_pulse_sources()
|
| 52 |
+
for s in sources:
|
| 53 |
+
if "monitor" not in s.lower() and ("mic" in s.lower() or "input" in s.lower() or "Line" in s):
|
| 54 |
+
return s
|
| 55 |
+
for s in sources:
|
| 56 |
+
if "monitor" not in s.lower():
|
| 57 |
+
return s
|
| 58 |
+
return "default"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def capture_wav(
|
| 62 |
+
path: PathLike,
|
| 63 |
+
*,
|
| 64 |
+
duration_sec: float = 2.0,
|
| 65 |
+
sample_rate: int = 48000,
|
| 66 |
+
channels: int = 1,
|
| 67 |
+
source: Optional[str] = None,
|
| 68 |
+
backend: str = "auto",
|
| 69 |
+
) -> Path:
|
| 70 |
+
"""Record audio to WAV. backend: auto | ffmpeg | arecord."""
|
| 71 |
+
path = Path(path)
|
| 72 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
source = source or default_mic_source()
|
| 74 |
+
backend = backend.lower()
|
| 75 |
+
|
| 76 |
+
def _ffmpeg() -> None:
|
| 77 |
+
# pulse device
|
| 78 |
+
dev = source if source != "default" else "default"
|
| 79 |
+
cmd = [
|
| 80 |
+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
| 81 |
+
"-f", "pulse", "-i", dev,
|
| 82 |
+
"-t", str(duration_sec),
|
| 83 |
+
"-ar", str(sample_rate),
|
| 84 |
+
"-ac", str(channels),
|
| 85 |
+
str(path),
|
| 86 |
+
]
|
| 87 |
+
subprocess.check_call(cmd)
|
| 88 |
+
|
| 89 |
+
def _arecord() -> None:
|
| 90 |
+
cmd = [
|
| 91 |
+
"arecord",
|
| 92 |
+
"-d", str(int(max(1, round(duration_sec)))),
|
| 93 |
+
"-f", "S16_LE",
|
| 94 |
+
"-r", str(sample_rate),
|
| 95 |
+
"-c", str(channels),
|
| 96 |
+
str(path),
|
| 97 |
+
]
|
| 98 |
+
subprocess.check_call(cmd)
|
| 99 |
+
|
| 100 |
+
errors = []
|
| 101 |
+
if backend in ("auto", "ffmpeg") and shutil.which("ffmpeg"):
|
| 102 |
+
try:
|
| 103 |
+
_ffmpeg()
|
| 104 |
+
return path
|
| 105 |
+
except Exception as e:
|
| 106 |
+
errors.append(f"ffmpeg: {e}")
|
| 107 |
+
if backend == "ffmpeg":
|
| 108 |
+
raise
|
| 109 |
+
if backend in ("auto", "arecord") and shutil.which("arecord"):
|
| 110 |
+
try:
|
| 111 |
+
_arecord()
|
| 112 |
+
return path
|
| 113 |
+
except Exception as e:
|
| 114 |
+
errors.append(f"arecord: {e}")
|
| 115 |
+
if backend == "arecord":
|
| 116 |
+
raise
|
| 117 |
+
raise RuntimeError("capture failed: " + "; ".join(errors) if errors else "no capture backend")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def stereo_to_order1_hoa(
|
| 121 |
+
left: np.ndarray,
|
| 122 |
+
right: np.ndarray,
|
| 123 |
+
*,
|
| 124 |
+
width_az_deg: float = 30.0,
|
| 125 |
+
) -> np.ndarray:
|
| 126 |
+
"""Pseudo order-1 Ambix from stereo: L at +width, R at -width, mid omni."""
|
| 127 |
+
left = np.asarray(left, dtype=np.float64).reshape(-1)
|
| 128 |
+
right = np.asarray(right, dtype=np.float64).reshape(-1)
|
| 129 |
+
n = min(left.shape[0], right.shape[0])
|
| 130 |
+
left, right = left[:n], right[:n]
|
| 131 |
+
mid = 0.5 * (left + right)
|
| 132 |
+
# plane waves
|
| 133 |
+
L = encode_plane_waves([width_az_deg], [0.0], left[None, :], max_order=1)
|
| 134 |
+
R = encode_plane_waves([-width_az_deg], [0.0], right[None, :], max_order=1)
|
| 135 |
+
M = encode_plane_waves([0.0], [0.0], mid[None, :], max_order=1)
|
| 136 |
+
hoa = L + R + 0.5 * M
|
| 137 |
+
# pad to 64
|
| 138 |
+
out = np.zeros((64, n), dtype=np.float64)
|
| 139 |
+
out[:4] = hoa[:4]
|
| 140 |
+
return out
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def audio_to_hoa_stream(
|
| 144 |
+
audio: np.ndarray,
|
| 145 |
+
*,
|
| 146 |
+
az_deg: float = 0.0,
|
| 147 |
+
el_deg: float = 0.0,
|
| 148 |
+
max_order: int = 3,
|
| 149 |
+
stereo_width_az: float = 30.0,
|
| 150 |
+
) -> np.ndarray:
|
| 151 |
+
"""(C,T) HOA from captured audio array (C_in, T) or (T,)."""
|
| 152 |
+
a = np.asarray(audio, dtype=np.float64)
|
| 153 |
+
if a.ndim == 1:
|
| 154 |
+
return encode_mono_plane_wave(a, az_deg, el_deg, max_order=max_order)
|
| 155 |
+
if a.shape[0] == 1:
|
| 156 |
+
return encode_mono_plane_wave(a[0], az_deg, el_deg, max_order=max_order)
|
| 157 |
+
if a.shape[0] >= 4:
|
| 158 |
+
# assume Ambix ACN already
|
| 159 |
+
from .audio_io import ensure_hoa_channels
|
| 160 |
+
return ensure_hoa_channels(a, max_order=max_order)
|
| 161 |
+
if a.shape[0] == 2:
|
| 162 |
+
hoa = stereo_to_order1_hoa(a[0], a[1], width_az_deg=stereo_width_az)
|
| 163 |
+
if max_order < 1:
|
| 164 |
+
return hoa[:1]
|
| 165 |
+
return hoa
|
| 166 |
+
# mixdown
|
| 167 |
+
mono = np.mean(a, axis=0)
|
| 168 |
+
return encode_mono_plane_wave(mono, az_deg, el_deg, max_order=max_order)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def live_report(
|
| 172 |
+
*,
|
| 173 |
+
duration_sec: float = 2.0,
|
| 174 |
+
sample_rate: int = 48000,
|
| 175 |
+
channels: int = 1,
|
| 176 |
+
source: Optional[str] = None,
|
| 177 |
+
az_deg: float = 0.0,
|
| 178 |
+
el_deg: float = 0.0,
|
| 179 |
+
max_order: int = 3,
|
| 180 |
+
keep_wav: Optional[PathLike] = None,
|
| 181 |
+
) -> SpatialReport:
|
| 182 |
+
"""Capture from mic and return a SpatialReport."""
|
| 183 |
+
if keep_wav:
|
| 184 |
+
wav_path = Path(keep_wav)
|
| 185 |
+
else:
|
| 186 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
| 187 |
+
wav_path = Path(tmp.name)
|
| 188 |
+
tmp.close()
|
| 189 |
+
try:
|
| 190 |
+
capture_wav(
|
| 191 |
+
wav_path,
|
| 192 |
+
duration_sec=duration_sec,
|
| 193 |
+
sample_rate=sample_rate,
|
| 194 |
+
channels=channels,
|
| 195 |
+
source=source,
|
| 196 |
+
)
|
| 197 |
+
audio, sr = read_wav(wav_path)
|
| 198 |
+
hoa = audio_to_hoa_stream(
|
| 199 |
+
audio, az_deg=az_deg, el_deg=el_deg, max_order=max_order
|
| 200 |
+
)
|
| 201 |
+
rep = report_from_hoa(
|
| 202 |
+
hoa,
|
| 203 |
+
sr,
|
| 204 |
+
max_order=max_order,
|
| 205 |
+
notes=[
|
| 206 |
+
f"live capture {duration_sec}s ch={audio.shape[0]} src={source or 'default'}",
|
| 207 |
+
f"encode az={az_deg} el={el_deg}" if audio.shape[0] < 4 else "ambix/multi-ch path",
|
| 208 |
+
],
|
| 209 |
+
meta={
|
| 210 |
+
"encode": "live_mic",
|
| 211 |
+
"source": source or default_mic_source(),
|
| 212 |
+
"wav": str(wav_path) if keep_wav else None,
|
| 213 |
+
},
|
| 214 |
+
)
|
| 215 |
+
return rep
|
| 216 |
+
finally:
|
| 217 |
+
if not keep_wav:
|
| 218 |
+
try:
|
| 219 |
+
wav_path.unlink(missing_ok=True)
|
| 220 |
+
except Exception:
|
| 221 |
+
pass
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def live_report_from_file(
|
| 225 |
+
path: PathLike,
|
| 226 |
+
*,
|
| 227 |
+
az_deg: float = 0.0,
|
| 228 |
+
el_deg: float = 0.0,
|
| 229 |
+
max_order: int = 3,
|
| 230 |
+
) -> SpatialReport:
|
| 231 |
+
"""Same pipeline as live_report but from an existing WAV (offline test)."""
|
| 232 |
+
audio, sr = read_wav(path)
|
| 233 |
+
hoa = audio_to_hoa_stream(audio, az_deg=az_deg, el_deg=el_deg, max_order=max_order)
|
| 234 |
+
return report_from_hoa(
|
| 235 |
+
hoa,
|
| 236 |
+
sr,
|
| 237 |
+
max_order=max_order,
|
| 238 |
+
notes=[f"live pipeline on file {path}"],
|
| 239 |
+
meta={"encode": "live_from_file", "path": str(path)},
|
| 240 |
+
)
|
hoa64/report.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON spatial report — compact payload for other models / tools."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import asdict, dataclass, field
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Optional, Sequence, Union
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .analysis import angular_error_deg, doa_from_intensity, field_energy, peak_direction
|
| 14 |
+
from .audio_io import ensure_hoa_channels, read_wav
|
| 15 |
+
from .basis import MAX_ORDER, N_CHANNELS, channel_names
|
| 16 |
+
from .stream import (
|
| 17 |
+
FrameAnalysis,
|
| 18 |
+
SourceSpec,
|
| 19 |
+
analyze_hoa_frames,
|
| 20 |
+
analyze_hoa_stft_bands,
|
| 21 |
+
encode_mono_plane_wave,
|
| 22 |
+
encode_scene,
|
| 23 |
+
hoa_rms,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
PathLike = Union[str, Path]
|
| 27 |
+
|
| 28 |
+
REPORT_SCHEMA_VERSION = "spatial-hoa.report.v1"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class SpatialReport:
|
| 33 |
+
"""Machine-readable spatial summary for LLM / diffusion conditioning."""
|
| 34 |
+
|
| 35 |
+
schema: str = REPORT_SCHEMA_VERSION
|
| 36 |
+
kind: str = "spatial_field"
|
| 37 |
+
sample_rate: int = 0
|
| 38 |
+
n_samples: int = 0
|
| 39 |
+
duration_sec: float = 0.0
|
| 40 |
+
max_order: int = MAX_ORDER
|
| 41 |
+
n_channels: int = N_CHANNELS
|
| 42 |
+
# Global broadband
|
| 43 |
+
energy: float = 0.0
|
| 44 |
+
doa_az_deg: float = 0.0
|
| 45 |
+
doa_el_deg: float = 0.0
|
| 46 |
+
peak_az_deg: float = 0.0
|
| 47 |
+
peak_el_deg: float = 0.0
|
| 48 |
+
peak_power: float = 0.0
|
| 49 |
+
# Optional detail
|
| 50 |
+
bands: list[dict] = field(default_factory=list)
|
| 51 |
+
frames: list[dict] = field(default_factory=list)
|
| 52 |
+
sources_hint: list[dict] = field(default_factory=list)
|
| 53 |
+
channel_rms: list[float] = field(default_factory=list)
|
| 54 |
+
notes: list[str] = field(default_factory=list)
|
| 55 |
+
meta: dict = field(default_factory=dict)
|
| 56 |
+
|
| 57 |
+
def to_dict(self) -> dict[str, Any]:
|
| 58 |
+
return asdict(self)
|
| 59 |
+
|
| 60 |
+
def to_json(self, *, indent: int | None = 2) -> str:
|
| 61 |
+
return json.dumps(self.to_dict(), indent=indent)
|
| 62 |
+
|
| 63 |
+
def save(self, path: PathLike) -> None:
|
| 64 |
+
Path(path).write_text(self.to_json() + "\n", encoding="utf-8")
|
| 65 |
+
|
| 66 |
+
def one_liner(self) -> str:
|
| 67 |
+
"""Short string suitable for tool_result / system hints."""
|
| 68 |
+
return (
|
| 69 |
+
f"spatial: DOA az={self.doa_az_deg:.1f}° el={self.doa_el_deg:.1f}° "
|
| 70 |
+
f"peak=({self.peak_az_deg:.1f},{self.peak_el_deg:.1f}) "
|
| 71 |
+
f"E={self.energy:.4g} T={self.duration_sec:.2f}s"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _frames_to_dicts(frames: Sequence[FrameAnalysis]) -> list[dict]:
|
| 76 |
+
return [
|
| 77 |
+
{
|
| 78 |
+
"t_sec": f.t_center_sec,
|
| 79 |
+
"energy": f.energy,
|
| 80 |
+
"doa_az_deg": f.doa_az_deg,
|
| 81 |
+
"doa_el_deg": f.doa_el_deg,
|
| 82 |
+
"peak_az_deg": f.peak_az_deg,
|
| 83 |
+
"peak_el_deg": f.peak_el_deg,
|
| 84 |
+
"order1_energy": f.order1_energy,
|
| 85 |
+
}
|
| 86 |
+
for f in frames
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def report_from_hoa(
|
| 91 |
+
hoa: np.ndarray,
|
| 92 |
+
sample_rate: int,
|
| 93 |
+
*,
|
| 94 |
+
max_order: int = MAX_ORDER,
|
| 95 |
+
frame_ms: float = 40.0,
|
| 96 |
+
hop_ms: float = 20.0,
|
| 97 |
+
include_frames: bool = True,
|
| 98 |
+
include_bands: bool = True,
|
| 99 |
+
include_peak_map: bool = True,
|
| 100 |
+
max_frames: int = 64,
|
| 101 |
+
meta: Optional[dict] = None,
|
| 102 |
+
notes: Optional[list[str]] = None,
|
| 103 |
+
) -> SpatialReport:
|
| 104 |
+
"""Build a SpatialReport from HOA coefficients (C,T) or (C,)."""
|
| 105 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 106 |
+
if a.ndim == 1:
|
| 107 |
+
a = a.reshape(-1, 1)
|
| 108 |
+
a = ensure_hoa_channels(a, max_order=max_order)
|
| 109 |
+
n_samples = int(a.shape[1])
|
| 110 |
+
duration = n_samples / float(sample_rate) if sample_rate else 0.0
|
| 111 |
+
|
| 112 |
+
# Broadband DOA must be AC-safe (time-mean of coeffs → 0 for audio).
|
| 113 |
+
# 1) global intensity from products W*X, W*Y, W*Z
|
| 114 |
+
# 2) energy-weighted blend of per-frame DOAs
|
| 115 |
+
from .basis import az_el_from_unit, unit_vector
|
| 116 |
+
|
| 117 |
+
W, Yc, Zc, Xc = a[0], a[1], a[2], a[3]
|
| 118 |
+
I_global = np.array(
|
| 119 |
+
[
|
| 120 |
+
float(np.mean(W * Xc)),
|
| 121 |
+
float(np.mean(W * Yc)),
|
| 122 |
+
float(np.mean(W * Zc)),
|
| 123 |
+
]
|
| 124 |
+
)
|
| 125 |
+
nI = float(np.linalg.norm(I_global))
|
| 126 |
+
if nI > 1e-18:
|
| 127 |
+
az, el = az_el_from_unit(I_global / nI, degrees=True)
|
| 128 |
+
az, el = float(az), float(el)
|
| 129 |
+
else:
|
| 130 |
+
az, el = 0.0, 0.0
|
| 131 |
+
|
| 132 |
+
frames = analyze_hoa_frames(
|
| 133 |
+
a,
|
| 134 |
+
sample_rate,
|
| 135 |
+
frame_ms=frame_ms,
|
| 136 |
+
hop_ms=hop_ms,
|
| 137 |
+
max_order=max_order,
|
| 138 |
+
peak_grid=False,
|
| 139 |
+
)
|
| 140 |
+
if frames:
|
| 141 |
+
vecs = []
|
| 142 |
+
for f in frames:
|
| 143 |
+
if f.energy <= 1e-18:
|
| 144 |
+
continue
|
| 145 |
+
u = unit_vector(f.doa_az_deg, f.doa_el_deg, degrees=True)
|
| 146 |
+
vecs.append(u * f.energy)
|
| 147 |
+
if vecs:
|
| 148 |
+
v = np.sum(vecs, axis=0)
|
| 149 |
+
n = float(np.linalg.norm(v))
|
| 150 |
+
if n > 1e-15:
|
| 151 |
+
az_f, el_f = az_el_from_unit(v / n, degrees=True)
|
| 152 |
+
# Prefer frame blend when global intensity is weak
|
| 153 |
+
if nI < 1e-12:
|
| 154 |
+
az, el = float(az_f), float(el_f)
|
| 155 |
+
else:
|
| 156 |
+
# average unit vectors of global + frames
|
| 157 |
+
ug = unit_vector(az, el, degrees=True)
|
| 158 |
+
uf = unit_vector(float(az_f), float(el_f), degrees=True)
|
| 159 |
+
um = ug + uf
|
| 160 |
+
nm = float(np.linalg.norm(um))
|
| 161 |
+
if nm > 1e-15:
|
| 162 |
+
az, el = az_el_from_unit(um / nm, degrees=True)
|
| 163 |
+
az, el = float(az), float(el)
|
| 164 |
+
|
| 165 |
+
energy = float(np.mean(np.sum(a * a, axis=0)))
|
| 166 |
+
|
| 167 |
+
if include_peak_map:
|
| 168 |
+
# Pseudo-static field from RMS * sign(corr with W) for map peak
|
| 169 |
+
rms = np.sqrt(np.mean(a * a, axis=1) + 1e-30)
|
| 170 |
+
sign = np.sign(np.mean(a * a[0:1, :], axis=1) + 1e-30)
|
| 171 |
+
pseudo = rms * sign
|
| 172 |
+
paz, pel, pv = peak_direction(
|
| 173 |
+
pseudo, n_azi=72, n_el=36, max_order=min(max_order, 3)
|
| 174 |
+
)
|
| 175 |
+
else:
|
| 176 |
+
paz, pel, pv = az, el, energy
|
| 177 |
+
|
| 178 |
+
bands: list[dict] = []
|
| 179 |
+
if include_bands and n_samples >= 256:
|
| 180 |
+
bands = analyze_hoa_stft_bands(a, sample_rate)
|
| 181 |
+
|
| 182 |
+
frame_dicts: list[dict] = []
|
| 183 |
+
if include_frames and frames:
|
| 184 |
+
step = max(1, len(frames) // max_frames)
|
| 185 |
+
frame_dicts = _frames_to_dicts(frames[::step][:max_frames])
|
| 186 |
+
|
| 187 |
+
return SpatialReport(
|
| 188 |
+
sample_rate=int(sample_rate),
|
| 189 |
+
n_samples=n_samples,
|
| 190 |
+
duration_sec=duration,
|
| 191 |
+
max_order=max_order,
|
| 192 |
+
n_channels=(max_order + 1) ** 2,
|
| 193 |
+
energy=energy,
|
| 194 |
+
doa_az_deg=float(az),
|
| 195 |
+
doa_el_deg=float(el),
|
| 196 |
+
peak_az_deg=float(paz),
|
| 197 |
+
peak_el_deg=float(pel),
|
| 198 |
+
peak_power=float(pv),
|
| 199 |
+
bands=bands,
|
| 200 |
+
frames=frame_dicts,
|
| 201 |
+
channel_rms=[float(x) for x in hoa_rms(a)[:8]], # first 8 for brevity
|
| 202 |
+
notes=list(notes or []),
|
| 203 |
+
meta=dict(meta or {}),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def report_from_mono_wav(
|
| 208 |
+
path: PathLike,
|
| 209 |
+
azimuth_deg: float,
|
| 210 |
+
elevation_deg: float = 0.0,
|
| 211 |
+
*,
|
| 212 |
+
max_order: int = MAX_ORDER,
|
| 213 |
+
**kwargs,
|
| 214 |
+
) -> SpatialReport:
|
| 215 |
+
"""Load mono (or mixdown) WAV, encode as plane wave from known direction, report."""
|
| 216 |
+
audio, sr = read_wav(path)
|
| 217 |
+
if audio.shape[0] == 1:
|
| 218 |
+
mono = audio[0]
|
| 219 |
+
else:
|
| 220 |
+
mono = np.mean(audio, axis=0)
|
| 221 |
+
notes_extra = ["mixed multi-channel input down to mono before plane-wave encode"]
|
| 222 |
+
hoa = encode_mono_plane_wave(mono, azimuth_deg, elevation_deg, max_order=max_order)
|
| 223 |
+
notes = list(kwargs.pop("notes", []) or [])
|
| 224 |
+
if audio.shape[0] > 1:
|
| 225 |
+
notes.extend(notes_extra)
|
| 226 |
+
notes.append(
|
| 227 |
+
f"encoded mono as plane wave az={azimuth_deg}° el={elevation_deg}°"
|
| 228 |
+
)
|
| 229 |
+
meta = dict(kwargs.pop("meta", None) or {})
|
| 230 |
+
meta.update(
|
| 231 |
+
{
|
| 232 |
+
"source_path": str(path),
|
| 233 |
+
"encode": "mono_plane_wave",
|
| 234 |
+
"encode_az_deg": azimuth_deg,
|
| 235 |
+
"encode_el_deg": elevation_deg,
|
| 236 |
+
"created_unix": time.time(),
|
| 237 |
+
}
|
| 238 |
+
)
|
| 239 |
+
return report_from_hoa(
|
| 240 |
+
hoa, sr, max_order=max_order, notes=notes, meta=meta, **kwargs
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def report_from_ambix_wav(
|
| 245 |
+
path: PathLike,
|
| 246 |
+
*,
|
| 247 |
+
max_order: int = MAX_ORDER,
|
| 248 |
+
**kwargs,
|
| 249 |
+
) -> SpatialReport:
|
| 250 |
+
"""Load multi-channel Ambix WAV (ACN order) and analyze."""
|
| 251 |
+
audio, sr = read_wav(path)
|
| 252 |
+
hoa = ensure_hoa_channels(audio, max_order=max_order)
|
| 253 |
+
notes = list(kwargs.pop("notes", []) or [])
|
| 254 |
+
notes.append(f"loaded Ambix-style multi-channel WAV with {audio.shape[0]} ch")
|
| 255 |
+
meta = dict(kwargs.pop("meta", None) or {})
|
| 256 |
+
meta.update(
|
| 257 |
+
{
|
| 258 |
+
"source_path": str(path),
|
| 259 |
+
"encode": "ambix_wav",
|
| 260 |
+
"input_channels": int(audio.shape[0]),
|
| 261 |
+
"created_unix": time.time(),
|
| 262 |
+
}
|
| 263 |
+
)
|
| 264 |
+
return report_from_hoa(
|
| 265 |
+
hoa, sr, max_order=max_order, notes=notes, meta=meta, **kwargs
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def report_from_scene(
|
| 270 |
+
sources: Sequence[SourceSpec],
|
| 271 |
+
sample_rate: int,
|
| 272 |
+
*,
|
| 273 |
+
max_order: int = MAX_ORDER,
|
| 274 |
+
**kwargs,
|
| 275 |
+
) -> SpatialReport:
|
| 276 |
+
"""Analyze a synthetic multi-source scene."""
|
| 277 |
+
hoa = encode_scene(sources, max_order=max_order)
|
| 278 |
+
hints = [
|
| 279 |
+
{
|
| 280 |
+
"label": s.label or f"src{i}",
|
| 281 |
+
"azimuth_deg": s.azimuth_deg,
|
| 282 |
+
"elevation_deg": s.elevation_deg,
|
| 283 |
+
"rms": float(np.sqrt(np.mean(np.asarray(s.signal) ** 2) + 1e-30)),
|
| 284 |
+
}
|
| 285 |
+
for i, s in enumerate(sources)
|
| 286 |
+
]
|
| 287 |
+
notes = list(kwargs.pop("notes", []) or [])
|
| 288 |
+
notes.append(f"synthetic scene with {len(sources)} plane-wave source(s)")
|
| 289 |
+
meta = dict(kwargs.pop("meta", None) or {})
|
| 290 |
+
meta["encode"] = "synthetic_scene"
|
| 291 |
+
rep = report_from_hoa(
|
| 292 |
+
hoa, sample_rate, max_order=max_order, notes=notes, meta=meta, **kwargs
|
| 293 |
+
)
|
| 294 |
+
rep.sources_hint = hints
|
| 295 |
+
return rep
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def load_report(path: PathLike) -> dict:
|
| 299 |
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
hoa64/rnn_stub.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RNN integrator stub — Phase 0 interface for iterative motion in a field.
|
| 2 |
+
|
| 3 |
+
No learned weights yet: explicit Euler integration of pose + field rotation.
|
| 4 |
+
Validates the "calculator + loop" hypothesis before training dynamics.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .basis import N_CHANNELS
|
| 14 |
+
from .encode import encode_points
|
| 15 |
+
from .rotate import rotate_matrix_order1, rotate_yaw_pitch_roll
|
| 16 |
+
from .analysis import doa_from_intensity, peak_direction, field_energy
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _pad64(a: np.ndarray) -> np.ndarray:
|
| 20 |
+
a = np.asarray(a, dtype=np.float64).reshape(-1)
|
| 21 |
+
out = np.zeros(N_CHANNELS, dtype=np.float64)
|
| 22 |
+
out[: min(N_CHANNELS, a.shape[0])] = a[:N_CHANNELS]
|
| 23 |
+
return out
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class SpatialState:
|
| 28 |
+
"""Agent + field state for iterative spatial calculation."""
|
| 29 |
+
|
| 30 |
+
hoa: np.ndarray # (64,) world field in listener frame after last step
|
| 31 |
+
yaw: float = 0.0 # degrees, agent heading
|
| 32 |
+
pitch: float = 0.0
|
| 33 |
+
roll: float = 0.0
|
| 34 |
+
history: list = field(default_factory=list)
|
| 35 |
+
|
| 36 |
+
def __post_init__(self) -> None:
|
| 37 |
+
self.hoa = _pad64(self.hoa)
|
| 38 |
+
|
| 39 |
+
def snapshot(self) -> dict:
|
| 40 |
+
az, el = doa_from_intensity(self.hoa)
|
| 41 |
+
paz, pel, pval = peak_direction(self.hoa, n_azi=72, n_el=36)
|
| 42 |
+
return {
|
| 43 |
+
"yaw": self.yaw,
|
| 44 |
+
"pitch": self.pitch,
|
| 45 |
+
"roll": self.roll,
|
| 46 |
+
"energy": field_energy(self.hoa),
|
| 47 |
+
"doa_intensity_az_el": (az, el),
|
| 48 |
+
"doa_peak_az_el": (paz, pel),
|
| 49 |
+
"peak_power": pval,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def step_rotate(
|
| 54 |
+
state: SpatialState,
|
| 55 |
+
d_yaw: float = 0.0,
|
| 56 |
+
d_pitch: float = 0.0,
|
| 57 |
+
d_roll: float = 0.0,
|
| 58 |
+
*,
|
| 59 |
+
max_order: int = 7,
|
| 60 |
+
dense: bool = True,
|
| 61 |
+
) -> SpatialState:
|
| 62 |
+
"""Integrate a pose increment: rotate the field opposite agent turn.
|
| 63 |
+
|
| 64 |
+
If the agent yaws +θ (turns left), the world field in head frame yaws −θ.
|
| 65 |
+
"""
|
| 66 |
+
# Agent pose update
|
| 67 |
+
yaw = state.yaw + d_yaw
|
| 68 |
+
pitch = state.pitch + d_pitch
|
| 69 |
+
roll = state.roll + d_roll
|
| 70 |
+
# Field in listener frame: rotate by -d_*
|
| 71 |
+
if dense and max_order > 1:
|
| 72 |
+
hoa = rotate_yaw_pitch_roll(
|
| 73 |
+
state.hoa,
|
| 74 |
+
yaw=-d_yaw,
|
| 75 |
+
pitch=-d_pitch,
|
| 76 |
+
roll=-d_roll,
|
| 77 |
+
degrees=True,
|
| 78 |
+
max_order=max_order,
|
| 79 |
+
)
|
| 80 |
+
else:
|
| 81 |
+
hoa = rotate_matrix_order1(
|
| 82 |
+
state.hoa, yaw=-d_yaw, pitch=-d_pitch, roll=-d_roll, degrees=True
|
| 83 |
+
)
|
| 84 |
+
hoa = _pad64(hoa)
|
| 85 |
+
new = SpatialState(hoa=hoa, yaw=yaw, pitch=pitch, roll=roll, history=list(state.history))
|
| 86 |
+
new.history.append(new.snapshot())
|
| 87 |
+
return new
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def world_from_sources(
|
| 91 |
+
azimuths,
|
| 92 |
+
elevations,
|
| 93 |
+
gains=None,
|
| 94 |
+
*,
|
| 95 |
+
max_order: int = 7,
|
| 96 |
+
) -> SpatialState:
|
| 97 |
+
"""Build initial state from world-frame sources (listener at origin, identity pose)."""
|
| 98 |
+
a = encode_points(azimuths, elevations, gains, degrees=True, max_order=max_order)
|
| 99 |
+
st = SpatialState(hoa=a)
|
| 100 |
+
st.history.append(st.snapshot())
|
| 101 |
+
return st
|
hoa64/rotate.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rotate HOA fields.
|
| 2 |
+
|
| 3 |
+
Phase 2 default: Wigner-D block rotation (fast, all orders).
|
| 4 |
+
Phase 0 reference: dense spherical re-projection (``method="dense"``).
|
| 5 |
+
Order-1 shortcut: ``rotate_matrix_order1`` (exact Cartesian).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from .basis import MAX_ORDER, N_CHANNELS, sh_sn3d_batch, sphere_grid, unit_vector, acn_index
|
| 15 |
+
from .wigner import (
|
| 16 |
+
apply_hoa_rotation,
|
| 17 |
+
hoa_rotation_matrix,
|
| 18 |
+
rotation_matrix_zyx,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _rotation_matrix_zyx(yaw: float, pitch: float, roll: float, degrees: bool) -> np.ndarray:
|
| 23 |
+
return rotation_matrix_zyx(yaw, pitch, roll, degrees=degrees)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def rotate_matrix_order1(
|
| 27 |
+
hoa: np.ndarray,
|
| 28 |
+
yaw: float = 0.0,
|
| 29 |
+
pitch: float = 0.0,
|
| 30 |
+
roll: float = 0.0,
|
| 31 |
+
*,
|
| 32 |
+
degrees: bool = True,
|
| 33 |
+
) -> np.ndarray:
|
| 34 |
+
"""Exact rotation for order-1 channels (Y,Z,X); W and n≥2 unchanged."""
|
| 35 |
+
a = np.array(hoa, dtype=np.float64, copy=True)
|
| 36 |
+
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
|
| 37 |
+
if a.ndim == 1:
|
| 38 |
+
cart = np.array([a[3], a[1], a[2]])
|
| 39 |
+
Xp, Yp, Zp = R @ cart
|
| 40 |
+
a[3], a[1], a[2] = Xp, Yp, Zp
|
| 41 |
+
return a
|
| 42 |
+
cart = np.stack([a[3], a[1], a[2]], axis=0)
|
| 43 |
+
rot = R @ cart
|
| 44 |
+
a[3], a[1], a[2] = rot[0], rot[1], rot[2]
|
| 45 |
+
return a
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _sn3d_channel_scale(max_order: int) -> np.ndarray:
|
| 49 |
+
nch = (max_order + 1) ** 2
|
| 50 |
+
scale = np.empty(nch, dtype=np.float64)
|
| 51 |
+
for n in range(max_order + 1):
|
| 52 |
+
s = float(2 * n + 1)
|
| 53 |
+
for m in range(-n, n + 1):
|
| 54 |
+
scale[acn_index(n, m)] = s
|
| 55 |
+
return scale
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def rotate_yaw_pitch_roll(
|
| 59 |
+
hoa: np.ndarray,
|
| 60 |
+
yaw: float = 0.0,
|
| 61 |
+
pitch: float = 0.0,
|
| 62 |
+
roll: float = 0.0,
|
| 63 |
+
*,
|
| 64 |
+
degrees: bool = True,
|
| 65 |
+
max_order: int = MAX_ORDER,
|
| 66 |
+
method: str = "wigner",
|
| 67 |
+
n_azi: int = 96,
|
| 68 |
+
n_el: int = 48,
|
| 69 |
+
) -> np.ndarray:
|
| 70 |
+
"""Rotate HOA field (active): values move with R.
|
| 71 |
+
|
| 72 |
+
Parameters
|
| 73 |
+
----------
|
| 74 |
+
method :
|
| 75 |
+
``"wigner"`` (default, Phase 2) — exact band-limited SH rotation.
|
| 76 |
+
``"dense"`` — Phase 0 spherical sample / re-encode (slow reference).
|
| 77 |
+
"""
|
| 78 |
+
if method == "wigner":
|
| 79 |
+
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
|
| 80 |
+
M = hoa_rotation_matrix(R, max_order=max_order)
|
| 81 |
+
return apply_hoa_rotation(hoa, M)
|
| 82 |
+
if method == "dense":
|
| 83 |
+
return _rotate_dense(
|
| 84 |
+
hoa,
|
| 85 |
+
yaw,
|
| 86 |
+
pitch,
|
| 87 |
+
roll,
|
| 88 |
+
degrees=degrees,
|
| 89 |
+
max_order=max_order,
|
| 90 |
+
n_azi=n_azi,
|
| 91 |
+
n_el=n_el,
|
| 92 |
+
)
|
| 93 |
+
raise ValueError("method must be 'wigner' or 'dense'")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _rotate_dense(
|
| 97 |
+
hoa: np.ndarray,
|
| 98 |
+
yaw: float,
|
| 99 |
+
pitch: float,
|
| 100 |
+
roll: float,
|
| 101 |
+
*,
|
| 102 |
+
degrees: bool,
|
| 103 |
+
max_order: int,
|
| 104 |
+
n_azi: int,
|
| 105 |
+
n_el: int,
|
| 106 |
+
) -> np.ndarray:
|
| 107 |
+
"""Phase 0 dense re-encode (reference)."""
|
| 108 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 109 |
+
if a.ndim == 2:
|
| 110 |
+
out = np.zeros(((max_order + 1) ** 2, a.shape[1]), dtype=np.float64)
|
| 111 |
+
for t in range(a.shape[1]):
|
| 112 |
+
out[:, t] = _rotate_dense(
|
| 113 |
+
a[:, t],
|
| 114 |
+
yaw,
|
| 115 |
+
pitch,
|
| 116 |
+
roll,
|
| 117 |
+
degrees=degrees,
|
| 118 |
+
max_order=max_order,
|
| 119 |
+
n_azi=n_azi,
|
| 120 |
+
n_el=n_el,
|
| 121 |
+
)
|
| 122 |
+
full = np.zeros((N_CHANNELS, a.shape[1]), dtype=np.float64)
|
| 123 |
+
full[: out.shape[0]] = out
|
| 124 |
+
return full
|
| 125 |
+
|
| 126 |
+
from .decode import decode_directions
|
| 127 |
+
|
| 128 |
+
nch = (max_order + 1) ** 2
|
| 129 |
+
a = a[:nch]
|
| 130 |
+
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
|
| 131 |
+
|
| 132 |
+
azi, el, weights = sphere_grid(n_azi, n_el, degrees=True)
|
| 133 |
+
AA, EE = np.meshgrid(azi, el, indexing="ij")
|
| 134 |
+
dirs = unit_vector(AA, EE, degrees=True).reshape(-1, 3)
|
| 135 |
+
w = weights.reshape(-1)
|
| 136 |
+
f = decode_directions(
|
| 137 |
+
a, AA.reshape(-1), EE.reshape(-1), degrees=True, max_order=max_order
|
| 138 |
+
)
|
| 139 |
+
dirs_rot = (R @ dirs.T).T
|
| 140 |
+
Y_rot = sh_sn3d_batch(dirs_rot, max_order=max_order)
|
| 141 |
+
a_new = np.einsum("i,i,ic->c", w, f, Y_rot)
|
| 142 |
+
a_new *= _sn3d_channel_scale(max_order) / (4.0 * math.pi)
|
| 143 |
+
full = np.zeros(N_CHANNELS, dtype=np.float64)
|
| 144 |
+
full[: a_new.shape[0]] = a_new
|
| 145 |
+
return full
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def rotate_source_directions(
|
| 149 |
+
azimuths: np.ndarray,
|
| 150 |
+
elevations: np.ndarray,
|
| 151 |
+
yaw: float = 0.0,
|
| 152 |
+
pitch: float = 0.0,
|
| 153 |
+
roll: float = 0.0,
|
| 154 |
+
*,
|
| 155 |
+
degrees: bool = True,
|
| 156 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 157 |
+
"""Rotate source az/el by the same R (exact plane-wave ground truth)."""
|
| 158 |
+
dirs = unit_vector(azimuths, elevations, degrees=degrees)
|
| 159 |
+
flat = np.atleast_2d(dirs.reshape(-1, 3))
|
| 160 |
+
R = _rotation_matrix_zyx(yaw, pitch, roll, degrees)
|
| 161 |
+
rot = (R @ flat.T).T
|
| 162 |
+
from .basis import az_el_from_unit
|
| 163 |
+
|
| 164 |
+
az, el = az_el_from_unit(rot, degrees=degrees)
|
| 165 |
+
return az.reshape(np.shape(azimuths)), el.reshape(np.shape(elevations))
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def rotate_plane_wave_field(
|
| 169 |
+
hoa: np.ndarray,
|
| 170 |
+
yaw: float = 0.0,
|
| 171 |
+
pitch: float = 0.0,
|
| 172 |
+
roll: float = 0.0,
|
| 173 |
+
*,
|
| 174 |
+
degrees: bool = True,
|
| 175 |
+
max_order: int = MAX_ORDER,
|
| 176 |
+
method: str = "wigner",
|
| 177 |
+
) -> np.ndarray:
|
| 178 |
+
return rotate_yaw_pitch_roll(
|
| 179 |
+
hoa,
|
| 180 |
+
yaw,
|
| 181 |
+
pitch,
|
| 182 |
+
roll,
|
| 183 |
+
degrees=degrees,
|
| 184 |
+
max_order=max_order,
|
| 185 |
+
method=method,
|
| 186 |
+
)
|
hoa64/server.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal HTTP API so Qwythos / agents can request spatial reports.
|
| 2 |
+
|
| 3 |
+
Stdlib only (no Flask). Default bind: 127.0.0.1:8765
|
| 4 |
+
|
| 5 |
+
Endpoints
|
| 6 |
+
---------
|
| 7 |
+
GET /health
|
| 8 |
+
GET /v1/spatial/schema — OpenAI-style tool descriptor
|
| 9 |
+
POST /v1/spatial/analyze — JSON body → SpatialReport
|
| 10 |
+
POST /v1/spatial/analyze_file — {path, mode, az, el, ...}
|
| 11 |
+
POST /v1/spatial/demo_scene — synthetic multi-source report
|
| 12 |
+
POST /v1/spatial/vision — Phase 3: boxes/rays → spatial report
|
| 13 |
+
POST /v1/spatial/fuse — merge audio + vision report dicts
|
| 14 |
+
|
| 15 |
+
OpenAI tools (for agents with bash): also install ``spatial-report`` on PATH.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import traceback
|
| 22 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any
|
| 25 |
+
from urllib.parse import urlparse
|
| 26 |
+
|
| 27 |
+
from . import __version__
|
| 28 |
+
from .report import (
|
| 29 |
+
REPORT_SCHEMA_VERSION,
|
| 30 |
+
report_from_ambix_wav,
|
| 31 |
+
report_from_hoa,
|
| 32 |
+
report_from_mono_wav,
|
| 33 |
+
report_from_scene,
|
| 34 |
+
)
|
| 35 |
+
from .stream import SourceSpec
|
| 36 |
+
from .synth import envelope_adsr, tone
|
| 37 |
+
|
| 38 |
+
DEFAULT_HOST = "127.0.0.1"
|
| 39 |
+
DEFAULT_PORT = 8765
|
| 40 |
+
|
| 41 |
+
TOOL_SCHEMA = {
|
| 42 |
+
"type": "function",
|
| 43 |
+
"function": {
|
| 44 |
+
"name": "spatial_analyze",
|
| 45 |
+
"description": (
|
| 46 |
+
"Analyze spatial audio (Ambix HOA or mono plane-wave) or visual "
|
| 47 |
+
"bounding boxes on the sphere. Returns a compact spatial report "
|
| 48 |
+
"(DOA, bands, frames) from the HOA-7 calculator — not an LLM."
|
| 49 |
+
),
|
| 50 |
+
"parameters": {
|
| 51 |
+
"type": "object",
|
| 52 |
+
"properties": {
|
| 53 |
+
"mode": {
|
| 54 |
+
"type": "string",
|
| 55 |
+
"enum": ["ambix_file", "mono_file", "demo_scene", "vision", "fuse"],
|
| 56 |
+
"description": "Analysis mode",
|
| 57 |
+
},
|
| 58 |
+
"path": {
|
| 59 |
+
"type": "string",
|
| 60 |
+
"description": "WAV path for ambix_file or mono_file",
|
| 61 |
+
},
|
| 62 |
+
"az": {
|
| 63 |
+
"type": "number",
|
| 64 |
+
"description": "Azimuth degrees for mono plane-wave encode (0=front, +90=left)",
|
| 65 |
+
},
|
| 66 |
+
"el": {
|
| 67 |
+
"type": "number",
|
| 68 |
+
"description": "Elevation degrees (0=horizon, +90=zenith)",
|
| 69 |
+
},
|
| 70 |
+
"order": {
|
| 71 |
+
"type": "integer",
|
| 72 |
+
"description": "Max HOA order 0..7 (default 3 for speed, 7 full)",
|
| 73 |
+
"default": 3,
|
| 74 |
+
},
|
| 75 |
+
"boxes": {
|
| 76 |
+
"type": "array",
|
| 77 |
+
"description": "Vision boxes: [{az, el, w_deg?, h_deg?, weight?, label?}]",
|
| 78 |
+
"items": {"type": "object"},
|
| 79 |
+
},
|
| 80 |
+
"audio_report": {
|
| 81 |
+
"type": "object",
|
| 82 |
+
"description": "Existing audio SpatialReport dict for fuse mode",
|
| 83 |
+
},
|
| 84 |
+
"vision_report": {
|
| 85 |
+
"type": "object",
|
| 86 |
+
"description": "Existing vision SpatialReport dict for fuse mode",
|
| 87 |
+
},
|
| 88 |
+
},
|
| 89 |
+
"required": ["mode"],
|
| 90 |
+
},
|
| 91 |
+
},
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _json_response(handler: BaseHTTPRequestHandler, code: int, obj: Any) -> None:
|
| 96 |
+
body = json.dumps(obj, indent=None).encode("utf-8")
|
| 97 |
+
handler.send_response(code)
|
| 98 |
+
handler.send_header("Content-Type", "application/json")
|
| 99 |
+
handler.send_header("Content-Length", str(len(body)))
|
| 100 |
+
handler.send_header("Access-Control-Allow-Origin", "*")
|
| 101 |
+
handler.end_headers()
|
| 102 |
+
handler.wfile.write(body)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _read_json(handler: BaseHTTPRequestHandler) -> dict:
|
| 106 |
+
n = int(handler.headers.get("Content-Length", "0"))
|
| 107 |
+
raw = handler.rfile.read(n) if n else b"{}"
|
| 108 |
+
if not raw:
|
| 109 |
+
return {}
|
| 110 |
+
return json.loads(raw.decode("utf-8"))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def handle_analyze(body: dict) -> dict:
|
| 114 |
+
mode = body.get("mode", "demo_scene")
|
| 115 |
+
order = int(body.get("order", 3))
|
| 116 |
+
order = max(0, min(7, order))
|
| 117 |
+
|
| 118 |
+
if mode == "demo_scene":
|
| 119 |
+
sr = int(body.get("sample_rate", 48000))
|
| 120 |
+
dur = float(body.get("duration", 0.4))
|
| 121 |
+
n = int(sr * dur)
|
| 122 |
+
env = envelope_adsr(n, sr)
|
| 123 |
+
sources = [
|
| 124 |
+
SourceSpec(0.0, 0.0, tone(440, dur, sr, amplitude=0.4) * env, "front"),
|
| 125 |
+
SourceSpec(90.0, 10.0, tone(660, dur, sr, amplitude=0.25) * env, "left"),
|
| 126 |
+
]
|
| 127 |
+
rep = report_from_scene(sources, sr, max_order=order)
|
| 128 |
+
d = rep.to_dict()
|
| 129 |
+
d["one_liner"] = rep.one_liner()
|
| 130 |
+
return d
|
| 131 |
+
|
| 132 |
+
if mode == "ambix_file":
|
| 133 |
+
path = body.get("path") or body.get("file")
|
| 134 |
+
if not path:
|
| 135 |
+
raise ValueError("path required for ambix_file")
|
| 136 |
+
rep = report_from_ambix_wav(path, max_order=order)
|
| 137 |
+
d = rep.to_dict()
|
| 138 |
+
d["one_liner"] = rep.one_liner()
|
| 139 |
+
return d
|
| 140 |
+
|
| 141 |
+
if mode == "mono_file":
|
| 142 |
+
path = body.get("path") or body.get("file")
|
| 143 |
+
if not path:
|
| 144 |
+
raise ValueError("path required for mono_file")
|
| 145 |
+
if body.get("az") is None:
|
| 146 |
+
raise ValueError("az required for mono_file plane-wave encode")
|
| 147 |
+
rep = report_from_mono_wav(
|
| 148 |
+
path,
|
| 149 |
+
float(body["az"]),
|
| 150 |
+
float(body.get("el", 0.0)),
|
| 151 |
+
max_order=order,
|
| 152 |
+
)
|
| 153 |
+
d = rep.to_dict()
|
| 154 |
+
d["one_liner"] = rep.one_liner()
|
| 155 |
+
return d
|
| 156 |
+
|
| 157 |
+
if mode == "vision":
|
| 158 |
+
from .vision import report_from_boxes
|
| 159 |
+
|
| 160 |
+
boxes = body.get("boxes") or []
|
| 161 |
+
rep = report_from_boxes(boxes, max_order=order)
|
| 162 |
+
d = rep.to_dict()
|
| 163 |
+
d["one_liner"] = rep.one_liner()
|
| 164 |
+
return d
|
| 165 |
+
|
| 166 |
+
if mode == "fuse":
|
| 167 |
+
from .vision import fuse_reports
|
| 168 |
+
|
| 169 |
+
ar = body.get("audio_report") or {}
|
| 170 |
+
vr = body.get("vision_report") or {}
|
| 171 |
+
return fuse_reports(ar, vr)
|
| 172 |
+
|
| 173 |
+
if mode == "detect":
|
| 174 |
+
from .detector import detect_to_sphere, load_boxes_json
|
| 175 |
+
from .vision import report_from_boxes
|
| 176 |
+
|
| 177 |
+
if body.get("boxes_path"):
|
| 178 |
+
boxes = load_boxes_json(body["boxes_path"])
|
| 179 |
+
elif body.get("image"):
|
| 180 |
+
boxes = detect_to_sphere(
|
| 181 |
+
body["image"],
|
| 182 |
+
backend=body.get("backend", "auto"),
|
| 183 |
+
score_thresh=float(body.get("score", 0.5)),
|
| 184 |
+
hfov_deg=float(body.get("hfov", 90)),
|
| 185 |
+
vfov_deg=float(body.get("vfov", 60)),
|
| 186 |
+
)
|
| 187 |
+
else:
|
| 188 |
+
boxes = body.get("boxes") or []
|
| 189 |
+
rep = report_from_boxes(boxes, max_order=order)
|
| 190 |
+
d = rep.to_dict()
|
| 191 |
+
d["one_liner"] = rep.one_liner()
|
| 192 |
+
d["boxes"] = boxes
|
| 193 |
+
return d
|
| 194 |
+
|
| 195 |
+
if mode == "live":
|
| 196 |
+
from .live_audio import live_report
|
| 197 |
+
|
| 198 |
+
rep = live_report(
|
| 199 |
+
duration_sec=float(body.get("duration", 2.0)),
|
| 200 |
+
sample_rate=int(body.get("sample_rate", 48000)),
|
| 201 |
+
channels=int(body.get("channels", 1)),
|
| 202 |
+
source=body.get("source"),
|
| 203 |
+
az_deg=float(body.get("az", 0.0)),
|
| 204 |
+
el_deg=float(body.get("el", 0.0)),
|
| 205 |
+
max_order=order,
|
| 206 |
+
keep_wav=body.get("write_wav"),
|
| 207 |
+
)
|
| 208 |
+
d = rep.to_dict()
|
| 209 |
+
d["one_liner"] = rep.one_liner()
|
| 210 |
+
return d
|
| 211 |
+
|
| 212 |
+
if mode == "panner":
|
| 213 |
+
# UI spherical panner: az/el + W amplitude → spatial report (+ optional condition)
|
| 214 |
+
from .conditioning import build_conditioning, panner_report
|
| 215 |
+
|
| 216 |
+
rep = panner_report(
|
| 217 |
+
float(body.get("az", body.get("az_deg", 0.0))),
|
| 218 |
+
float(body.get("el", body.get("el_deg", 0.0))),
|
| 219 |
+
float(body.get("w", body.get("w_amplitude", body.get("energy", 0.5)))),
|
| 220 |
+
)
|
| 221 |
+
if body.get("condition") or body.get("prompt"):
|
| 222 |
+
return build_conditioning(
|
| 223 |
+
rep,
|
| 224 |
+
base_prompt=str(body.get("prompt", "")),
|
| 225 |
+
style=str(body.get("style", "natural")),
|
| 226 |
+
)
|
| 227 |
+
return rep
|
| 228 |
+
|
| 229 |
+
if mode == "condition":
|
| 230 |
+
from .conditioning import build_conditioning, panner_report
|
| 231 |
+
|
| 232 |
+
rep = body.get("report")
|
| 233 |
+
if rep is None and (
|
| 234 |
+
"az" in body or "az_deg" in body or "w" in body or "w_amplitude" in body
|
| 235 |
+
):
|
| 236 |
+
# Convenience: condition directly from panner knobs
|
| 237 |
+
rep = panner_report(
|
| 238 |
+
float(body.get("az", body.get("az_deg", 0.0))),
|
| 239 |
+
float(body.get("el", body.get("el_deg", 0.0))),
|
| 240 |
+
float(body.get("w", body.get("w_amplitude", body.get("energy", 0.5)))),
|
| 241 |
+
)
|
| 242 |
+
else:
|
| 243 |
+
rep = rep or body
|
| 244 |
+
return build_conditioning(
|
| 245 |
+
rep,
|
| 246 |
+
base_prompt=str(body.get("prompt", "")),
|
| 247 |
+
style=str(body.get("style", "natural")),
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
if mode == "hoa_vector":
|
| 251 |
+
# raw coefficients list
|
| 252 |
+
import numpy as np
|
| 253 |
+
|
| 254 |
+
coeffs = body.get("hoa") or body.get("coefficients")
|
| 255 |
+
if coeffs is None:
|
| 256 |
+
raise ValueError("hoa coefficients required")
|
| 257 |
+
sr = int(body.get("sample_rate", 48000))
|
| 258 |
+
a = np.asarray(coeffs, dtype=np.float64)
|
| 259 |
+
if a.ndim == 1:
|
| 260 |
+
a = a.reshape(-1, 1)
|
| 261 |
+
rep = report_from_hoa(a, sr, max_order=order)
|
| 262 |
+
d = rep.to_dict()
|
| 263 |
+
d["one_liner"] = rep.one_liner()
|
| 264 |
+
return d
|
| 265 |
+
|
| 266 |
+
raise ValueError(f"unknown mode: {mode}")
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class SpatialHandler(BaseHTTPRequestHandler):
|
| 270 |
+
server_version = f"spatial-hoa/{__version__}"
|
| 271 |
+
|
| 272 |
+
def log_message(self, fmt: str, *args) -> None:
|
| 273 |
+
# quieter default
|
| 274 |
+
sys_stderr = __import__("sys").stderr
|
| 275 |
+
sys_stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
| 276 |
+
|
| 277 |
+
def do_OPTIONS(self) -> None:
|
| 278 |
+
self.send_response(204)
|
| 279 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 280 |
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
| 281 |
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
| 282 |
+
self.end_headers()
|
| 283 |
+
|
| 284 |
+
def do_GET(self) -> None:
|
| 285 |
+
path = urlparse(self.path).path
|
| 286 |
+
if path in ("/health", "/v1/health"):
|
| 287 |
+
_json_response(
|
| 288 |
+
self,
|
| 289 |
+
200,
|
| 290 |
+
{
|
| 291 |
+
"status": "ok",
|
| 292 |
+
"service": "spatial-hoa",
|
| 293 |
+
"version": __version__,
|
| 294 |
+
"schema": REPORT_SCHEMA_VERSION,
|
| 295 |
+
},
|
| 296 |
+
)
|
| 297 |
+
return
|
| 298 |
+
if path in ("/v1/spatial/schema", "/v1/tools"):
|
| 299 |
+
_json_response(
|
| 300 |
+
self,
|
| 301 |
+
200,
|
| 302 |
+
{
|
| 303 |
+
"tools": [TOOL_SCHEMA],
|
| 304 |
+
"report_schema": REPORT_SCHEMA_VERSION,
|
| 305 |
+
},
|
| 306 |
+
)
|
| 307 |
+
return
|
| 308 |
+
_json_response(self, 404, {"error": "not found", "path": path})
|
| 309 |
+
|
| 310 |
+
def do_POST(self) -> None:
|
| 311 |
+
path = urlparse(self.path).path
|
| 312 |
+
try:
|
| 313 |
+
body = _read_json(self)
|
| 314 |
+
except Exception as e:
|
| 315 |
+
_json_response(self, 400, {"error": f"invalid json: {e}"})
|
| 316 |
+
return
|
| 317 |
+
try:
|
| 318 |
+
if path in (
|
| 319 |
+
"/v1/spatial/analyze",
|
| 320 |
+
"/v1/spatial/analyze_file",
|
| 321 |
+
"/v1/spatial/demo_scene",
|
| 322 |
+
"/v1/spatial/vision",
|
| 323 |
+
"/v1/spatial/fuse",
|
| 324 |
+
):
|
| 325 |
+
# map path to mode if not set
|
| 326 |
+
if path.endswith("demo_scene") and "mode" not in body:
|
| 327 |
+
body["mode"] = "demo_scene"
|
| 328 |
+
elif path.endswith("vision") and "mode" not in body:
|
| 329 |
+
body["mode"] = "vision"
|
| 330 |
+
elif path.endswith("fuse") and "mode" not in body:
|
| 331 |
+
body["mode"] = "fuse"
|
| 332 |
+
elif path.endswith("analyze_file") and "mode" not in body:
|
| 333 |
+
body["mode"] = "ambix_file" if body.get("ambix") else "mono_file"
|
| 334 |
+
elif "mode" not in body:
|
| 335 |
+
body["mode"] = "demo_scene"
|
| 336 |
+
result = handle_analyze(body)
|
| 337 |
+
_json_response(self, 200, result)
|
| 338 |
+
return
|
| 339 |
+
_json_response(self, 404, {"error": "not found", "path": path})
|
| 340 |
+
except Exception as e:
|
| 341 |
+
_json_response(
|
| 342 |
+
self,
|
| 343 |
+
400,
|
| 344 |
+
{"error": str(e), "trace": traceback.format_exc()[-800:]},
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
| 349 |
+
httpd = ThreadingHTTPServer((host, port), SpatialHandler)
|
| 350 |
+
print(f"spatial-hoa API http://{host}:{port} (hoa64 {__version__})")
|
| 351 |
+
print(" GET /health")
|
| 352 |
+
print(" GET /v1/spatial/schema")
|
| 353 |
+
print(" POST /v1/spatial/analyze")
|
| 354 |
+
httpd.serve_forever()
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def main(argv: list[str] | None = None) -> int:
|
| 358 |
+
import argparse
|
| 359 |
+
|
| 360 |
+
p = argparse.ArgumentParser(description="spatial-hoa HTTP API for Qwythos/agents")
|
| 361 |
+
p.add_argument("--host", default=DEFAULT_HOST)
|
| 362 |
+
p.add_argument("--port", type=int, default=DEFAULT_PORT)
|
| 363 |
+
args = p.parse_args(argv)
|
| 364 |
+
serve(args.host, args.port)
|
| 365 |
+
return 0
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
raise SystemExit(main())
|
hoa64/stft.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal STFT / ISTFT in pure NumPy (no scipy)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def hann_window(n: int) -> np.ndarray:
|
| 9 |
+
if n <= 1:
|
| 10 |
+
return np.ones(n, dtype=np.float64)
|
| 11 |
+
return 0.5 - 0.5 * np.cos(2.0 * np.pi * np.arange(n, dtype=np.float64) / n)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def stft(
|
| 15 |
+
x: np.ndarray,
|
| 16 |
+
*,
|
| 17 |
+
n_fft: int = 1024,
|
| 18 |
+
hop: int = 256,
|
| 19 |
+
window: np.ndarray | None = None,
|
| 20 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 21 |
+
"""Short-time FFT.
|
| 22 |
+
|
| 23 |
+
Parameters
|
| 24 |
+
----------
|
| 25 |
+
x : (n_samples,) real
|
| 26 |
+
Returns
|
| 27 |
+
-------
|
| 28 |
+
freqs_bins : (n_fft//2+1,) (normalized later by caller with sr)
|
| 29 |
+
S : complex64 (n_bins, n_frames)
|
| 30 |
+
"""
|
| 31 |
+
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
| 32 |
+
if window is None:
|
| 33 |
+
window = hann_window(n_fft)
|
| 34 |
+
window = np.asarray(window, dtype=np.float64)
|
| 35 |
+
if window.shape[0] != n_fft:
|
| 36 |
+
raise ValueError("window length must equal n_fft")
|
| 37 |
+
|
| 38 |
+
if x.shape[0] < n_fft:
|
| 39 |
+
x = np.pad(x, (0, n_fft - x.shape[0]))
|
| 40 |
+
|
| 41 |
+
n_frames = 1 + (x.shape[0] - n_fft) // hop
|
| 42 |
+
n_bins = n_fft // 2 + 1
|
| 43 |
+
S = np.empty((n_bins, n_frames), dtype=np.complex128)
|
| 44 |
+
for i in range(n_frames):
|
| 45 |
+
start = i * hop
|
| 46 |
+
frame = x[start : start + n_fft] * window
|
| 47 |
+
spec = np.fft.rfft(frame, n=n_fft)
|
| 48 |
+
S[:, i] = spec
|
| 49 |
+
return S
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def stft_freqs(n_fft: int, sample_rate: int) -> np.ndarray:
|
| 53 |
+
return np.fft.rfftfreq(n_fft, d=1.0 / sample_rate)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def frame_signal(
|
| 57 |
+
x: np.ndarray,
|
| 58 |
+
*,
|
| 59 |
+
frame_len: int,
|
| 60 |
+
hop: int,
|
| 61 |
+
window: np.ndarray | None = None,
|
| 62 |
+
) -> np.ndarray:
|
| 63 |
+
"""Slice a 1-D signal into overlapping frames (n_frames, frame_len)."""
|
| 64 |
+
x = np.asarray(x, dtype=np.float64).reshape(-1)
|
| 65 |
+
if window is None:
|
| 66 |
+
window = hann_window(frame_len)
|
| 67 |
+
if x.shape[0] < frame_len:
|
| 68 |
+
x = np.pad(x, (0, frame_len - x.shape[0]))
|
| 69 |
+
n_frames = 1 + (x.shape[0] - frame_len) // hop
|
| 70 |
+
out = np.empty((n_frames, frame_len), dtype=np.float64)
|
| 71 |
+
for i in range(n_frames):
|
| 72 |
+
start = i * hop
|
| 73 |
+
out[i] = x[start : start + frame_len] * window
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def frame_multichannel(
|
| 78 |
+
audio: np.ndarray,
|
| 79 |
+
*,
|
| 80 |
+
frame_len: int,
|
| 81 |
+
hop: int,
|
| 82 |
+
) -> np.ndarray:
|
| 83 |
+
"""Frame multi-channel audio (C, T) → (n_frames, C, frame_len) rectangular (no window)."""
|
| 84 |
+
a = np.asarray(audio, dtype=np.float64)
|
| 85 |
+
if a.ndim != 2:
|
| 86 |
+
raise ValueError("audio must be (C,T)")
|
| 87 |
+
C, T = a.shape
|
| 88 |
+
if T < frame_len:
|
| 89 |
+
a = np.pad(a, ((0, 0), (0, frame_len - T)))
|
| 90 |
+
T = a.shape[1]
|
| 91 |
+
n_frames = 1 + (T - frame_len) // hop
|
| 92 |
+
out = np.empty((n_frames, C, frame_len), dtype=np.float64)
|
| 93 |
+
for i in range(n_frames):
|
| 94 |
+
start = i * hop
|
| 95 |
+
out[i] = a[:, start : start + frame_len]
|
| 96 |
+
return out
|
hoa64/stream.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Time-domain HOA streams: encode mono sources, frame-wise analysis."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Sequence
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from .analysis import (
|
| 11 |
+
angular_error_deg,
|
| 12 |
+
doa_from_intensity,
|
| 13 |
+
field_energy,
|
| 14 |
+
peak_direction,
|
| 15 |
+
)
|
| 16 |
+
from .audio_io import ensure_hoa_channels
|
| 17 |
+
from .basis import MAX_ORDER, N_CHANNELS, sh_sn3d
|
| 18 |
+
from .encode import encode_plane_waves, mix
|
| 19 |
+
from .stft import frame_multichannel, hann_window, stft, stft_freqs
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class SourceSpec:
|
| 24 |
+
"""One plane-wave source in a synthetic or annotated scene."""
|
| 25 |
+
|
| 26 |
+
azimuth_deg: float
|
| 27 |
+
elevation_deg: float
|
| 28 |
+
signal: np.ndarray # (n_samples,)
|
| 29 |
+
label: str = ""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def encode_mono_plane_wave(
|
| 33 |
+
signal: np.ndarray,
|
| 34 |
+
azimuth_deg: float,
|
| 35 |
+
elevation_deg: float = 0.0,
|
| 36 |
+
*,
|
| 37 |
+
max_order: int = MAX_ORDER,
|
| 38 |
+
) -> np.ndarray:
|
| 39 |
+
"""Mono signal from a known direction → Ambix HOA stream (C, T)."""
|
| 40 |
+
sig = np.asarray(signal, dtype=np.float64).reshape(1, -1)
|
| 41 |
+
return encode_plane_waves(
|
| 42 |
+
[azimuth_deg],
|
| 43 |
+
[elevation_deg],
|
| 44 |
+
sig,
|
| 45 |
+
degrees=True,
|
| 46 |
+
max_order=max_order,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def encode_scene(
|
| 51 |
+
sources: Sequence[SourceSpec],
|
| 52 |
+
*,
|
| 53 |
+
max_order: int = MAX_ORDER,
|
| 54 |
+
) -> np.ndarray:
|
| 55 |
+
"""Superpose multiple plane-wave sources into one HOA stream (C, T)."""
|
| 56 |
+
if not sources:
|
| 57 |
+
raise ValueError("sources must be non-empty")
|
| 58 |
+
lengths = [int(np.asarray(s.signal).reshape(-1).shape[0]) for s in sources]
|
| 59 |
+
T = max(lengths)
|
| 60 |
+
fields = []
|
| 61 |
+
for s in sources:
|
| 62 |
+
sig = np.asarray(s.signal, dtype=np.float64).reshape(-1)
|
| 63 |
+
if sig.shape[0] < T:
|
| 64 |
+
sig = np.pad(sig, (0, T - sig.shape[0]))
|
| 65 |
+
elif sig.shape[0] > T:
|
| 66 |
+
sig = sig[:T]
|
| 67 |
+
fields.append(
|
| 68 |
+
encode_mono_plane_wave(
|
| 69 |
+
sig, s.azimuth_deg, s.elevation_deg, max_order=max_order
|
| 70 |
+
)
|
| 71 |
+
)
|
| 72 |
+
out = fields[0]
|
| 73 |
+
for f in fields[1:]:
|
| 74 |
+
out = out + f
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def hoa_rms(hoa: np.ndarray) -> np.ndarray:
|
| 79 |
+
"""Per-channel RMS. hoa (C,T) → (C,)."""
|
| 80 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 81 |
+
return np.sqrt(np.mean(a * a, axis=-1) + 1e-30)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class FrameAnalysis:
|
| 86 |
+
t_center_sec: float
|
| 87 |
+
energy: float
|
| 88 |
+
doa_az_deg: float
|
| 89 |
+
doa_el_deg: float
|
| 90 |
+
peak_az_deg: float
|
| 91 |
+
peak_el_deg: float
|
| 92 |
+
peak_power: float
|
| 93 |
+
order1_energy: float
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def analyze_hoa_frames(
|
| 97 |
+
hoa: np.ndarray,
|
| 98 |
+
sample_rate: int,
|
| 99 |
+
*,
|
| 100 |
+
frame_ms: float = 40.0,
|
| 101 |
+
hop_ms: float = 20.0,
|
| 102 |
+
max_order: int = MAX_ORDER,
|
| 103 |
+
peak_grid: bool = False,
|
| 104 |
+
) -> list[FrameAnalysis]:
|
| 105 |
+
"""Short-time spatial analysis of an HOA stream.
|
| 106 |
+
|
| 107 |
+
Uses rectangular frames; DOA from order-1 intensity on frame-averaged
|
| 108 |
+
(or energy-weighted) coefficients. Optional dense peak per frame is slower.
|
| 109 |
+
"""
|
| 110 |
+
a = ensure_hoa_channels(hoa, max_order=max_order)
|
| 111 |
+
frame_len = max(1, int(round(sample_rate * frame_ms / 1000.0)))
|
| 112 |
+
hop = max(1, int(round(sample_rate * hop_ms / 1000.0)))
|
| 113 |
+
frames = frame_multichannel(a, frame_len=frame_len, hop=hop)
|
| 114 |
+
# Energy-weighted mean coefficient per frame: sum_t a[c,t]*|a_w| style —
|
| 115 |
+
# use simple mean of coeffs (works for quasi-stationary plane waves).
|
| 116 |
+
win = hann_window(frame_len)
|
| 117 |
+
win = win / (np.sum(win) + 1e-30)
|
| 118 |
+
|
| 119 |
+
out: list[FrameAnalysis] = []
|
| 120 |
+
nch_o1 = 4
|
| 121 |
+
for i in range(frames.shape[0]):
|
| 122 |
+
block = frames[i] # (C, L)
|
| 123 |
+
# AC-safe: do NOT average coeffs (→0 for audio). Use intensity products.
|
| 124 |
+
w = win # (L,)
|
| 125 |
+
# weighted instantaneous intensity ~ W*X etc.
|
| 126 |
+
W = block[0] * w
|
| 127 |
+
Yc = block[1] * w
|
| 128 |
+
Zc = block[2] * w
|
| 129 |
+
Xc = block[3] * w
|
| 130 |
+
I = np.array(
|
| 131 |
+
[
|
| 132 |
+
float(np.sum(W * Xc)),
|
| 133 |
+
float(np.sum(W * Yc)),
|
| 134 |
+
float(np.sum(W * Zc)),
|
| 135 |
+
],
|
| 136 |
+
dtype=np.float64,
|
| 137 |
+
)
|
| 138 |
+
nrm = float(np.linalg.norm(I))
|
| 139 |
+
if nrm < 1e-18:
|
| 140 |
+
az, el = 0.0, 0.0
|
| 141 |
+
else:
|
| 142 |
+
from .basis import az_el_from_unit
|
| 143 |
+
|
| 144 |
+
az, el = az_el_from_unit(I / nrm, degrees=True)
|
| 145 |
+
az, el = float(az), float(el)
|
| 146 |
+
|
| 147 |
+
# RMS energy of the frame
|
| 148 |
+
energy = float(np.mean(np.sum(block * block, axis=0)))
|
| 149 |
+
o1e = float(np.mean(np.sum(block[:nch_o1] ** 2, axis=0)))
|
| 150 |
+
|
| 151 |
+
if peak_grid:
|
| 152 |
+
# Build a pseudo-static vector: sign-stable energy-weighted mean
|
| 153 |
+
# via sqrt of mean squares * sign of correlation with W
|
| 154 |
+
rms = np.sqrt(np.mean(block * block, axis=1) + 1e-30)
|
| 155 |
+
sign = np.sign(np.mean(block * block[0:1, :], axis=1) + 1e-30)
|
| 156 |
+
pseudo = rms * sign
|
| 157 |
+
paz, pel, pv = peak_direction(
|
| 158 |
+
pseudo, n_azi=48, n_el=24, max_order=min(max_order, 3)
|
| 159 |
+
)
|
| 160 |
+
else:
|
| 161 |
+
paz, pel, pv = float(az), float(el), energy
|
| 162 |
+
t_center = (i * hop + 0.5 * frame_len) / float(sample_rate)
|
| 163 |
+
out.append(
|
| 164 |
+
FrameAnalysis(
|
| 165 |
+
t_center_sec=t_center,
|
| 166 |
+
energy=energy,
|
| 167 |
+
doa_az_deg=float(az),
|
| 168 |
+
doa_el_deg=float(el),
|
| 169 |
+
peak_az_deg=float(paz),
|
| 170 |
+
peak_el_deg=float(pel),
|
| 171 |
+
peak_power=float(pv),
|
| 172 |
+
order1_energy=o1e,
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
return out
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def analyze_hoa_stft_bands(
|
| 179 |
+
hoa: np.ndarray,
|
| 180 |
+
sample_rate: int,
|
| 181 |
+
*,
|
| 182 |
+
n_fft: int = 1024,
|
| 183 |
+
hop: int = 256,
|
| 184 |
+
band_edges_hz: Sequence[float] | None = None,
|
| 185 |
+
) -> list[dict]:
|
| 186 |
+
"""Per-frequency-band intensity DOA using order-1 HOA channels only.
|
| 187 |
+
|
| 188 |
+
Returns list of {band_hz: [lo,hi], doa_az, doa_el, energy}.
|
| 189 |
+
"""
|
| 190 |
+
a = ensure_hoa_channels(hoa, max_order=1)
|
| 191 |
+
if band_edges_hz is None:
|
| 192 |
+
band_edges_hz = [0, 250, 500, 1000, 2000, 4000, 8000, sample_rate / 2]
|
| 193 |
+
|
| 194 |
+
# STFT of W,Y,Z,X
|
| 195 |
+
specs = []
|
| 196 |
+
for c in range(4):
|
| 197 |
+
S = stft(a[c], n_fft=n_fft, hop=hop)
|
| 198 |
+
specs.append(S)
|
| 199 |
+
freqs = stft_freqs(n_fft, sample_rate)
|
| 200 |
+
# Time-average power-weighted intensity per bin then fold into bands
|
| 201 |
+
W, Y, Z, X = specs
|
| 202 |
+
# Use complex conjugate product for active intensity-like measure
|
| 203 |
+
# I_x ~ Re(W * conj(X)), etc., averaged over frames
|
| 204 |
+
Ix = np.mean(np.real(W * np.conj(X)), axis=1)
|
| 205 |
+
Iy = np.mean(np.real(W * np.conj(Y)), axis=1)
|
| 206 |
+
Iz = np.mean(np.real(W * np.conj(Z)), axis=1)
|
| 207 |
+
Ew = np.mean(np.abs(W) ** 2, axis=1)
|
| 208 |
+
|
| 209 |
+
edges = list(band_edges_hz)
|
| 210 |
+
reports = []
|
| 211 |
+
for lo, hi in zip(edges[:-1], edges[1:]):
|
| 212 |
+
mask = (freqs >= lo) & (freqs < hi)
|
| 213 |
+
if not np.any(mask):
|
| 214 |
+
continue
|
| 215 |
+
I = np.array(
|
| 216 |
+
[np.sum(Ix[mask]), np.sum(Iy[mask]), np.sum(Iz[mask])],
|
| 217 |
+
dtype=np.float64,
|
| 218 |
+
)
|
| 219 |
+
n = np.linalg.norm(I)
|
| 220 |
+
if n < 1e-15:
|
| 221 |
+
az, el = 0.0, 0.0
|
| 222 |
+
else:
|
| 223 |
+
from .basis import az_el_from_unit
|
| 224 |
+
|
| 225 |
+
az, el = az_el_from_unit(I / n, degrees=True)
|
| 226 |
+
az, el = float(az), float(el)
|
| 227 |
+
reports.append(
|
| 228 |
+
{
|
| 229 |
+
"band_hz": [float(lo), float(hi)],
|
| 230 |
+
"doa_az_deg": az,
|
| 231 |
+
"doa_el_deg": el,
|
| 232 |
+
"energy": float(np.sum(Ew[mask])),
|
| 233 |
+
}
|
| 234 |
+
)
|
| 235 |
+
return reports
|
hoa64/synth.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic test signals for Phase 1 audio pipeline."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def tone(
|
| 9 |
+
freq_hz: float,
|
| 10 |
+
duration_sec: float,
|
| 11 |
+
sample_rate: int,
|
| 12 |
+
*,
|
| 13 |
+
amplitude: float = 0.5,
|
| 14 |
+
phase: float = 0.0,
|
| 15 |
+
) -> np.ndarray:
|
| 16 |
+
t = np.arange(int(round(duration_sec * sample_rate)), dtype=np.float64) / sample_rate
|
| 17 |
+
return amplitude * np.sin(2.0 * np.pi * freq_hz * t + phase)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def noise(
|
| 21 |
+
duration_sec: float,
|
| 22 |
+
sample_rate: int,
|
| 23 |
+
*,
|
| 24 |
+
amplitude: float = 0.2,
|
| 25 |
+
seed: int = 0,
|
| 26 |
+
) -> np.ndarray:
|
| 27 |
+
rng = np.random.default_rng(seed)
|
| 28 |
+
n = int(round(duration_sec * sample_rate))
|
| 29 |
+
return amplitude * rng.standard_normal(n)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def envelope_adsr(
|
| 33 |
+
n: int,
|
| 34 |
+
sample_rate: int,
|
| 35 |
+
*,
|
| 36 |
+
attack: float = 0.01,
|
| 37 |
+
release: float = 0.05,
|
| 38 |
+
) -> np.ndarray:
|
| 39 |
+
env = np.ones(n, dtype=np.float64)
|
| 40 |
+
a = min(n, int(attack * sample_rate))
|
| 41 |
+
r = min(n, int(release * sample_rate))
|
| 42 |
+
if a > 0:
|
| 43 |
+
env[:a] = np.linspace(0.0, 1.0, a)
|
| 44 |
+
if r > 0:
|
| 45 |
+
env[-r:] = np.linspace(1.0, 0.0, r)
|
| 46 |
+
return env
|
hoa64/vision.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3 — vision tower: project boxes/rays onto the HOA-7 sphere.
|
| 2 |
+
|
| 3 |
+
No neural net. Detections become soft spherical Gaussians in Ambix SN3D
|
| 4 |
+
space (same 64-D geometry as audio). Suitable as conditioning / tool output
|
| 5 |
+
for language models and later diffusion control.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from typing import Any, Iterable, Mapping, Optional, Sequence
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from .basis import MAX_ORDER, N_CHANNELS, sh_sn3d, unit_vector
|
| 16 |
+
from .encode import mix
|
| 17 |
+
from .report import SpatialReport, report_from_hoa
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _as_box(b: Mapping[str, Any]) -> dict:
|
| 21 |
+
"""Normalize a box/ray dict."""
|
| 22 |
+
if "az" in b or "azimuth" in b or "azimuth_deg" in b:
|
| 23 |
+
az = float(b.get("az", b.get("azimuth", b.get("azimuth_deg", 0.0))))
|
| 24 |
+
el = float(b.get("el", b.get("elevation", b.get("elevation_deg", 0.0))))
|
| 25 |
+
elif "x" in b and "y" in b:
|
| 26 |
+
# normalized image coords: x in [0,1] left→right, y in [0,1] top→bottom
|
| 27 |
+
# map to az in [-180,180] around center, el in [-90,90]
|
| 28 |
+
x = float(b["x"])
|
| 29 |
+
y = float(b["y"])
|
| 30 |
+
# simple equirectangular pinhole-ish: center (0.5,0.5) = front
|
| 31 |
+
# az: left of image = +az (left), right = -az if we want Ambix left=+Y
|
| 32 |
+
# Convention: image x=0 left → az=+hfov/2, x=1 right → az=-hfov/2
|
| 33 |
+
hfov = float(b.get("hfov_deg", 90.0))
|
| 34 |
+
vfov = float(b.get("vfov_deg", 60.0))
|
| 35 |
+
az = (0.5 - x) * hfov
|
| 36 |
+
el = (0.5 - y) * vfov
|
| 37 |
+
else:
|
| 38 |
+
raise ValueError(f"box needs az/el or x/y: {b}")
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
"az": az,
|
| 42 |
+
"el": el,
|
| 43 |
+
"w_deg": float(b.get("w_deg", b.get("width_deg", b.get("sigma_deg", 8.0)))),
|
| 44 |
+
"h_deg": float(b.get("h_deg", b.get("height_deg", b.get("sigma_deg", 8.0)))),
|
| 45 |
+
"weight": float(b.get("weight", b.get("score", b.get("confidence", 1.0)))),
|
| 46 |
+
"label": str(b.get("label", b.get("class", b.get("name", "")))),
|
| 47 |
+
"kind": str(b.get("kind", "box")), # box | ray | point
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def angular_gaussian_weights(
|
| 52 |
+
center_az: float,
|
| 53 |
+
center_el: float,
|
| 54 |
+
sample_az: np.ndarray,
|
| 55 |
+
sample_el: np.ndarray,
|
| 56 |
+
sigma_az_deg: float,
|
| 57 |
+
sigma_el_deg: float,
|
| 58 |
+
) -> np.ndarray:
|
| 59 |
+
"""Soft lobe on the sphere (product of wrapped azimuth + elevation Gaussians)."""
|
| 60 |
+
# great-circle-ish separation using unit vectors is better
|
| 61 |
+
c = unit_vector(center_az, center_el, degrees=True)
|
| 62 |
+
# sample_az, sample_el may be mesh
|
| 63 |
+
u = unit_vector(sample_az, sample_el, degrees=True)
|
| 64 |
+
# cos gamma = c · u
|
| 65 |
+
cosg = np.clip(np.sum(u * c, axis=-1), -1.0, 1.0)
|
| 66 |
+
gamma = np.rad2deg(np.arccos(cosg))
|
| 67 |
+
# isotropic sigma from geometric mean of width axes
|
| 68 |
+
sig = max(0.5, math.sqrt(max(sigma_az_deg, 0.5) * max(sigma_el_deg, 0.5)))
|
| 69 |
+
return np.exp(-0.5 * (gamma / sig) ** 2)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def encode_boxes_to_hoa(
|
| 73 |
+
boxes: Sequence[Mapping[str, Any]],
|
| 74 |
+
*,
|
| 75 |
+
max_order: int = MAX_ORDER,
|
| 76 |
+
n_azi: int = 72,
|
| 77 |
+
n_el: int = 36,
|
| 78 |
+
) -> np.ndarray:
|
| 79 |
+
"""Project one or more vision boxes/rays into a static HOA-7 field (C,).
|
| 80 |
+
|
| 81 |
+
Each box is a soft spherical Gaussian; the field is the weighted sum of
|
| 82 |
+
SN3D plane-wave encodings of the discrete sphere samples of that lobe.
|
| 83 |
+
"""
|
| 84 |
+
if not boxes:
|
| 85 |
+
return np.zeros((max_order + 1) ** 2, dtype=np.float64)
|
| 86 |
+
|
| 87 |
+
azi = np.linspace(-180.0, 180.0, n_azi, endpoint=False)
|
| 88 |
+
el = np.linspace(-90.0, 90.0, n_el)
|
| 89 |
+
AA, EE = np.meshgrid(azi, el, indexing="ij")
|
| 90 |
+
# solid-angle-ish weights
|
| 91 |
+
w_el = np.cos(np.deg2rad(el))
|
| 92 |
+
w_el = np.maximum(w_el, 0.0)
|
| 93 |
+
dA = (2.0 * math.pi / n_azi) * (math.pi / max(n_el - 1, 1))
|
| 94 |
+
sa = dA * w_el[None, :] # (1,E) broadcast to (A,E)
|
| 95 |
+
|
| 96 |
+
field = np.zeros((max_order + 1) ** 2, dtype=np.float64)
|
| 97 |
+
for raw in boxes:
|
| 98 |
+
b = _as_box(raw)
|
| 99 |
+
if b["kind"] == "ray" or b["kind"] == "point":
|
| 100 |
+
# delta: single direction plane wave
|
| 101 |
+
Y = sh_sn3d(b["az"], b["el"], degrees=True, max_order=max_order)
|
| 102 |
+
field = field + b["weight"] * Y
|
| 103 |
+
continue
|
| 104 |
+
lobe = angular_gaussian_weights(
|
| 105 |
+
b["az"], b["el"], AA, EE, b["w_deg"], b["h_deg"]
|
| 106 |
+
)
|
| 107 |
+
# discrete HOA analysis of the lobe function f(Ω)
|
| 108 |
+
# a = sum f(Ω) Y(Ω) sa(Ω) * (2n+1)/4π — use same scale as rotate dense
|
| 109 |
+
Y = sh_sn3d(AA, EE, degrees=True, max_order=max_order) # (A,E,C)
|
| 110 |
+
f = lobe * b["weight"]
|
| 111 |
+
# a_c = sum_{ae} f_ae * sa_ae * Y_ae,c
|
| 112 |
+
weighted = (f * sa)[..., None] * Y
|
| 113 |
+
a = np.sum(weighted, axis=(0, 1))
|
| 114 |
+
# SN3D analysis scale per order
|
| 115 |
+
nch = a.shape[0]
|
| 116 |
+
scale = np.empty(nch, dtype=np.float64)
|
| 117 |
+
for n in range(max_order + 1):
|
| 118 |
+
s = float(2 * n + 1)
|
| 119 |
+
for m in range(-n, n + 1):
|
| 120 |
+
scale[n * (n + 1) + m] = s
|
| 121 |
+
a = a * scale / (4.0 * math.pi)
|
| 122 |
+
field = field + a
|
| 123 |
+
|
| 124 |
+
# pad to 64
|
| 125 |
+
out = np.zeros(N_CHANNELS, dtype=np.float64)
|
| 126 |
+
out[: field.shape[0]] = field
|
| 127 |
+
return out
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def report_from_boxes(
|
| 131 |
+
boxes: Sequence[Mapping[str, Any]],
|
| 132 |
+
*,
|
| 133 |
+
max_order: int = MAX_ORDER,
|
| 134 |
+
sample_rate: int = 48000,
|
| 135 |
+
n_samples: int = 1,
|
| 136 |
+
meta: Optional[dict] = None,
|
| 137 |
+
) -> SpatialReport:
|
| 138 |
+
"""Vision-only spatial report from detection boxes/rays."""
|
| 139 |
+
hoa = encode_boxes_to_hoa(boxes, max_order=max_order)
|
| 140 |
+
# report_from_hoa expects (C,T) — use a constant "frame"
|
| 141 |
+
stream = np.tile(hoa.reshape(-1, 1), (1, max(1, n_samples)))
|
| 142 |
+
notes = [f"vision tower: {len(boxes)} box/ray event(s) on HOA sphere"]
|
| 143 |
+
hints = []
|
| 144 |
+
for i, raw in enumerate(boxes):
|
| 145 |
+
try:
|
| 146 |
+
b = _as_box(raw)
|
| 147 |
+
hints.append(
|
| 148 |
+
{
|
| 149 |
+
"label": b["label"] or f"box{i}",
|
| 150 |
+
"azimuth_deg": b["az"],
|
| 151 |
+
"elevation_deg": b["el"],
|
| 152 |
+
"weight": b["weight"],
|
| 153 |
+
"kind": b["kind"],
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
except Exception:
|
| 157 |
+
continue
|
| 158 |
+
m = dict(meta or {})
|
| 159 |
+
m["encode"] = "vision_boxes"
|
| 160 |
+
m["n_boxes"] = len(boxes)
|
| 161 |
+
rep = report_from_hoa(
|
| 162 |
+
stream,
|
| 163 |
+
sample_rate,
|
| 164 |
+
max_order=max_order,
|
| 165 |
+
include_frames=False,
|
| 166 |
+
include_bands=False,
|
| 167 |
+
include_peak_map=True,
|
| 168 |
+
notes=notes,
|
| 169 |
+
meta=m,
|
| 170 |
+
)
|
| 171 |
+
rep.kind = "spatial_vision"
|
| 172 |
+
rep.sources_hint = hints
|
| 173 |
+
return rep
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def fuse_reports(audio: Mapping[str, Any], vision: Mapping[str, Any]) -> dict:
|
| 177 |
+
"""Merge audio + vision spatial reports into one agent-facing payload.
|
| 178 |
+
|
| 179 |
+
Strategy:
|
| 180 |
+
- Keep both DOAs
|
| 181 |
+
- angular_separation_deg between them
|
| 182 |
+
- agreement flag if within 15°
|
| 183 |
+
- combined one_liner
|
| 184 |
+
"""
|
| 185 |
+
from .analysis import angular_error_deg
|
| 186 |
+
|
| 187 |
+
a_az = float(audio.get("doa_az_deg", 0.0))
|
| 188 |
+
a_el = float(audio.get("doa_el_deg", 0.0))
|
| 189 |
+
v_az = float(vision.get("doa_az_deg", vision.get("peak_az_deg", 0.0)))
|
| 190 |
+
v_el = float(vision.get("doa_el_deg", vision.get("peak_el_deg", 0.0)))
|
| 191 |
+
sep = angular_error_deg(a_az, a_el, v_az, v_el)
|
| 192 |
+
agree = sep <= 15.0
|
| 193 |
+
|
| 194 |
+
# energy-weighted blend of unit vectors if both have energy
|
| 195 |
+
from .basis import az_el_from_unit, unit_vector
|
| 196 |
+
|
| 197 |
+
ea = float(audio.get("energy", 0.0)) + 1e-12
|
| 198 |
+
ev = float(vision.get("energy", 0.0)) + 1e-12
|
| 199 |
+
ua = unit_vector(a_az, a_el, degrees=True)
|
| 200 |
+
uv = unit_vector(v_az, v_el, degrees=True)
|
| 201 |
+
um = (ea * ua + ev * uv) / (ea + ev)
|
| 202 |
+
nm = float(np.linalg.norm(um))
|
| 203 |
+
if nm > 1e-15:
|
| 204 |
+
baz, bel = az_el_from_unit(um / nm, degrees=True)
|
| 205 |
+
baz, bel = float(baz), float(bel)
|
| 206 |
+
else:
|
| 207 |
+
baz, bel = a_az, a_el
|
| 208 |
+
|
| 209 |
+
one = (
|
| 210 |
+
f"spatial-fuse: audio=({a_az:.1f},{a_el:.1f}) vision=({v_az:.1f},{v_el:.1f}) "
|
| 211 |
+
f"sep={sep:.1f}° agree={agree} blend=({baz:.1f},{bel:.1f})"
|
| 212 |
+
)
|
| 213 |
+
return {
|
| 214 |
+
"schema": "spatial-hoa.fuse.v1",
|
| 215 |
+
"kind": "spatial_av_fuse",
|
| 216 |
+
"audio_doa_az_deg": a_az,
|
| 217 |
+
"audio_doa_el_deg": a_el,
|
| 218 |
+
"vision_doa_az_deg": v_az,
|
| 219 |
+
"vision_doa_el_deg": v_el,
|
| 220 |
+
"angular_separation_deg": sep,
|
| 221 |
+
"agreement": agree,
|
| 222 |
+
"blend_az_deg": baz,
|
| 223 |
+
"blend_el_deg": bel,
|
| 224 |
+
"audio_energy": float(audio.get("energy", 0.0)),
|
| 225 |
+
"vision_energy": float(vision.get("energy", 0.0)),
|
| 226 |
+
"audio_one_liner": audio.get("one_liner")
|
| 227 |
+
or f"audio DOA ({a_az:.1f},{a_el:.1f})",
|
| 228 |
+
"vision_one_liner": vision.get("one_liner")
|
| 229 |
+
or f"vision DOA ({v_az:.1f},{v_el:.1f})",
|
| 230 |
+
"vision_sources": vision.get("sources_hint") or [],
|
| 231 |
+
"audio_bands": audio.get("bands") or [],
|
| 232 |
+
"one_liner": one,
|
| 233 |
+
"notes": [
|
| 234 |
+
"Fused audio intensity DOA with vision spherical presence peak.",
|
| 235 |
+
"agreement true if separation ≤ 15°.",
|
| 236 |
+
],
|
| 237 |
+
}
|
hoa64/wigner.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fast HOA rotation via Wigner-D matrices (real Ambix ACN / SN3D).
|
| 2 |
+
|
| 3 |
+
Within each order n the (2n+1) coefficients transform by a real matrix built
|
| 4 |
+
from complex Wigner D functions. Normalization (SN3D vs N3D) cancels inside
|
| 5 |
+
an order, so the same matrices apply to Ambix SN3D.
|
| 6 |
+
|
| 7 |
+
Public:
|
| 8 |
+
rotation_matrix_zyx — 3×3 active rotation (yaw/pitch/roll)
|
| 9 |
+
hoa_rotation_matrix — full ((N+1)²)×((N+1)²) block-diagonal matrix
|
| 10 |
+
apply_hoa_rotation — a' = M @ a (also (C,T) streams)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
from typing import Tuple
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
from .basis import MAX_ORDER, N_CHANNELS, acn_index
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def rotation_matrix_zyx(
|
| 25 |
+
yaw: float, pitch: float, roll: float, *, degrees: bool = True
|
| 26 |
+
) -> np.ndarray:
|
| 27 |
+
"""R = Rz(yaw) @ Ry(pitch) @ Rx(roll); active rotation of column vectors."""
|
| 28 |
+
if degrees:
|
| 29 |
+
yaw, pitch, roll = map(math.radians, (yaw, pitch, roll))
|
| 30 |
+
cy, sy = math.cos(yaw), math.sin(yaw)
|
| 31 |
+
cp, sp = math.cos(pitch), math.sin(pitch)
|
| 32 |
+
cr, sr = math.cos(roll), math.sin(roll)
|
| 33 |
+
Rz = np.array([[cy, -sy, 0.0], [sy, cy, 0.0], [0.0, 0.0, 1.0]])
|
| 34 |
+
Ry = np.array([[cp, 0.0, sp], [0.0, 1.0, 0.0], [-sp, 0.0, cp]])
|
| 35 |
+
Rx = np.array([[1.0, 0.0, 0.0], [0.0, cr, -sr], [0.0, sr, cr]])
|
| 36 |
+
return Rz @ Ry @ Rx
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _fact(n: int) -> float:
|
| 40 |
+
if n < 0:
|
| 41 |
+
return 0.0
|
| 42 |
+
return float(math.factorial(n))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@lru_cache(maxsize=8192)
|
| 46 |
+
def wigner_d(j: int, mp: int, m: int, beta: float) -> float:
|
| 47 |
+
"""Wigner small-d matrix element d^j_{mp,m}(beta), real.
|
| 48 |
+
|
| 49 |
+
Explicit finite sum (safe for j ≤ 7 used here).
|
| 50 |
+
"""
|
| 51 |
+
if abs(mp) > j or abs(m) > j:
|
| 52 |
+
return 0.0
|
| 53 |
+
# Numerical stability: beta in [0, pi] preferred but general ok
|
| 54 |
+
cb = math.cos(beta * 0.5)
|
| 55 |
+
sb = math.sin(beta * 0.5)
|
| 56 |
+
# Avoid 0**negative
|
| 57 |
+
s_min = max(0, m - mp)
|
| 58 |
+
s_max = min(j + m, j - mp)
|
| 59 |
+
total = 0.0
|
| 60 |
+
for s in range(s_min, s_max + 1):
|
| 61 |
+
den = (
|
| 62 |
+
_fact(j + m - s)
|
| 63 |
+
* _fact(j - mp - s)
|
| 64 |
+
* _fact(s)
|
| 65 |
+
* _fact(s + mp - m)
|
| 66 |
+
)
|
| 67 |
+
if den == 0.0:
|
| 68 |
+
continue
|
| 69 |
+
num = _fact(j + m) * _fact(j - m) * _fact(j + mp) * _fact(j - mp)
|
| 70 |
+
pref = ((-1.0) ** (mp - m + s)) * math.sqrt(num) / den
|
| 71 |
+
cpow = 2 * j + m - mp - 2 * s
|
| 72 |
+
spow = mp - m + 2 * s
|
| 73 |
+
# handle base cases
|
| 74 |
+
cterm = 1.0 if cpow == 0 else (cb ** cpow if abs(cb) > 1e-15 or cpow > 0 else 0.0)
|
| 75 |
+
sterm = 1.0 if spow == 0 else (sb ** spow if abs(sb) > 1e-15 or spow > 0 else 0.0)
|
| 76 |
+
if cpow < 0 and abs(cb) < 1e-15:
|
| 77 |
+
cterm = 0.0
|
| 78 |
+
if spow < 0 and abs(sb) < 1e-15:
|
| 79 |
+
sterm = 0.0
|
| 80 |
+
total += pref * cterm * sterm
|
| 81 |
+
return total
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def wigner_D_complex(
|
| 85 |
+
j: int, alpha: float, beta: float, gamma: float
|
| 86 |
+
) -> np.ndarray:
|
| 87 |
+
"""Complex Wigner-D matrix for order j, indices m',m ∈ [-j..j].
|
| 88 |
+
|
| 89 |
+
Ordering: row/col index = m + j (m from -j to +j).
|
| 90 |
+
D_{mp,m} = e^{-i mp α} d_{mp,m}(β) e^{-i m γ}
|
| 91 |
+
"""
|
| 92 |
+
dim = 2 * j + 1
|
| 93 |
+
D = np.zeros((dim, dim), dtype=np.complex128)
|
| 94 |
+
for imp, mp in enumerate(range(-j, j + 1)):
|
| 95 |
+
for im, m in enumerate(range(-j, j + 1)):
|
| 96 |
+
d = wigner_d(j, mp, m, beta)
|
| 97 |
+
D[imp, im] = (
|
| 98 |
+
math.cos(mp * alpha)
|
| 99 |
+
- 1j * math.sin(mp * alpha)
|
| 100 |
+
) * d * (
|
| 101 |
+
math.cos(m * gamma) - 1j * math.sin(m * gamma)
|
| 102 |
+
)
|
| 103 |
+
# e^{-i θ} = cosθ - i sinθ
|
| 104 |
+
return D
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _real_to_complex_matrix(j: int) -> np.ndarray:
|
| 108 |
+
"""Unitary map U: real ACN coeffs (m=-j..j) → complex m=-j..j.
|
| 109 |
+
|
| 110 |
+
Convention (common in Ambisonics / real SH):
|
| 111 |
+
c_0 = r_0
|
| 112 |
+
c_{+m} = (-1)^m / √2 * (r_{+m} - i r_{-m})
|
| 113 |
+
c_{-m} = 1 / √2 * (r_{+m} + i r_{-m})
|
| 114 |
+
so r = U^H c and c = U r with U unitary.
|
| 115 |
+
"""
|
| 116 |
+
dim = 2 * j + 1
|
| 117 |
+
U = np.zeros((dim, dim), dtype=np.complex128)
|
| 118 |
+
# index helper: m -> i = m + j
|
| 119 |
+
def idx(m: int) -> int:
|
| 120 |
+
return m + j
|
| 121 |
+
|
| 122 |
+
U[idx(0), idx(0)] = 1.0 + 0.0j
|
| 123 |
+
s2 = 1.0 / math.sqrt(2.0)
|
| 124 |
+
for m in range(1, j + 1):
|
| 125 |
+
sign = (-1.0) ** m
|
| 126 |
+
# c_{+m} from r_{+m}, r_{-m}
|
| 127 |
+
U[idx(m), idx(m)] = sign * s2
|
| 128 |
+
U[idx(m), idx(-m)] = -1j * sign * s2
|
| 129 |
+
# c_{-m} from r_{+m}, r_{-m}
|
| 130 |
+
U[idx(-m), idx(m)] = s2
|
| 131 |
+
U[idx(-m), idx(-m)] = 1j * s2
|
| 132 |
+
return U
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def real_sh_rotation_block(
|
| 136 |
+
j: int, alpha: float, beta: float, gamma: float
|
| 137 |
+
) -> np.ndarray:
|
| 138 |
+
"""Real (2j+1)×(2j+1) rotation matrix for ACN order-j block (m=-j..j)."""
|
| 139 |
+
if j == 0:
|
| 140 |
+
return np.array([[1.0]], dtype=np.float64)
|
| 141 |
+
U = _real_to_complex_matrix(j)
|
| 142 |
+
D = wigner_D_complex(j, alpha, beta, gamma)
|
| 143 |
+
# real coeffs: r' = U^H D U r
|
| 144 |
+
R_c = U.conj().T @ D @ U
|
| 145 |
+
# Should be real symmetric orthogonal (numerically tiny imag)
|
| 146 |
+
return np.real(R_c)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def rotation_matrix_to_zyz(R: np.ndarray) -> Tuple[float, float, float]:
|
| 150 |
+
"""Extract ZYZ Euler angles (α, β, γ) from a right-handed rotation matrix.
|
| 151 |
+
|
| 152 |
+
R = Rz(α) @ Ry(β) @ Rz(γ) (active, column vectors).
|
| 153 |
+
"""
|
| 154 |
+
R = np.asarray(R, dtype=np.float64)
|
| 155 |
+
# β ∈ [0, π]
|
| 156 |
+
# R[2,2] = cos β
|
| 157 |
+
cbeta = float(np.clip(R[2, 2], -1.0, 1.0))
|
| 158 |
+
beta = math.acos(cbeta)
|
| 159 |
+
sb = math.sin(beta)
|
| 160 |
+
if abs(sb) > 1e-10:
|
| 161 |
+
# α = atan2(R[1,2]/sinβ, R[0,2]/sinβ)
|
| 162 |
+
alpha = math.atan2(R[1, 2] / sb, R[0, 2] / sb)
|
| 163 |
+
# γ = atan2(R[2,1]/sinβ, -R[2,0]/sinβ)
|
| 164 |
+
gamma = math.atan2(R[2, 1] / sb, -R[2, 0] / sb)
|
| 165 |
+
else:
|
| 166 |
+
# Gimbal: β ≈ 0 or π — only α+γ or α-γ determined
|
| 167 |
+
alpha = math.atan2(-R[0, 1], R[0, 0])
|
| 168 |
+
gamma = 0.0
|
| 169 |
+
if cbeta < 0:
|
| 170 |
+
# β = π
|
| 171 |
+
alpha = math.atan2(R[0, 1], -R[0, 0])
|
| 172 |
+
return alpha, beta, gamma
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def hoa_rotation_matrix(
|
| 176 |
+
R3: np.ndarray,
|
| 177 |
+
*,
|
| 178 |
+
max_order: int = MAX_ORDER,
|
| 179 |
+
) -> np.ndarray:
|
| 180 |
+
"""Full Ambix ACN rotation matrix for orders 0..max_order.
|
| 181 |
+
|
| 182 |
+
Applies the *same* geometric rotation as R3 does to Cartesian vectors.
|
| 183 |
+
"""
|
| 184 |
+
max_order = int(max_order)
|
| 185 |
+
nch = (max_order + 1) ** 2
|
| 186 |
+
M = np.zeros((nch, nch), dtype=np.float64)
|
| 187 |
+
alpha, beta, gamma = rotation_matrix_to_zyz(R3)
|
| 188 |
+
|
| 189 |
+
# Order 0
|
| 190 |
+
M[0, 0] = 1.0
|
| 191 |
+
|
| 192 |
+
# Order 1: direct Cartesian for numerical exactness / convention lock.
|
| 193 |
+
# ACN: [Y, Z, X] = indices 1,2,3 ; cart = [X,Y,Z]
|
| 194 |
+
# v' = R3 @ v ⇒ [X',Y',Z'] = R3 @ [X,Y,Z]
|
| 195 |
+
if max_order >= 1:
|
| 196 |
+
# Build 3×3 block mapping [Y,Z,X] -> [Y',Z',X']
|
| 197 |
+
# [X'] [R00 R01 R02] [X]
|
| 198 |
+
# [Y'] = [R10 R11 R12] [Y]
|
| 199 |
+
# [Z'] [R20 R21 R22] [Z]
|
| 200 |
+
# Y' = R10 X + R11 Y + R12 Z
|
| 201 |
+
# Z' = R20 X + R21 Y + R22 Z
|
| 202 |
+
# X' = R00 X + R01 Y + R02 Z
|
| 203 |
+
# Coefficients of (Y,Z,X):
|
| 204 |
+
# Y' = R11 Y + R12 Z + R10 X
|
| 205 |
+
# Z' = R21 Y + R22 Z + R20 X
|
| 206 |
+
# X' = R01 Y + R02 Z + R00 X
|
| 207 |
+
B = np.array(
|
| 208 |
+
[
|
| 209 |
+
[R3[1, 1], R3[1, 2], R3[1, 0]],
|
| 210 |
+
[R3[2, 1], R3[2, 2], R3[2, 0]],
|
| 211 |
+
[R3[0, 1], R3[0, 2], R3[0, 0]],
|
| 212 |
+
],
|
| 213 |
+
dtype=np.float64,
|
| 214 |
+
)
|
| 215 |
+
M[1:4, 1:4] = B
|
| 216 |
+
|
| 217 |
+
for n in range(2, max_order + 1):
|
| 218 |
+
block = real_sh_rotation_block(n, alpha, beta, gamma)
|
| 219 |
+
# Verify det ~ 1; if complex conversion convention is flipped we may
|
| 220 |
+
# need transpose — tests catch this against plane-wave re-encode.
|
| 221 |
+
i0 = n * n # ACN start for order n is n^2 (m=-n → n*(n+1)+(-n)=n^2)
|
| 222 |
+
# Wait: n*(n+1)+(-n) = n^2 + n - n = n^2. Yes.
|
| 223 |
+
dim = 2 * n + 1
|
| 224 |
+
M[i0 : i0 + dim, i0 : i0 + dim] = block
|
| 225 |
+
|
| 226 |
+
return M
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def apply_hoa_rotation(
|
| 230 |
+
hoa: np.ndarray,
|
| 231 |
+
M: np.ndarray,
|
| 232 |
+
) -> np.ndarray:
|
| 233 |
+
"""Apply precomputed rotation matrix to (C,) or (C,T) coefficients."""
|
| 234 |
+
a = np.asarray(hoa, dtype=np.float64)
|
| 235 |
+
nch = M.shape[0]
|
| 236 |
+
if a.ndim == 1:
|
| 237 |
+
aa = np.zeros(nch, dtype=np.float64)
|
| 238 |
+
n = min(nch, a.shape[0])
|
| 239 |
+
aa[:n] = a[:n]
|
| 240 |
+
out = M @ aa
|
| 241 |
+
if a.shape[0] > nch:
|
| 242 |
+
full = np.zeros_like(a)
|
| 243 |
+
full[:nch] = out
|
| 244 |
+
return full
|
| 245 |
+
if a.shape[0] < N_CHANNELS:
|
| 246 |
+
full = np.zeros(N_CHANNELS, dtype=np.float64)
|
| 247 |
+
full[:nch] = out
|
| 248 |
+
return full
|
| 249 |
+
return out
|
| 250 |
+
if a.ndim == 2:
|
| 251 |
+
aa = np.zeros((nch, a.shape[1]), dtype=np.float64)
|
| 252 |
+
n = min(nch, a.shape[0])
|
| 253 |
+
aa[:n] = a[:n]
|
| 254 |
+
out = M @ aa
|
| 255 |
+
if a.shape[0] >= N_CHANNELS:
|
| 256 |
+
full = np.zeros((max(a.shape[0], N_CHANNELS), a.shape[1]), dtype=np.float64)
|
| 257 |
+
full[:nch] = out
|
| 258 |
+
return full[: a.shape[0]]
|
| 259 |
+
full = np.zeros((N_CHANNELS, a.shape[1]), dtype=np.float64)
|
| 260 |
+
full[:nch] = out
|
| 261 |
+
return full
|
| 262 |
+
raise ValueError("hoa must be (C,) or (C,T)")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def clear_wigner_cache() -> None:
|
| 266 |
+
wigner_d.cache_clear()
|
integrations/qwythos_system_snippet.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Qwythos / Pi bridge — spatial tool wiring
|
| 2 |
+
|
| 3 |
+
The HOA-7 spatial calculator is **not** part of the LLM weights. Qwythos should
|
| 4 |
+
call it as a tool and treat the JSON / one-liner as ground-truth geometry.
|
| 5 |
+
|
| 6 |
+
## Runtime
|
| 7 |
+
|
| 8 |
+
| Piece | Address |
|
| 9 |
+
|-------|---------|
|
| 10 |
+
| Qwythos (llama-server) | `http://127.0.0.1:8000/v1` |
|
| 11 |
+
| Olympus Pi bridge | `http://127.0.0.1:8642` |
|
| 12 |
+
| **spatial-hoa API** | `http://127.0.0.1:8765` |
|
| 13 |
+
| CLI on PATH | `spatial-report` |
|
| 14 |
+
|
| 15 |
+
Enable API at login:
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
systemctl --user enable --now spatial-hoa.service
|
| 19 |
+
curl -s http://127.0.0.1:8765/health
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## For Pi bridge (bash tool)
|
| 23 |
+
|
| 24 |
+
Pi already has `bash`. Instruct the agent:
|
| 25 |
+
|
| 26 |
+
```text
|
| 27 |
+
When the user asks about spatial audio/location/direction of sound or objects,
|
| 28 |
+
run one of:
|
| 29 |
+
spatial-report demo-scene -o /tmp/spatial.json && cat /tmp/spatial.json
|
| 30 |
+
spatial-report analyze /path/to.wav --ambix -o /tmp/spatial.json && cat /tmp/spatial.json
|
| 31 |
+
spatial-report analyze /path/to.wav --az DEG --el DEG -o /tmp/spatial.json
|
| 32 |
+
spatial-report vision --boxes '[{"az":0,"el":0,"label":"obj"}]' -o /tmp/v.json
|
| 33 |
+
Or HTTP:
|
| 34 |
+
curl -s -X POST http://127.0.0.1:8765/v1/spatial/analyze \
|
| 35 |
+
-H 'Content-Type: application/json' \
|
| 36 |
+
-d '{"mode":"demo_scene","order":3}'
|
| 37 |
+
Use the returned one_liner and doa_* fields as factual spatial state.
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Optional: append that block to the Pi system prompt / AGENTS notes.
|
| 41 |
+
|
| 42 |
+
## OpenAI tool schema
|
| 43 |
+
|
| 44 |
+
See `tools/spatial_analyze.openai.json` for a function-calling descriptor
|
| 45 |
+
compatible with agents that support tools against a custom executor.
|
| 46 |
+
|
| 47 |
+
## Coordinates (always)
|
| 48 |
+
|
| 49 |
+
- Azimuth 0° = front, +90° = left, −90° = right
|
| 50 |
+
- Elevation 0° = horizon, +90° = zenith
|
scripts/spatial-report
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# spatial-report — PATH entry for Qwythos / Pi bash tool / humans.
|
| 3 |
+
# Wraps hoa64 CLI. Examples:
|
| 4 |
+
# spatial-report demo-scene -o /tmp/r.json
|
| 5 |
+
# spatial-report analyze /path/file.wav --ambix
|
| 6 |
+
# spatial-report vision --boxes '[{"az":30,"el":0,"label":"obj"}]'
|
| 7 |
+
# spatial-report serve
|
| 8 |
+
set -euo pipefail
|
| 9 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 10 |
+
export PYTHONPATH="${ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
| 11 |
+
exec python3 -m hoa64 "$@"
|
tests/test_basis.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Basis / Farina formula sanity checks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from hoa64.basis import (
|
| 14 |
+
MAX_ORDER,
|
| 15 |
+
N_CHANNELS,
|
| 16 |
+
acn_index,
|
| 17 |
+
acn_nm,
|
| 18 |
+
channel_names,
|
| 19 |
+
sh_sn3d,
|
| 20 |
+
unit_vector,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_layout():
|
| 25 |
+
assert N_CHANNELS == 64
|
| 26 |
+
assert MAX_ORDER == 7
|
| 27 |
+
assert acn_index(0, 0) == 0
|
| 28 |
+
assert acn_index(1, -1) == 1
|
| 29 |
+
assert acn_index(1, 0) == 2
|
| 30 |
+
assert acn_index(1, 1) == 3
|
| 31 |
+
assert acn_index(7, 7) == 63
|
| 32 |
+
for i in range(64):
|
| 33 |
+
n, m = acn_nm(i)
|
| 34 |
+
assert acn_index(n, m) == i
|
| 35 |
+
names = channel_names()
|
| 36 |
+
assert names[0] == "W"
|
| 37 |
+
assert names[1] == "Y"
|
| 38 |
+
assert names[2] == "Z"
|
| 39 |
+
assert names[3] == "X"
|
| 40 |
+
assert len(names) == 64
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_unit_vector_cardinals():
|
| 44 |
+
# front, left, up
|
| 45 |
+
f = unit_vector(0.0, 0.0)
|
| 46 |
+
l = unit_vector(90.0, 0.0)
|
| 47 |
+
u = unit_vector(0.0, 90.0)
|
| 48 |
+
np.testing.assert_allclose(f, [1, 0, 0], atol=1e-12)
|
| 49 |
+
np.testing.assert_allclose(l, [0, 1, 0], atol=1e-12)
|
| 50 |
+
np.testing.assert_allclose(u, [0, 0, 1], atol=1e-12)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_order1_cartesian_identity():
|
| 54 |
+
"""Order-1 SN3D: Y=y, Z=z, X=x at unit directions."""
|
| 55 |
+
for az, el, xyz in [
|
| 56 |
+
(0, 0, (1, 0, 0)),
|
| 57 |
+
(90, 0, (0, 1, 0)),
|
| 58 |
+
(0, 90, (0, 0, 1)),
|
| 59 |
+
(-90, 0, (0, -1, 0)),
|
| 60 |
+
]:
|
| 61 |
+
y = sh_sn3d(az, el)
|
| 62 |
+
assert y.shape == (64,)
|
| 63 |
+
assert abs(y[0] - 1.0) < 1e-12 # W
|
| 64 |
+
x, yy, z = xyz
|
| 65 |
+
assert abs(y[1] - yy) < 1e-12
|
| 66 |
+
assert abs(y[2] - z) < 1e-12
|
| 67 |
+
assert abs(y[3] - x) < 1e-12
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_front_source_signs():
|
| 71 |
+
y = sh_sn3d(0.0, 0.0)
|
| 72 |
+
# front: X dominant positive among order-1 dipoles
|
| 73 |
+
assert y[3] > 0.9
|
| 74 |
+
assert abs(y[1]) < 1e-12
|
| 75 |
+
assert abs(y[2]) < 1e-12
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_finite_all_channels():
|
| 79 |
+
y = sh_sn3d(33.0, -12.0)
|
| 80 |
+
assert y.shape == (64,)
|
| 81 |
+
assert np.all(np.isfinite(y))
|
| 82 |
+
# batch
|
| 83 |
+
yb = sh_sn3d([0, 90, 180], [0, 0, 0])
|
| 84 |
+
assert yb.shape == (3, 64)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_zenith_only_z_orders():
|
| 88 |
+
y = sh_sn3d(0.0, 90.0)
|
| 89 |
+
# at north pole, only m=0 channels should be nonzero (real SH)
|
| 90 |
+
for n in range(0, 8):
|
| 91 |
+
for m in range(-n, n + 1):
|
| 92 |
+
idx = acn_index(n, m)
|
| 93 |
+
if m != 0:
|
| 94 |
+
assert abs(y[idx]) < 1e-8, f"n={n} m={m} val={y[idx]}"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
for fn in [
|
| 99 |
+
test_layout,
|
| 100 |
+
test_unit_vector_cardinals,
|
| 101 |
+
test_order1_cartesian_identity,
|
| 102 |
+
test_front_source_signs,
|
| 103 |
+
test_finite_all_channels,
|
| 104 |
+
test_zenith_only_z_orders,
|
| 105 |
+
]:
|
| 106 |
+
fn()
|
| 107 |
+
print("OK", fn.__name__)
|
tests/test_encode_decode.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encode / decode / DOA hypothesis tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 11 |
+
|
| 12 |
+
from hoa64.analysis import (
|
| 13 |
+
angular_error_deg,
|
| 14 |
+
doa_from_intensity,
|
| 15 |
+
field_energy,
|
| 16 |
+
peak_direction,
|
| 17 |
+
)
|
| 18 |
+
from hoa64.decode import beamform, decode_directions
|
| 19 |
+
from hoa64.encode import encode_plane_waves, encode_points, mix
|
| 20 |
+
from hoa64.basis import sh_sn3d
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_encode_matches_basis():
|
| 24 |
+
a = encode_points([45.0], [10.0], [1.0])
|
| 25 |
+
y = sh_sn3d(45.0, 10.0)
|
| 26 |
+
np.testing.assert_allclose(a, y, atol=1e-12)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_beamform_peaks_at_source():
|
| 30 |
+
a = encode_points([30.0], [-15.0], [1.0])
|
| 31 |
+
on = float(beamform(a, 30.0, -15.0))
|
| 32 |
+
off = float(beamform(a, -150.0, 40.0))
|
| 33 |
+
assert on > off
|
| 34 |
+
assert on > 0.5 # SN3D self-inner-product roughly order-dependent
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_intensity_doa_cardinal():
|
| 38 |
+
for az, el in [(0, 0), (90, 0), (-90, 0), (0, 45)]:
|
| 39 |
+
a = encode_points([az], [el], [1.0])
|
| 40 |
+
az_h, el_h = doa_from_intensity(a)
|
| 41 |
+
err = angular_error_deg(az, el, az_h, el_h)
|
| 42 |
+
assert err < 2.0, f"src=({az},{el}) hat=({az_h},{el_h}) err={err}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_peak_direction_near_source():
|
| 46 |
+
a = encode_points([120.0], [25.0], [1.0])
|
| 47 |
+
paz, pel, pval = peak_direction(a, n_azi=120, n_el=60)
|
| 48 |
+
err = angular_error_deg(120.0, 25.0, paz, pel)
|
| 49 |
+
assert err < 5.0, f"peak=({paz},{pel}) err={err}"
|
| 50 |
+
assert pval > 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_superposition_linearity():
|
| 54 |
+
a1 = encode_points([0], [0], [1.0])
|
| 55 |
+
a2 = encode_points([90], [0], [0.5])
|
| 56 |
+
m = mix(a1, a2)
|
| 57 |
+
np.testing.assert_allclose(m, a1 + a2)
|
| 58 |
+
assert field_energy(m) > field_energy(a1)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_plane_wave_time_series():
|
| 62 |
+
t = np.linspace(0, 1, 100, endpoint=False)
|
| 63 |
+
sig = np.sin(2 * np.pi * 5 * t)[None, :] # (1, T)
|
| 64 |
+
a = encode_plane_waves([0.0], [0.0], sig)
|
| 65 |
+
assert a.shape == (64, 100)
|
| 66 |
+
# W channel tracks the signal
|
| 67 |
+
np.testing.assert_allclose(a[0], sig[0], atol=1e-12)
|
| 68 |
+
# X (front) also tracks for front source
|
| 69 |
+
np.testing.assert_allclose(a[3], sig[0], atol=1e-12)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_two_source_separation_energy():
|
| 73 |
+
a = mix(
|
| 74 |
+
encode_points([0], [0], [1.0]),
|
| 75 |
+
encode_points([180], [0], [1.0]),
|
| 76 |
+
)
|
| 77 |
+
front = float(beamform(a, 0, 0))
|
| 78 |
+
back = float(beamform(a, 180, 0))
|
| 79 |
+
side = float(beamform(a, 90, 0))
|
| 80 |
+
assert front > side
|
| 81 |
+
assert back > side
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
for fn in [
|
| 86 |
+
test_encode_matches_basis,
|
| 87 |
+
test_beamform_peaks_at_source,
|
| 88 |
+
test_intensity_doa_cardinal,
|
| 89 |
+
test_peak_direction_near_source,
|
| 90 |
+
test_superposition_linearity,
|
| 91 |
+
test_plane_wave_time_series,
|
| 92 |
+
test_two_source_separation_energy,
|
| 93 |
+
]:
|
| 94 |
+
fn()
|
| 95 |
+
print("OK", fn.__name__)
|
tests/test_integration_extras.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Detector, live pipeline (file), conditioning tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
+
|
| 14 |
+
from hoa64.analysis import angular_error_deg
|
| 15 |
+
from hoa64.audio_io import write_wav
|
| 16 |
+
from hoa64.conditioning import build_conditioning, comfy_txt2img_payload, spatial_prompt_fragment
|
| 17 |
+
from hoa64.detector import (
|
| 18 |
+
Detection,
|
| 19 |
+
detections_to_sphere_boxes,
|
| 20 |
+
load_yolo_labels,
|
| 21 |
+
write_demo_image_with_box,
|
| 22 |
+
)
|
| 23 |
+
from hoa64.live_audio import audio_to_hoa_stream, live_report_from_file, stereo_to_order1_hoa
|
| 24 |
+
from hoa64.synth import tone
|
| 25 |
+
from hoa64.vision import fuse_reports, report_from_boxes
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_demo_image_detect_sphere():
|
| 29 |
+
with tempfile.TemporaryDirectory() as td:
|
| 30 |
+
img = Path(td) / "demo.png"
|
| 31 |
+
det = write_demo_image_with_box(img)
|
| 32 |
+
boxes = detections_to_sphere_boxes([det])
|
| 33 |
+
assert boxes[0]["label"] == "demo_object"
|
| 34 |
+
# center of box ~ (0.45, 0.5) → slight left az positive? cx=0.45 → az=(0.5-0.45)*90=+4.5
|
| 35 |
+
assert abs(boxes[0]["az"] - 4.5) < 1.0
|
| 36 |
+
rep = report_from_boxes(boxes, max_order=3)
|
| 37 |
+
assert rep.energy > 0
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_yolo_labels():
|
| 41 |
+
with tempfile.TemporaryDirectory() as td:
|
| 42 |
+
p = Path(td) / "labels.txt"
|
| 43 |
+
# class 0 center front-ish
|
| 44 |
+
p.write_text("0 0.5 0.5 0.2 0.2 0.9\n")
|
| 45 |
+
dets = load_yolo_labels(p)
|
| 46 |
+
assert len(dets) == 1
|
| 47 |
+
boxes = detections_to_sphere_boxes(dets)
|
| 48 |
+
assert abs(boxes[0]["az"]) < 1.0 # center → az≈0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_live_pipeline_from_synth_wav():
|
| 52 |
+
with tempfile.TemporaryDirectory() as td:
|
| 53 |
+
wav = Path(td) / "m.wav"
|
| 54 |
+
sr = 16000
|
| 55 |
+
sig = tone(800, 0.3, sr, amplitude=0.4)
|
| 56 |
+
write_wav(wav, sig, sr)
|
| 57 |
+
rep = live_report_from_file(wav, az_deg=30.0, el_deg=0.0, max_order=1)
|
| 58 |
+
err = angular_error_deg(30.0, 0.0, rep.doa_az_deg, rep.doa_el_deg)
|
| 59 |
+
assert err < 8.0, f"err={err}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_stereo_pseudo_hoa():
|
| 63 |
+
sr = 8000
|
| 64 |
+
L = tone(400, 0.2, sr, amplitude=0.5)
|
| 65 |
+
R = tone(400, 0.2, sr, amplitude=0.1)
|
| 66 |
+
hoa = stereo_to_order1_hoa(L, R, width_az_deg=40)
|
| 67 |
+
assert hoa.shape[0] == 64
|
| 68 |
+
# left-dominant → positive Y energy
|
| 69 |
+
assert np.mean(hoa[1] ** 2) > 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_conditioning_from_fuse():
|
| 73 |
+
audio = {"doa_az_deg": 15.0, "doa_el_deg": 0.0, "energy": 1.0, "kind": "spatial_field"}
|
| 74 |
+
vision = {
|
| 75 |
+
"doa_az_deg": 18.0,
|
| 76 |
+
"doa_el_deg": 2.0,
|
| 77 |
+
"energy": 0.5,
|
| 78 |
+
"kind": "spatial_vision",
|
| 79 |
+
"peak_az_deg": 18.0,
|
| 80 |
+
"peak_el_deg": 2.0,
|
| 81 |
+
}
|
| 82 |
+
fused = fuse_reports(audio, vision)
|
| 83 |
+
cond = build_conditioning(fused, base_prompt="cinematic room", style="natural")
|
| 84 |
+
assert "cinematic room" in cond["positive_prompt"]
|
| 85 |
+
assert cond["schema"] == "spatial-hoa.conditioning.v1"
|
| 86 |
+
assert "control_vector" in cond
|
| 87 |
+
frag = spatial_prompt_fragment(fused, style="tags")
|
| 88 |
+
assert "spatial-az" in frag
|
| 89 |
+
# offline payload with explicit checkpoint (no Comfy required)
|
| 90 |
+
wf = comfy_txt2img_payload(
|
| 91 |
+
cond, checkpoint="sd_xl_base_1.0.safetensors", auto_checkpoint=False
|
| 92 |
+
)
|
| 93 |
+
assert "3" in wf and wf["6"]["inputs"]["text"]
|
| 94 |
+
assert wf["4"]["inputs"]["ckpt_name"] == "sd_xl_base_1.0.safetensors"
|
| 95 |
+
assert wf["5"]["inputs"]["width"] == 1024 # XL default size
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
test_demo_image_detect_sphere()
|
| 100 |
+
print("OK test_demo_image_detect_sphere")
|
| 101 |
+
test_yolo_labels()
|
| 102 |
+
print("OK test_yolo_labels")
|
| 103 |
+
test_live_pipeline_from_synth_wav()
|
| 104 |
+
print("OK test_live_pipeline_from_synth_wav")
|
| 105 |
+
test_stereo_pseudo_hoa()
|
| 106 |
+
print("OK test_stereo_pseudo_hoa")
|
| 107 |
+
test_conditioning_from_fuse()
|
| 108 |
+
print("OK test_conditioning_from_fuse")
|
tests/test_phase1_audio.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 1: WAV I/O, streams, JSON spatial reports."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
+
|
| 14 |
+
from hoa64.analysis import angular_error_deg
|
| 15 |
+
from hoa64.audio_io import read_wav, write_wav
|
| 16 |
+
from hoa64.report import (
|
| 17 |
+
REPORT_SCHEMA_VERSION,
|
| 18 |
+
report_from_ambix_wav,
|
| 19 |
+
report_from_mono_wav,
|
| 20 |
+
report_from_scene,
|
| 21 |
+
)
|
| 22 |
+
from hoa64.stream import SourceSpec, encode_mono_plane_wave, encode_scene
|
| 23 |
+
from hoa64.synth import envelope_adsr, tone
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_wav_roundtrip(tmp_path: Path | None = None):
|
| 27 |
+
base = Path(tmp_path) if tmp_path else Path(tempfile.mkdtemp())
|
| 28 |
+
sr = 16000
|
| 29 |
+
x = tone(440, 0.2, sr, amplitude=0.3)
|
| 30 |
+
path = base / "mono.wav"
|
| 31 |
+
write_wav(path, x, sr)
|
| 32 |
+
audio, sr2 = read_wav(path)
|
| 33 |
+
assert sr2 == sr
|
| 34 |
+
assert audio.shape[0] == 1
|
| 35 |
+
assert audio.shape[1] == x.shape[0]
|
| 36 |
+
assert np.corrcoef(audio[0], x)[0, 1] > 0.99
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_mono_encode_report_doa():
|
| 40 |
+
sr = 24000
|
| 41 |
+
sig = tone(1000, 0.4, sr, amplitude=0.5) * envelope_adsr(
|
| 42 |
+
int(0.4 * sr), sr
|
| 43 |
+
)
|
| 44 |
+
hoa = encode_mono_plane_wave(sig, 45.0, 0.0, max_order=3)
|
| 45 |
+
rep = report_from_hoa_local(hoa, sr, az_true=45.0)
|
| 46 |
+
err = angular_error_deg(45.0, 0.0, rep.doa_az_deg, rep.doa_el_deg)
|
| 47 |
+
assert err < 5.0, f"DOA err={err} got ({rep.doa_az_deg},{rep.doa_el_deg})"
|
| 48 |
+
assert rep.schema == REPORT_SCHEMA_VERSION
|
| 49 |
+
assert rep.duration_sec > 0.3
|
| 50 |
+
d = rep.to_dict()
|
| 51 |
+
assert "bands" in d and "frames" in d
|
| 52 |
+
# JSON serializable
|
| 53 |
+
json.dumps(d)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def report_from_hoa_local(hoa, sr, az_true=None):
|
| 57 |
+
from hoa64.report import report_from_hoa
|
| 58 |
+
|
| 59 |
+
return report_from_hoa(hoa, sr, max_order=3, include_peak_map=True)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_scene_two_sources_json(tmp_path: Path | None = None):
|
| 63 |
+
base = Path(tmp_path) if tmp_path else Path(tempfile.mkdtemp())
|
| 64 |
+
sr = 16000
|
| 65 |
+
n = int(0.35 * sr)
|
| 66 |
+
env = envelope_adsr(n, sr)
|
| 67 |
+
sources = [
|
| 68 |
+
SourceSpec(-30.0, 0.0, tone(500, 0.35, sr, amplitude=0.5) * env, "A"),
|
| 69 |
+
SourceSpec(120.0, 10.0, tone(900, 0.35, sr, amplitude=0.35) * env, "B"),
|
| 70 |
+
]
|
| 71 |
+
rep = report_from_scene(sources, sr, max_order=3)
|
| 72 |
+
assert len(rep.sources_hint) == 2
|
| 73 |
+
assert rep.energy > 0
|
| 74 |
+
out = base / "report.json"
|
| 75 |
+
rep.save(out)
|
| 76 |
+
loaded = json.loads(out.read_text())
|
| 77 |
+
assert loaded["schema"] == REPORT_SCHEMA_VERSION
|
| 78 |
+
assert loaded["n_channels"] == 16 # order 3
|
| 79 |
+
assert "one_liner" not in loaded
|
| 80 |
+
assert rep.one_liner().startswith("spatial:")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_ambix_wav_analyze(tmp_path: Path | None = None):
|
| 84 |
+
base = Path(tmp_path) if tmp_path else Path(tempfile.mkdtemp())
|
| 85 |
+
sr = 16000
|
| 86 |
+
sig = tone(700, 0.25, sr, amplitude=0.4)
|
| 87 |
+
hoa = encode_mono_plane_wave(sig, -60.0, 5.0, max_order=1)
|
| 88 |
+
path = base / "bformat.wav"
|
| 89 |
+
write_wav(path, hoa[:4], sr)
|
| 90 |
+
rep = report_from_ambix_wav(path, max_order=1, include_bands=True)
|
| 91 |
+
err = angular_error_deg(-60.0, 5.0, rep.doa_az_deg, rep.doa_el_deg)
|
| 92 |
+
assert err < 8.0, f"ambix DOA err={err}"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_mono_wav_cli_path(tmp_path: Path | None = None):
|
| 96 |
+
base = Path(tmp_path) if tmp_path else Path(tempfile.mkdtemp())
|
| 97 |
+
sr = 16000
|
| 98 |
+
sig = tone(800, 0.2, sr, amplitude=0.4)
|
| 99 |
+
wav = base / "m.wav"
|
| 100 |
+
write_wav(wav, sig, sr)
|
| 101 |
+
rep = report_from_mono_wav(wav, 0.0, 0.0, max_order=1)
|
| 102 |
+
err = angular_error_deg(0.0, 0.0, rep.doa_az_deg, rep.doa_el_deg)
|
| 103 |
+
assert err < 5.0
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
test_wav_roundtrip()
|
| 108 |
+
print("OK test_wav_roundtrip")
|
| 109 |
+
test_mono_encode_report_doa()
|
| 110 |
+
print("OK test_mono_encode_report_doa")
|
| 111 |
+
test_scene_two_sources_json()
|
| 112 |
+
print("OK test_scene_two_sources_json")
|
| 113 |
+
test_ambix_wav_analyze()
|
| 114 |
+
print("OK test_ambix_wav_analyze")
|
| 115 |
+
test_mono_wav_cli_path()
|
| 116 |
+
print("OK test_mono_wav_cli_path")
|
tests/test_phase2_wigner.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 2: Wigner-D HOA rotation accuracy and speed."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from hoa64.analysis import angular_error_deg, doa_from_intensity
|
| 14 |
+
from hoa64.encode import encode_points
|
| 15 |
+
from hoa64.rotate import rotate_matrix_order1, rotate_source_directions, rotate_yaw_pitch_roll
|
| 16 |
+
from hoa64.wigner import hoa_rotation_matrix, rotation_matrix_zyx, apply_hoa_rotation
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_order1_wigner_matches_cartesian():
|
| 20 |
+
a = encode_points([25.0], [-12.0], [1.0])
|
| 21 |
+
for yaw, pitch, roll in [(90, 0, 0), (0, 45, 0), (30, -20, 15), (180, 0, 0)]:
|
| 22 |
+
a_w = rotate_yaw_pitch_roll(
|
| 23 |
+
a, yaw=yaw, pitch=pitch, roll=roll, max_order=1, method="wigner"
|
| 24 |
+
)
|
| 25 |
+
a_c = rotate_matrix_order1(a, yaw=yaw, pitch=pitch, roll=roll)
|
| 26 |
+
np.testing.assert_allclose(a_w[:4], a_c[:4], atol=1e-9, rtol=1e-9)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_wigner_matches_plane_wave_reencode():
|
| 30 |
+
"""Ground truth: rotate source direction, re-encode."""
|
| 31 |
+
cases = [
|
| 32 |
+
(0.0, 0.0, 90.0, 0.0, 0.0),
|
| 33 |
+
(40.0, -15.0, 35.0, 0.0, 0.0),
|
| 34 |
+
(10.0, 20.0, 0.0, 40.0, 0.0),
|
| 35 |
+
(-70.0, 5.0, 20.0, -25.0, 30.0),
|
| 36 |
+
]
|
| 37 |
+
for az0, el0, yaw, pitch, roll in cases:
|
| 38 |
+
a0 = encode_points([az0], [el0], [1.0], max_order=7)
|
| 39 |
+
az1, el1 = rotate_source_directions(az0, el0, yaw=yaw, pitch=pitch, roll=roll)
|
| 40 |
+
a_gt = encode_points([float(az1)], [float(el1)], [1.0], max_order=7)
|
| 41 |
+
a_w = rotate_yaw_pitch_roll(
|
| 42 |
+
a0, yaw=yaw, pitch=pitch, roll=roll, max_order=7, method="wigner"
|
| 43 |
+
)
|
| 44 |
+
# Relative error on full 64-vector
|
| 45 |
+
denom = np.linalg.norm(a_gt) + 1e-15
|
| 46 |
+
rel = np.linalg.norm(a_w[:64] - a_gt[:64]) / denom
|
| 47 |
+
assert rel < 1e-6, (
|
| 48 |
+
f"rel={rel:.3e} for src=({az0},{el0}) rot=({yaw},{pitch},{roll})"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_wigner_orthogonal_blocks():
|
| 53 |
+
R = rotation_matrix_zyx(33.0, -17.0, 8.0, degrees=True)
|
| 54 |
+
M = hoa_rotation_matrix(R, max_order=7)
|
| 55 |
+
# Each order block should be orthogonal (rotation)
|
| 56 |
+
for n in range(0, 8):
|
| 57 |
+
i0 = n * n
|
| 58 |
+
dim = 2 * n + 1
|
| 59 |
+
B = M[i0 : i0 + dim, i0 : i0 + dim]
|
| 60 |
+
I = B.T @ B
|
| 61 |
+
np.testing.assert_allclose(I, np.eye(dim), atol=1e-8)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_wigner_faster_than_dense():
|
| 65 |
+
a = encode_points([15.0], [10.0], [1.0], max_order=7)
|
| 66 |
+
# warm-up
|
| 67 |
+
rotate_yaw_pitch_roll(a, yaw=20.0, pitch=10.0, roll=5.0, method="wigner")
|
| 68 |
+
rotate_yaw_pitch_roll(
|
| 69 |
+
a, yaw=20.0, pitch=10.0, roll=5.0, method="dense", n_azi=48, n_el=24
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
t0 = time.perf_counter()
|
| 73 |
+
for _ in range(50):
|
| 74 |
+
rotate_yaw_pitch_roll(a, yaw=20.0, pitch=10.0, roll=5.0, method="wigner")
|
| 75 |
+
t_w = time.perf_counter() - t0
|
| 76 |
+
|
| 77 |
+
t0 = time.perf_counter()
|
| 78 |
+
for _ in range(5):
|
| 79 |
+
rotate_yaw_pitch_roll(
|
| 80 |
+
a, yaw=20.0, pitch=10.0, roll=5.0, method="dense", n_azi=48, n_el=24
|
| 81 |
+
)
|
| 82 |
+
t_d = time.perf_counter() - t0
|
| 83 |
+
# per-call times
|
| 84 |
+
tw = t_w / 50
|
| 85 |
+
td = t_d / 5
|
| 86 |
+
# Wigner should be substantially faster (typically 50–1000×)
|
| 87 |
+
assert tw < td, f"wigner {tw:.4f}s not faster than dense {td:.4f}s"
|
| 88 |
+
print(f" timing: wigner={tw*1e3:.3f} ms/call dense={td*1e3:.3f} ms/call speedup={td/tw:.0f}x")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_stream_rotation_CT():
|
| 92 |
+
# (C,T) path
|
| 93 |
+
from hoa64.encode import encode_plane_waves
|
| 94 |
+
|
| 95 |
+
t = np.linspace(0, 1, 32, endpoint=False)
|
| 96 |
+
sig = np.sin(2 * np.pi * 3 * t)[None, :]
|
| 97 |
+
hoa = encode_plane_waves([0.0], [0.0], sig, max_order=3)
|
| 98 |
+
out = rotate_yaw_pitch_roll(hoa, yaw=90.0, max_order=3, method="wigner")
|
| 99 |
+
assert out.shape[0] >= 16
|
| 100 |
+
# DOA of first frame energy via products
|
| 101 |
+
W, Y, Z, X = out[0], out[1], out[2], out[3]
|
| 102 |
+
I = np.array([np.mean(W * X), np.mean(W * Y), np.mean(W * Z)])
|
| 103 |
+
n = np.linalg.norm(I)
|
| 104 |
+
assert n > 1e-9
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
test_order1_wigner_matches_cartesian()
|
| 109 |
+
print("OK test_order1_wigner_matches_cartesian")
|
| 110 |
+
test_wigner_matches_plane_wave_reencode()
|
| 111 |
+
print("OK test_wigner_matches_plane_wave_reencode")
|
| 112 |
+
test_wigner_orthogonal_blocks()
|
| 113 |
+
print("OK test_wigner_orthogonal_blocks")
|
| 114 |
+
test_wigner_faster_than_dense()
|
| 115 |
+
print("OK test_wigner_faster_than_dense")
|
| 116 |
+
test_stream_rotation_CT()
|
| 117 |
+
print("OK test_stream_rotation_CT")
|
tests/test_phase3_vision.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3 vision + Qwythos API smoke tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 14 |
+
|
| 15 |
+
from hoa64.analysis import angular_error_deg
|
| 16 |
+
from hoa64.server import TOOL_SCHEMA, handle_analyze
|
| 17 |
+
from hoa64.vision import encode_boxes_to_hoa, fuse_reports, report_from_boxes
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_vision_point_doa():
|
| 21 |
+
rep = report_from_boxes(
|
| 22 |
+
[{"az": 45.0, "el": -10.0, "kind": "point", "weight": 1.0, "label": "p"}],
|
| 23 |
+
max_order=3,
|
| 24 |
+
)
|
| 25 |
+
err = angular_error_deg(45.0, -10.0, rep.doa_az_deg, rep.doa_el_deg)
|
| 26 |
+
# intensity on static field works; peak should be tight
|
| 27 |
+
perr = angular_error_deg(45.0, -10.0, rep.peak_az_deg, rep.peak_el_deg)
|
| 28 |
+
assert perr < 8.0, f"peak err={perr} doa=({rep.doa_az_deg},{rep.doa_el_deg})"
|
| 29 |
+
assert rep.kind == "spatial_vision"
|
| 30 |
+
assert rep.sources_hint[0]["label"] == "p"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_vision_box_lobe_near_center():
|
| 34 |
+
hoa = encode_boxes_to_hoa(
|
| 35 |
+
[{"az": 0.0, "el": 0.0, "w_deg": 15, "h_deg": 15, "weight": 1.0}],
|
| 36 |
+
max_order=3,
|
| 37 |
+
n_azi=48,
|
| 38 |
+
n_el=24,
|
| 39 |
+
)
|
| 40 |
+
assert hoa.shape[0] == 64
|
| 41 |
+
assert np.linalg.norm(hoa) > 0
|
| 42 |
+
# W channel should dominate for broad front lobe
|
| 43 |
+
assert abs(hoa[0]) > 0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_fuse_agreement():
|
| 47 |
+
audio = {"doa_az_deg": 10.0, "doa_el_deg": 0.0, "energy": 1.0}
|
| 48 |
+
vision = {"doa_az_deg": 12.0, "doa_el_deg": 1.0, "energy": 0.8}
|
| 49 |
+
f = fuse_reports(audio, vision)
|
| 50 |
+
assert f["agreement"] is True
|
| 51 |
+
assert f["angular_separation_deg"] < 15
|
| 52 |
+
assert "one_liner" in f
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_fuse_disagreement():
|
| 56 |
+
audio = {"doa_az_deg": 0.0, "doa_el_deg": 0.0, "energy": 1.0}
|
| 57 |
+
vision = {"doa_az_deg": 90.0, "doa_el_deg": 0.0, "energy": 1.0}
|
| 58 |
+
f = fuse_reports(audio, vision)
|
| 59 |
+
assert f["agreement"] is False
|
| 60 |
+
assert f["angular_separation_deg"] > 80
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_handle_analyze_vision_and_demo():
|
| 64 |
+
d = handle_analyze({"mode": "demo_scene", "order": 2})
|
| 65 |
+
assert d["schema"].startswith("spatial-hoa")
|
| 66 |
+
assert "one_liner" in d
|
| 67 |
+
v = handle_analyze(
|
| 68 |
+
{
|
| 69 |
+
"mode": "vision",
|
| 70 |
+
"order": 3,
|
| 71 |
+
"boxes": [{"az": -30, "el": 5, "kind": "ray", "label": "x"}],
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
assert v["kind"] == "spatial_vision"
|
| 75 |
+
err = angular_error_deg(-30, 5, v["peak_az_deg"], v["peak_el_deg"])
|
| 76 |
+
assert err < 10.0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_tool_schema_shape():
|
| 80 |
+
assert TOOL_SCHEMA["type"] == "function"
|
| 81 |
+
assert TOOL_SCHEMA["function"]["name"] == "spatial_analyze"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
test_vision_point_doa()
|
| 86 |
+
print("OK test_vision_point_doa")
|
| 87 |
+
test_vision_box_lobe_near_center()
|
| 88 |
+
print("OK test_vision_box_lobe_near_center")
|
| 89 |
+
test_fuse_agreement()
|
| 90 |
+
print("OK test_fuse_agreement")
|
| 91 |
+
test_fuse_disagreement()
|
| 92 |
+
print("OK test_fuse_disagreement")
|
| 93 |
+
test_handle_analyze_vision_and_demo()
|
| 94 |
+
print("OK test_handle_analyze_vision_and_demo")
|
| 95 |
+
test_tool_schema_shape()
|
| 96 |
+
print("OK test_tool_schema_shape")
|
tests/test_rotate_rnn.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rotation + iterative RNN-stub motion tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 11 |
+
|
| 12 |
+
from hoa64.analysis import angular_error_deg, doa_from_intensity, peak_direction
|
| 13 |
+
from hoa64.encode import encode_points
|
| 14 |
+
from hoa64.rotate import (
|
| 15 |
+
rotate_matrix_order1,
|
| 16 |
+
rotate_source_directions,
|
| 17 |
+
rotate_yaw_pitch_roll,
|
| 18 |
+
)
|
| 19 |
+
from hoa64.rnn_stub import step_rotate, world_from_sources
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_order1_yaw_matches_reencode():
|
| 23 |
+
a = encode_points([0.0], [0.0], [1.0])
|
| 24 |
+
# rotate field +90° yaw: front → left
|
| 25 |
+
a_fast = rotate_matrix_order1(a, yaw=90.0)
|
| 26 |
+
az, el = doa_from_intensity(a_fast)
|
| 27 |
+
err = angular_error_deg(90.0, 0.0, az, el)
|
| 28 |
+
assert err < 1.0, f"intensity DOA after yaw90: ({az},{el}) err={err}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_source_dir_rotation_exact_reencode():
|
| 32 |
+
"""Ground truth: rotate source coordinates, re-encode."""
|
| 33 |
+
az0, el0 = 20.0, -10.0
|
| 34 |
+
a0 = encode_points([az0], [el0], [1.0])
|
| 35 |
+
az1, el1 = rotate_source_directions(az0, el0, yaw=45.0)
|
| 36 |
+
a1 = encode_points([float(az1)], [float(el1)], [1.0])
|
| 37 |
+
# dense rotate of a0 by same yaw should ≈ a1 for low orders
|
| 38 |
+
a_rot = rotate_yaw_pitch_roll(a0, yaw=45.0, max_order=3, n_azi=120, n_el=60)
|
| 39 |
+
# compare order ≤ 3 channels
|
| 40 |
+
nch = 16
|
| 41 |
+
rel = np.linalg.norm(a_rot[:nch] - a1[:nch]) / (np.linalg.norm(a1[:nch]) + 1e-12)
|
| 42 |
+
assert rel < 0.15, f"relative L2 order≤3 after rotate: {rel}"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_rnn_loop_keeps_tracking_after_turns():
|
| 46 |
+
"""Agent turns through 360° in steps; peak should stay world-stable in intensity
|
| 47 |
+
when we interpret DOA in head frame correctly.
|
| 48 |
+
|
| 49 |
+
After total yaw +90 (agent turns left), a world-front source is at head-right
|
| 50 |
+
(−90) in listener frame if field is counter-rotated.
|
| 51 |
+
"""
|
| 52 |
+
st = world_from_sources([0.0], [0.0], [1.0])
|
| 53 |
+
# 6 steps of +15° agent yaw
|
| 54 |
+
for _ in range(6):
|
| 55 |
+
st = step_rotate(st, d_yaw=15.0, max_order=3, dense=True)
|
| 56 |
+
assert abs(st.yaw - 90.0) < 1e-9
|
| 57 |
+
az, el = doa_from_intensity(st.hoa)
|
| 58 |
+
# front source, agent yawed +90 → source appears at az=-90 (right)
|
| 59 |
+
err = angular_error_deg(-90.0, 0.0, az, el)
|
| 60 |
+
assert err < 8.0, f"after +90 agent yaw, head-frame DOA=({az},{el}) err={err}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_iterative_path_energy_stable_order1():
|
| 64 |
+
st = world_from_sources([45.0], [0.0], [1.0])
|
| 65 |
+
e0 = float(np.dot(st.hoa[:4], st.hoa[:4]))
|
| 66 |
+
for _ in range(24):
|
| 67 |
+
st = step_rotate(st, d_yaw=15.0, max_order=1, dense=False)
|
| 68 |
+
e1 = float(np.dot(st.hoa[:4], st.hoa[:4]))
|
| 69 |
+
# full 360° of order-1 rotations should preserve energy exactly
|
| 70 |
+
assert abs(e1 - e0) / e0 < 1e-9
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
for fn in [
|
| 75 |
+
test_order1_yaw_matches_reencode,
|
| 76 |
+
test_source_dir_rotation_exact_reencode,
|
| 77 |
+
test_rnn_loop_keeps_tracking_after_turns,
|
| 78 |
+
test_iterative_path_energy_stable_order1,
|
| 79 |
+
]:
|
| 80 |
+
fn()
|
| 81 |
+
print("OK", fn.__name__)
|
tools/spatial_analyze.openai.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tools": [
|
| 3 |
+
{
|
| 4 |
+
"type": "function",
|
| 5 |
+
"function": {
|
| 6 |
+
"name": "spatial_analyze",
|
| 7 |
+
"description": "HOA-7 spatial calculator (not an LLM). Analyze Ambix audio, mono plane-wave audio, synthetic scenes, or vision boxes on the sphere. Returns DOA, energy, bands, frames. Call via HTTP POST http://127.0.0.1:8765/v1/spatial/analyze or bash: spatial-report ...",
|
| 8 |
+
"parameters": {
|
| 9 |
+
"type": "object",
|
| 10 |
+
"properties": {
|
| 11 |
+
"mode": {
|
| 12 |
+
"type": "string",
|
| 13 |
+
"enum": [
|
| 14 |
+
"ambix_file",
|
| 15 |
+
"mono_file",
|
| 16 |
+
"demo_scene",
|
| 17 |
+
"vision",
|
| 18 |
+
"fuse",
|
| 19 |
+
"detect",
|
| 20 |
+
"live",
|
| 21 |
+
"condition"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
"image": { "type": "string", "description": "Image path for detect mode" },
|
| 25 |
+
"duration": { "type": "number", "description": "Seconds for live mic capture" },
|
| 26 |
+
"prompt": { "type": "string", "description": "Base diffusion prompt for condition mode" },
|
| 27 |
+
"report": { "type": "object", "description": "Spatial report object for condition mode" },
|
| 28 |
+
"path": { "type": "string" },
|
| 29 |
+
"az": { "type": "number" },
|
| 30 |
+
"el": { "type": "number" },
|
| 31 |
+
"order": { "type": "integer", "default": 3 },
|
| 32 |
+
"boxes": {
|
| 33 |
+
"type": "array",
|
| 34 |
+
"items": {
|
| 35 |
+
"type": "object",
|
| 36 |
+
"properties": {
|
| 37 |
+
"az": { "type": "number" },
|
| 38 |
+
"el": { "type": "number" },
|
| 39 |
+
"w_deg": { "type": "number" },
|
| 40 |
+
"h_deg": { "type": "number" },
|
| 41 |
+
"weight": { "type": "number" },
|
| 42 |
+
"label": { "type": "string" },
|
| 43 |
+
"kind": { "type": "string", "enum": ["box", "ray", "point"] }
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
},
|
| 48 |
+
"required": ["mode"]
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
],
|
| 53 |
+
"http": {
|
| 54 |
+
"base": "http://127.0.0.1:8765",
|
| 55 |
+
"analyze": "POST /v1/spatial/analyze",
|
| 56 |
+
"health": "GET /health",
|
| 57 |
+
"schema": "GET /v1/spatial/schema"
|
| 58 |
+
},
|
| 59 |
+
"bash": {
|
| 60 |
+
"demo": "spatial-report demo-scene -o /tmp/spatial.json",
|
| 61 |
+
"ambix": "spatial-report analyze FILE.wav --ambix -o /tmp/spatial.json",
|
| 62 |
+
"mono": "spatial-report analyze FILE.wav --az 30 --el 0 -o /tmp/spatial.json",
|
| 63 |
+
"vision": "spatial-report vision --boxes '[{\"az\":0,\"el\":0,\"label\":\"obj\"}]' -o /tmp/vision.json"
|
| 64 |
+
}
|
| 65 |
+
}
|