woodfireind commited on
Commit
570b87b
·
verified ·
1 Parent(s): 5033fc7

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 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
+ }