HOA7-Spatial-Decoder / hoa64 /audio_io.py
woodfireind's picture
HOA7 Spatial Field Decoder (hoa64 v0.5.0): 7th-order Ambisonics encode/decode, Wigner-D rotation, DOA analysis, vision fuse, diffusion conditioning
570b87b verified
Raw
History Blame Contribute Delete
3.87 kB
"""WAV read/write without scipy/soundfile (stdlib wave + numpy).
Supports mono, multi-channel (e.g. Ambix 4/16/64), and float32/int16.
"""
from __future__ import annotations
import struct
import wave
from pathlib import Path
from typing import Tuple, Union
import numpy as np
PathLike = Union[str, Path]
def read_wav(path: PathLike) -> Tuple[np.ndarray, int]:
"""Read a WAV file.
Returns
-------
audio : np.ndarray
Shape (n_channels, n_samples), float64 in roughly [-1, 1].
sample_rate : int
"""
path = Path(path)
with wave.open(str(path), "rb") as wf:
nch = wf.getnchannels()
sw = wf.getsampwidth()
sr = wf.getframerate()
nframes = wf.getnframes()
raw = wf.readframes(nframes)
if sw == 2:
mono = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
elif sw == 4:
# Try 32-bit int first; if file is float32 PCM some writers use WAVE_FORMAT_IEEE_FLOAT
# which wave may still hand us as bytes — detect by scale.
arr_i = np.frombuffer(raw, dtype="<i4")
# Heuristic: if max abs > 2^30-ish it's int; else might be float bits misread.
# Prefer IEEE float if values look like floats when reinterpreted.
arr_f = np.frombuffer(raw, dtype="<f4").astype(np.float64)
if np.max(np.abs(arr_f)) <= 8.0 and np.max(np.abs(arr_i)) > 1000:
mono = arr_f
else:
mono = arr_i.astype(np.float64) / 2147483648.0
elif sw == 3:
# 24-bit packed little-endian
a = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
vals = (
a[:, 0].astype(np.int32)
| (a[:, 1].astype(np.int32) << 8)
| (a[:, 2].astype(np.int32) << 16)
)
vals = np.where(vals >= 0x800000, vals - 0x1000000, vals)
mono = vals.astype(np.float64) / 8388608.0
elif sw == 1:
mono = (np.frombuffer(raw, dtype=np.uint8).astype(np.float64) - 128.0) / 128.0
else:
raise ValueError(f"unsupported sample width {sw}")
if nch == 1:
audio = mono.reshape(1, -1)
else:
audio = mono.reshape(-1, nch).T.copy()
return audio, int(sr)
def write_wav(
path: PathLike,
audio: np.ndarray,
sample_rate: int,
*,
subtype: str = "pcm16",
) -> None:
"""Write WAV. audio shape (n_channels, n_samples) or (n_samples,)."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
a = np.asarray(audio, dtype=np.float64)
if a.ndim == 1:
a = a.reshape(1, -1)
if a.ndim != 2:
raise ValueError("audio must be (C,T) or (T,)")
nch, n_samples = a.shape
a = np.clip(a, -1.0, 1.0)
if subtype == "pcm16":
pcm = (a.T.reshape(-1) * 32767.0).astype("<i2")
sw = 2
raw = pcm.tobytes()
elif subtype == "float32":
pcm = a.T.reshape(-1).astype("<f4")
sw = 4
raw = pcm.tobytes()
else:
raise ValueError("subtype must be pcm16 or float32")
with wave.open(str(path), "wb") as wf:
wf.setnchannels(nch)
wf.setsampwidth(sw)
wf.setframerate(int(sample_rate))
# wave module doesn't set IEEE float format tag; pcm16 is portable.
if subtype == "float32":
# Still write bytes; many tools accept float32 wav with format 3
# but stdlib wave always writes PCM. Prefer pcm16 for portability.
pass
wf.writeframes(raw)
def ensure_hoa_channels(audio: np.ndarray, max_order: int = 7) -> np.ndarray:
"""Pad or truncate multi-channel audio to (max_order+1)**2 Ambix channels."""
nch = (max_order + 1) ** 2
a = np.asarray(audio, dtype=np.float64)
if a.ndim == 1:
a = a.reshape(1, -1)
out = np.zeros((nch, a.shape[1]), dtype=np.float64)
n = min(nch, a.shape[0])
out[:n] = a[:n]
return out