HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
570b87b verified | """CLI: spatial-hoa analyze — produce JSON reports for other models. | |
| Examples | |
| -------- | |
| python -m hoa64.cli analyze scene.wav --ambix | |
| python -m hoa64.cli analyze mono.wav --az 30 --el 0 -o report.json | |
| python -m hoa64.cli demo-scene -o /tmp/scene_report.json | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| from . import __version__ | |
| from .audio_io import write_wav | |
| from .report import ( | |
| report_from_ambix_wav, | |
| report_from_mono_wav, | |
| report_from_scene, | |
| ) | |
| from .stream import SourceSpec, encode_scene | |
| from .synth import envelope_adsr, tone | |
| def _cmd_analyze(args: argparse.Namespace) -> int: | |
| path = Path(args.input) | |
| if not path.is_file(): | |
| print(f"error: file not found: {path}", file=sys.stderr) | |
| return 2 | |
| kwargs = dict( | |
| max_order=args.order, | |
| frame_ms=args.frame_ms, | |
| hop_ms=args.hop_ms, | |
| include_frames=not args.no_frames, | |
| include_bands=not args.no_bands, | |
| ) | |
| if args.ambix: | |
| rep = report_from_ambix_wav(path, **kwargs) | |
| else: | |
| if args.az is None: | |
| print( | |
| "error: mono plane-wave encode needs --az (or pass --ambix)", | |
| file=sys.stderr, | |
| ) | |
| return 2 | |
| rep = report_from_mono_wav( | |
| path, args.az, args.el if args.el is not None else 0.0, **kwargs | |
| ) | |
| text = rep.to_json(indent=2 if not args.compact else None) | |
| if args.output: | |
| Path(args.output).write_text(text + "\n", encoding="utf-8") | |
| print(rep.one_liner()) | |
| print(f"wrote {args.output}") | |
| else: | |
| print(text) | |
| return 0 | |
| def _cmd_demo_scene(args: argparse.Namespace) -> int: | |
| sr = args.sr | |
| dur = args.duration | |
| n = int(sr * dur) | |
| env = envelope_adsr(n, sr) | |
| s1 = tone(440.0, dur, sr, amplitude=0.4) * env | |
| s2 = tone(660.0, dur, sr, amplitude=0.25) * env | |
| sources = [ | |
| SourceSpec(0.0, 0.0, s1, label="front_A4"), | |
| SourceSpec(90.0, 15.0, s2, label="left_E5"), | |
| ] | |
| rep = report_from_scene( | |
| sources, | |
| sr, | |
| max_order=args.order, | |
| frame_ms=args.frame_ms, | |
| hop_ms=args.hop_ms, | |
| ) | |
| if args.write_wav: | |
| hoa = encode_scene(sources, max_order=min(args.order, 3)) | |
| # Write order-1 B-format-ish (first 4 ch) for portability | |
| write_wav(args.write_wav, hoa[:4], sr) | |
| print(f"wrote ambix-lite WAV {args.write_wav} (4 ch)") | |
| text = rep.to_json(indent=2 if not args.compact else None) | |
| if args.output: | |
| Path(args.output).write_text(text + "\n", encoding="utf-8") | |
| print(rep.one_liner()) | |
| print(f"wrote {args.output}") | |
| else: | |
| print(text) | |
| return 0 | |
| def _cmd_serve(args: argparse.Namespace) -> int: | |
| from .server import serve | |
| serve(args.host, args.port) | |
| return 0 | |
| def _cmd_detect(args: argparse.Namespace) -> int: | |
| import json | |
| from .detector import ( | |
| detect_to_sphere, | |
| detections_to_sphere_boxes, | |
| load_boxes_json, | |
| load_yolo_labels, | |
| write_demo_image_with_box, | |
| Detection, | |
| ) | |
| from .vision import report_from_boxes | |
| if args.demo_image: | |
| path = Path(args.demo_image) | |
| det = write_demo_image_with_box(path) | |
| boxes = detections_to_sphere_boxes([det], hfov_deg=args.hfov, vfov_deg=args.vfov) | |
| print(f"wrote demo image {path}") | |
| elif args.yolo_labels: | |
| dets = load_yolo_labels(args.yolo_labels) | |
| boxes = detections_to_sphere_boxes(dets, hfov_deg=args.hfov, vfov_deg=args.vfov) | |
| elif args.boxes_json: | |
| boxes = load_boxes_json(args.boxes_json) | |
| elif args.image: | |
| img_path = Path(args.image) | |
| if not img_path.is_file(): | |
| print( | |
| f"error: image not found: {img_path}\n" | |
| " /path/to/photo.jpg was only an example placeholder.\n" | |
| " Try a real file, or:\n" | |
| " spatial-report detect --demo-image /tmp/demo.png -o /tmp/det.json\n" | |
| " spatial-report detect --image /tmp/spatial_hoa_e2e/demo_frame.png -o /tmp/det.json", | |
| file=sys.stderr, | |
| ) | |
| return 2 | |
| try: | |
| boxes = detect_to_sphere( | |
| img_path, | |
| backend=args.backend, | |
| score_thresh=args.score, | |
| hfov_deg=args.hfov, | |
| vfov_deg=args.vfov, | |
| ) | |
| except Exception as e: | |
| print(f"error: detector failed: {e}", file=sys.stderr) | |
| print( | |
| " Fallback without neural net:\n" | |
| " spatial-report detect --demo-image /tmp/demo.png -o /tmp/det.json", | |
| file=sys.stderr, | |
| ) | |
| return 1 | |
| if not boxes: | |
| print( | |
| "warning: no detections above score threshold " | |
| "(try --score 0.2, or pass --boxes-json / --demo-image)", | |
| file=sys.stderr, | |
| ) | |
| else: | |
| print( | |
| "error: need --image, --boxes-json, --yolo-labels, or --demo-image\n" | |
| " Example: spatial-report detect --demo-image /tmp/demo.png -o /tmp/det.json", | |
| file=sys.stderr, | |
| ) | |
| return 2 | |
| rep = report_from_boxes(boxes, max_order=args.order) | |
| text = rep.to_json(indent=2 if not args.compact else None) | |
| if args.output: | |
| Path(args.output).write_text(text + "\n", encoding="utf-8") | |
| print(rep.one_liner()) | |
| print(f"wrote {args.output}") | |
| else: | |
| print(text) | |
| if args.write_boxes: | |
| Path(args.write_boxes).write_text(json.dumps(boxes, indent=2) + "\n") | |
| print(f"wrote boxes {args.write_boxes}") | |
| return 0 | |
| def _cmd_live(args: argparse.Namespace) -> int: | |
| from .live_audio import live_report, list_pulse_sources | |
| if args.list_sources: | |
| for s in list_pulse_sources(): | |
| print(s) | |
| return 0 | |
| rep = live_report( | |
| duration_sec=args.duration, | |
| sample_rate=args.sr, | |
| channels=args.channels, | |
| source=args.source, | |
| az_deg=args.az, | |
| el_deg=args.el, | |
| max_order=args.order, | |
| keep_wav=args.write_wav, | |
| ) | |
| text = rep.to_json(indent=2 if not args.compact else None) | |
| if args.output: | |
| Path(args.output).write_text(text + "\n", encoding="utf-8") | |
| print(rep.one_liner()) | |
| print(f"wrote {args.output}") | |
| else: | |
| print(text) | |
| return 0 | |
| def _cmd_condition(args: argparse.Namespace) -> int: | |
| import json | |
| from .conditioning import ( | |
| build_conditioning, | |
| comfy_txt2img_payload, | |
| condition_from_report_file, | |
| save_conditioning, | |
| submit_comfy_prompt, | |
| load_report, | |
| ) | |
| rep = load_report(args.report) | |
| cond = build_conditioning( | |
| rep, base_prompt=args.prompt or "", style=args.style | |
| ) | |
| if args.output: | |
| save_conditioning(cond, args.output) | |
| print(cond["positive_prompt"]) | |
| print(f"wrote {args.output}") | |
| else: | |
| print(json.dumps(cond, indent=2)) | |
| if args.comfy or args.write_workflow: | |
| from .conditioning import resolve_comfy_checkpoint, list_comfy_checkpoints | |
| ckpt = args.checkpoint | |
| if not ckpt or args.auto_checkpoint: | |
| ckpt = resolve_comfy_checkpoint(ckpt, base_url=args.comfy_url) | |
| print(f"comfy checkpoint: {ckpt}") | |
| wf = comfy_txt2img_payload( | |
| cond, | |
| checkpoint=ckpt, | |
| width=args.width, | |
| height=args.height, | |
| steps=args.steps, | |
| seed=args.seed, | |
| base_url=args.comfy_url, | |
| auto_checkpoint=False, | |
| ) | |
| if args.write_workflow: | |
| # Write API-ready payload so `curl -d @file` works | |
| api_body = {"prompt": wf, "client_id": "spatial-hoa"} | |
| Path(args.write_workflow).write_text( | |
| json.dumps(api_body, indent=2) + "\n" | |
| ) | |
| print(f"wrote API workflow {args.write_workflow}") | |
| if args.comfy: | |
| result = submit_comfy_prompt(wf, base_url=args.comfy_url) | |
| print(json.dumps(result, indent=2)) | |
| if result.get("error") and result.get("available_checkpoints"): | |
| print( | |
| "available checkpoints:\n " | |
| + "\n ".join(result["available_checkpoints"]), | |
| file=sys.stderr, | |
| ) | |
| return 0 | |
| def _cmd_vision(args: argparse.Namespace) -> int: | |
| import json | |
| from .vision import report_from_boxes | |
| if args.boxes_json: | |
| boxes = json.loads(Path(args.boxes_json).read_text(encoding="utf-8")) | |
| elif args.boxes: | |
| boxes = json.loads(args.boxes) | |
| else: | |
| # demo boxes | |
| boxes = [ | |
| {"az": 0, "el": 0, "w_deg": 10, "h_deg": 10, "weight": 1.0, "label": "front"}, | |
| {"az": 90, "el": 5, "w_deg": 12, "h_deg": 12, "weight": 0.7, "label": "left"}, | |
| ] | |
| if isinstance(boxes, dict) and "boxes" in boxes: | |
| boxes = boxes["boxes"] | |
| rep = report_from_boxes(boxes, max_order=args.order) | |
| text = rep.to_json(indent=2 if not args.compact else None) | |
| if args.output: | |
| Path(args.output).write_text(text + "\n", encoding="utf-8") | |
| print(rep.one_liner()) | |
| print(f"wrote {args.output}") | |
| else: | |
| print(text) | |
| return 0 | |
| def build_parser() -> argparse.ArgumentParser: | |
| p = argparse.ArgumentParser( | |
| prog="hoa64", | |
| description="HOA-7 spatial calculator (audio + vision + JSON for Qwythos)", | |
| ) | |
| p.add_argument("--version", action="version", version=f"hoa64 {__version__}") | |
| sub = p.add_subparsers(dest="cmd", required=True) | |
| a = sub.add_parser("analyze", help="Analyze a WAV → spatial JSON report") | |
| a.add_argument("input", help="Path to WAV") | |
| a.add_argument( | |
| "--ambix", | |
| action="store_true", | |
| help="Input is multi-channel Ambix ACN (not mono plane-wave)", | |
| ) | |
| a.add_argument("--az", type=float, default=None, help="Plane-wave azimuth (deg)") | |
| a.add_argument("--el", type=float, default=0.0, help="Plane-wave elevation (deg)") | |
| a.add_argument("--order", type=int, default=7, help="Max HOA order (default 7)") | |
| a.add_argument("--frame-ms", type=float, default=40.0) | |
| a.add_argument("--hop-ms", type=float, default=20.0) | |
| a.add_argument("--no-frames", action="store_true") | |
| a.add_argument("--no-bands", action="store_true") | |
| a.add_argument("-o", "--output", help="Write JSON to path") | |
| a.add_argument("--compact", action="store_true", help="Minified JSON") | |
| a.set_defaults(func=_cmd_analyze) | |
| d = sub.add_parser("demo-scene", help="Synthetic 2-source scene → report") | |
| d.add_argument("--sr", type=int, default=48000) | |
| d.add_argument("--duration", type=float, default=0.5) | |
| d.add_argument("--order", type=int, default=7) | |
| d.add_argument("--frame-ms", type=float, default=40.0) | |
| d.add_argument("--hop-ms", type=float, default=20.0) | |
| d.add_argument("-o", "--output", help="Write JSON to path") | |
| d.add_argument("--write-wav", help="Also write 4-ch Ambix-lite WAV") | |
| d.add_argument("--compact", action="store_true") | |
| d.set_defaults(func=_cmd_demo_scene) | |
| v = sub.add_parser("vision", help="Vision boxes/rays → spatial JSON report") | |
| v.add_argument( | |
| "--boxes", | |
| help='JSON array of boxes, e.g. \'[{"az":0,"el":0,"weight":1}]\'', | |
| ) | |
| v.add_argument("--boxes-json", help="Path to JSON file with boxes array") | |
| v.add_argument("--order", type=int, default=3) | |
| v.add_argument("-o", "--output", help="Write JSON to path") | |
| v.add_argument("--compact", action="store_true") | |
| v.set_defaults(func=_cmd_vision) | |
| s = sub.add_parser("serve", help="HTTP API for Qwythos/agents (default :8765)") | |
| s.add_argument("--host", default="127.0.0.1") | |
| s.add_argument("--port", type=int, default=8765) | |
| s.set_defaults(func=_cmd_serve) | |
| det = sub.add_parser("detect", help="Image/YOLO/JSON → vision spatial report") | |
| det.add_argument("--image", help="Image path (optional torchvision detector)") | |
| det.add_argument("--boxes-json", help="Precomputed boxes or detections JSON") | |
| det.add_argument("--yolo-labels", help="YOLO .txt labels for an image") | |
| det.add_argument("--demo-image", help="Write synthetic image+box to this path and analyze") | |
| det.add_argument("--backend", default="auto", choices=["auto", "torchvision", "none"]) | |
| det.add_argument("--score", type=float, default=0.5) | |
| det.add_argument("--hfov", type=float, default=90.0) | |
| det.add_argument("--vfov", type=float, default=60.0) | |
| det.add_argument("--order", type=int, default=3) | |
| det.add_argument("-o", "--output", help="Spatial report JSON") | |
| det.add_argument("--write-boxes", help="Write sphere boxes JSON") | |
| det.add_argument("--compact", action="store_true") | |
| det.set_defaults(func=_cmd_detect) | |
| live = sub.add_parser("live", help="Capture mic → spatial report") | |
| live.add_argument("--duration", type=float, default=2.0) | |
| live.add_argument("--sr", type=int, default=48000) | |
| live.add_argument("--channels", type=int, default=1) | |
| live.add_argument("--source", help="Pulse source name (pactl list short sources)") | |
| live.add_argument("--list-sources", action="store_true") | |
| live.add_argument("--az", type=float, default=0.0, help="Plane-wave az if mono") | |
| live.add_argument("--el", type=float, default=0.0) | |
| live.add_argument("--order", type=int, default=3) | |
| live.add_argument("-o", "--output") | |
| live.add_argument("--write-wav", help="Keep captured WAV") | |
| live.add_argument("--compact", action="store_true") | |
| live.set_defaults(func=_cmd_live) | |
| cond = sub.add_parser("condition", help="Spatial report → diffusion conditioning") | |
| cond.add_argument("report", help="Path to spatial/fuse report JSON") | |
| cond.add_argument("--prompt", default="cinematic still, photoreal") | |
| cond.add_argument("--style", default="natural", choices=["natural", "tags", "technical"]) | |
| cond.add_argument("-o", "--output", help="Write conditioning JSON") | |
| cond.add_argument("--comfy", action="store_true", help="Submit minimal workflow to ComfyUI") | |
| cond.add_argument("--comfy-url", default="http://127.0.0.1:8188") | |
| cond.add_argument( | |
| "--checkpoint", | |
| default=None, | |
| help="ComfyUI ckpt_name (default: auto-detect from /object_info)", | |
| ) | |
| cond.add_argument( | |
| "--auto-checkpoint", | |
| action=argparse.BooleanOptionalAction, | |
| default=True, | |
| help="Resolve checkpoint against ComfyUI's installed list (default: true)", | |
| ) | |
| cond.add_argument("--width", type=int, default=None, help="Latent width (default: 1024 XL / 512 SD)") | |
| cond.add_argument("--height", type=int, default=None) | |
| cond.add_argument("--steps", type=int, default=20) | |
| cond.add_argument("--seed", type=int, default=0) | |
| cond.add_argument( | |
| "--write-workflow", | |
| help="Save ComfyUI API JSON ({prompt: graph}) for curl -d @file", | |
| ) | |
| cond.set_defaults(func=_cmd_condition) | |
| return p | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = build_parser() | |
| args = parser.parse_args(argv) | |
| return int(args.func(args)) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |