File size: 7,211 Bytes
570b87b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Time-domain HOA streams: encode mono sources, frame-wise analysis."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Sequence

import numpy as np

from .analysis import (
    angular_error_deg,
    doa_from_intensity,
    field_energy,
    peak_direction,
)
from .audio_io import ensure_hoa_channels
from .basis import MAX_ORDER, N_CHANNELS, sh_sn3d
from .encode import encode_plane_waves, mix
from .stft import frame_multichannel, hann_window, stft, stft_freqs


@dataclass
class SourceSpec:
    """One plane-wave source in a synthetic or annotated scene."""

    azimuth_deg: float
    elevation_deg: float
    signal: np.ndarray  # (n_samples,)
    label: str = ""


def encode_mono_plane_wave(
    signal: np.ndarray,
    azimuth_deg: float,
    elevation_deg: float = 0.0,
    *,
    max_order: int = MAX_ORDER,
) -> np.ndarray:
    """Mono signal from a known direction → Ambix HOA stream (C, T)."""
    sig = np.asarray(signal, dtype=np.float64).reshape(1, -1)
    return encode_plane_waves(
        [azimuth_deg],
        [elevation_deg],
        sig,
        degrees=True,
        max_order=max_order,
    )


def encode_scene(
    sources: Sequence[SourceSpec],
    *,
    max_order: int = MAX_ORDER,
) -> np.ndarray:
    """Superpose multiple plane-wave sources into one HOA stream (C, T)."""
    if not sources:
        raise ValueError("sources must be non-empty")
    lengths = [int(np.asarray(s.signal).reshape(-1).shape[0]) for s in sources]
    T = max(lengths)
    fields = []
    for s in sources:
        sig = np.asarray(s.signal, dtype=np.float64).reshape(-1)
        if sig.shape[0] < T:
            sig = np.pad(sig, (0, T - sig.shape[0]))
        elif sig.shape[0] > T:
            sig = sig[:T]
        fields.append(
            encode_mono_plane_wave(
                sig, s.azimuth_deg, s.elevation_deg, max_order=max_order
            )
        )
    out = fields[0]
    for f in fields[1:]:
        out = out + f
    return out


def hoa_rms(hoa: np.ndarray) -> np.ndarray:
    """Per-channel RMS. hoa (C,T) → (C,)."""
    a = np.asarray(hoa, dtype=np.float64)
    return np.sqrt(np.mean(a * a, axis=-1) + 1e-30)


@dataclass
class FrameAnalysis:
    t_center_sec: float
    energy: float
    doa_az_deg: float
    doa_el_deg: float
    peak_az_deg: float
    peak_el_deg: float
    peak_power: float
    order1_energy: float


def analyze_hoa_frames(
    hoa: np.ndarray,
    sample_rate: int,
    *,
    frame_ms: float = 40.0,
    hop_ms: float = 20.0,
    max_order: int = MAX_ORDER,
    peak_grid: bool = False,
) -> list[FrameAnalysis]:
    """Short-time spatial analysis of an HOA stream.

    Uses rectangular frames; DOA from order-1 intensity on frame-averaged
    (or energy-weighted) coefficients. Optional dense peak per frame is slower.
    """
    a = ensure_hoa_channels(hoa, max_order=max_order)
    frame_len = max(1, int(round(sample_rate * frame_ms / 1000.0)))
    hop = max(1, int(round(sample_rate * hop_ms / 1000.0)))
    frames = frame_multichannel(a, frame_len=frame_len, hop=hop)
    # Energy-weighted mean coefficient per frame: sum_t a[c,t]*|a_w| style —
    # use simple mean of coeffs (works for quasi-stationary plane waves).
    win = hann_window(frame_len)
    win = win / (np.sum(win) + 1e-30)

    out: list[FrameAnalysis] = []
    nch_o1 = 4
    for i in range(frames.shape[0]):
        block = frames[i]  # (C, L)
        # AC-safe: do NOT average coeffs (→0 for audio). Use intensity products.
        w = win  # (L,)
        # weighted instantaneous intensity ~ W*X etc.
        W = block[0] * w
        Yc = block[1] * w
        Zc = block[2] * w
        Xc = block[3] * w
        I = np.array(
            [
                float(np.sum(W * Xc)),
                float(np.sum(W * Yc)),
                float(np.sum(W * Zc)),
            ],
            dtype=np.float64,
        )
        nrm = float(np.linalg.norm(I))
        if nrm < 1e-18:
            az, el = 0.0, 0.0
        else:
            from .basis import az_el_from_unit

            az, el = az_el_from_unit(I / nrm, degrees=True)
            az, el = float(az), float(el)

        # RMS energy of the frame
        energy = float(np.mean(np.sum(block * block, axis=0)))
        o1e = float(np.mean(np.sum(block[:nch_o1] ** 2, axis=0)))

        if peak_grid:
            # Build a pseudo-static vector: sign-stable energy-weighted mean
            # via sqrt of mean squares * sign of correlation with W
            rms = np.sqrt(np.mean(block * block, axis=1) + 1e-30)
            sign = np.sign(np.mean(block * block[0:1, :], axis=1) + 1e-30)
            pseudo = rms * sign
            paz, pel, pv = peak_direction(
                pseudo, n_azi=48, n_el=24, max_order=min(max_order, 3)
            )
        else:
            paz, pel, pv = float(az), float(el), energy
        t_center = (i * hop + 0.5 * frame_len) / float(sample_rate)
        out.append(
            FrameAnalysis(
                t_center_sec=t_center,
                energy=energy,
                doa_az_deg=float(az),
                doa_el_deg=float(el),
                peak_az_deg=float(paz),
                peak_el_deg=float(pel),
                peak_power=float(pv),
                order1_energy=o1e,
            )
        )
    return out


def analyze_hoa_stft_bands(
    hoa: np.ndarray,
    sample_rate: int,
    *,
    n_fft: int = 1024,
    hop: int = 256,
    band_edges_hz: Sequence[float] | None = None,
) -> list[dict]:
    """Per-frequency-band intensity DOA using order-1 HOA channels only.

    Returns list of {band_hz: [lo,hi], doa_az, doa_el, energy}.
    """
    a = ensure_hoa_channels(hoa, max_order=1)
    if band_edges_hz is None:
        band_edges_hz = [0, 250, 500, 1000, 2000, 4000, 8000, sample_rate / 2]

    # STFT of W,Y,Z,X
    specs = []
    for c in range(4):
        S = stft(a[c], n_fft=n_fft, hop=hop)
        specs.append(S)
    freqs = stft_freqs(n_fft, sample_rate)
    # Time-average power-weighted intensity per bin then fold into bands
    W, Y, Z, X = specs
    # Use complex conjugate product for active intensity-like measure
    # I_x ~ Re(W * conj(X)), etc., averaged over frames
    Ix = np.mean(np.real(W * np.conj(X)), axis=1)
    Iy = np.mean(np.real(W * np.conj(Y)), axis=1)
    Iz = np.mean(np.real(W * np.conj(Z)), axis=1)
    Ew = np.mean(np.abs(W) ** 2, axis=1)

    edges = list(band_edges_hz)
    reports = []
    for lo, hi in zip(edges[:-1], edges[1:]):
        mask = (freqs >= lo) & (freqs < hi)
        if not np.any(mask):
            continue
        I = np.array(
            [np.sum(Ix[mask]), np.sum(Iy[mask]), np.sum(Iz[mask])],
            dtype=np.float64,
        )
        n = np.linalg.norm(I)
        if n < 1e-15:
            az, el = 0.0, 0.0
        else:
            from .basis import az_el_from_unit

            az, el = az_el_from_unit(I / n, degrees=True)
            az, el = float(az), float(el)
        reports.append(
            {
                "band_hz": [float(lo), float(hi)],
                "doa_az_deg": az,
                "doa_el_deg": el,
                "energy": float(np.sum(Ew[mask])),
            }
        )
    return reports