ambiviz 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ambiviz-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: ambiviz
3
+ Version: 0.0.1
4
+ Summary: Visualization tools for ambisonic audio and 360 video
5
+ Author-email: Arthur Jinyue Guo <jinyue.guo@imv.uio.no>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=2.0.2
9
+ Requires-Dist: matplotlib>=3.7
10
+ Requires-Dist: librosa>=0.10
11
+ Requires-Dist: torchaudio>=2.0
12
+ Requires-Dist: opencv-python>=4.8
13
+ Requires-Dist: Pillow>=10.0
14
+ Requires-Dist: tqdm>=4.0
15
+ Requires-Dist: scipy>=1.10
16
+ Requires-Dist: scienceplots>=2.2.1
17
+ Requires-Dist: musicalgestures>=1.3.3
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+
21
+ # ambiviz
22
+
23
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
24
+ [![Actions status](https://github.com/fisheggg/ambiviz/workflows/ci.yml/badge.svg)](https://github.com/fisheggg/ambiviz/actions)
25
+
26
+
27
+ Visualization tools for ambisonic audio (and 360 video).
28
+
29
+
30
+ ## References
31
+
32
+ - The core ambisonics decoder is modified from [https://github.com/pedro-morgado/spatialaudiogen](https://github.com/pedro-morgado/spatialaudiogen).
@@ -0,0 +1,12 @@
1
+ # ambiviz
2
+
3
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
4
+ [![Actions status](https://github.com/fisheggg/ambiviz/workflows/ci.yml/badge.svg)](https://github.com/fisheggg/ambiviz/actions)
5
+
6
+
7
+ Visualization tools for ambisonic audio (and 360 video).
8
+
9
+
10
+ ## References
11
+
12
+ - The core ambisonics decoder is modified from [https://github.com/pedro-morgado/spatialaudiogen](https://github.com/pedro-morgado/spatialaudiogen).
@@ -0,0 +1,10 @@
1
+ from .aem import aem
2
+ from .anglegram import anglegram
3
+ from .directogram import directogram
4
+
5
+
6
+ __all__ = [
7
+ "aem",
8
+ "anglegram",
9
+ "directogram",
10
+ ]
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import os
5
+ import subprocess
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ import cv2
11
+ import librosa
12
+ import musicalgestures
13
+ import numpy as np
14
+ import PIL
15
+ import tqdm
16
+
17
+ from .ambisonics.audio_to_aem import compute_aem
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def aem(
23
+ audio_path: str,
24
+ video_paths: List[str],
25
+ save_path: str | Path,
26
+ duration: float,
27
+ audio_offset: float = 0.0,
28
+ video_offset: float = 0.0,
29
+ normalization_mode: str = "global",
30
+ use_gpu: bool = False,
31
+ aem_mode: str = "aem",
32
+ aem_mask_alpha: float = 0.5,
33
+ verbose: bool = False,
34
+ to_db: bool = False,
35
+ **kwargs,
36
+ ):
37
+ """
38
+ Takes an FOA audio and a 360 video, plots the audio energy map on the video.
39
+
40
+ Parameters
41
+ ----------
42
+ normalization_mode: "global" or "local"
43
+ if "global", normalize the AEM according to the min/max over the entire video.
44
+ if "local", normalize the AEM of each frame by itself
45
+ """
46
+ assert normalization_mode in ["global", "local"]
47
+ assert aem_mode in ["aem"]
48
+
49
+ if verbose:
50
+ logging.basicConfig(level=logging.INFO)
51
+
52
+ # Load video
53
+ logger.info("Loading video...")
54
+ video, fps = musicalgestures.Mg360Video(
55
+ video_paths,
56
+ projection="equirectangular",
57
+ starttime=video_offset,
58
+ endtime=video_offset + duration,
59
+ ).numpy()
60
+ video = np.copy(video) # make writable
61
+ logger.info(f"Video shape: {video.shape}")
62
+ logger.info(f"Video FPS: {fps}")
63
+ logger.info(
64
+ f"Video stats: max={np.max(video)}, min={np.min(video)}, mean={np.mean(video)}"
65
+ )
66
+ logger.info(f"Video dtype: {video.dtype}")
67
+
68
+ video_frame_shape = video.shape[1:3]
69
+ video = video * 0.95 # reduce brightness
70
+
71
+ audio_sr = librosa.get_samplerate(audio_path)
72
+ aem, timestamp, phi_mesh, nu_mesh = compute_aem(
73
+ audio_path,
74
+ mode=aem_mode,
75
+ fps=fps,
76
+ audio_frame_length=int(audio_sr * 4 / fps),
77
+ audio_hop_length=int(audio_sr * 2 / fps),
78
+ gpu=use_gpu,
79
+ verbose=verbose,
80
+ duration=duration,
81
+ offset=audio_offset,
82
+ to_db=to_db,
83
+ **kwargs,
84
+ )
85
+ # cv2 counts video frame from top left,
86
+ # aem counts from bottom left, so flip the aem vertically
87
+ aem = np.flip(aem, axis=1)
88
+ aem = np.repeat(
89
+ aem, 2, axis=0
90
+ ) # when hoplength reduced to half fps, repeat each AEM frame once to match the length
91
+
92
+ if normalization_mode == "global":
93
+ # global normalize
94
+ aem = (aem - np.min(aem)) / (np.max(aem) - np.min(aem))
95
+ # aem_frame_gain = np.mean(aem, axis=(1, 2))
96
+ # aem_frame_gain = (aem_frame_gain - np.min(aem_frame_gain)) / (
97
+ # np.max(aem_frame_gain) - np.min(aem_frame_gain)
98
+ # )
99
+ logger.info(f"AEM shape: {aem.shape}")
100
+ logger.info(f"AEM stats: max={np.max(aem)}, min={np.min(aem)}, mean={np.mean(aem)}")
101
+
102
+ # add aem colormask to video
103
+ frames = range(min(video.shape[0], aem.shape[0]))
104
+ if verbose:
105
+ frames = tqdm.tqdm(frames, desc="=> rendering AEM mask to video")
106
+ for i in frames:
107
+ # aem_frame = aem[i]
108
+ # aem_frame = aem_frame_gain[i] * (aem[i] - np.min(aem[i])) / (np.max(aem[i]) - np.min(aem[i])) # gain+local normalization
109
+ if normalization_mode == "local":
110
+ # local normalize
111
+ aem_frame = (aem[i] - np.min(aem[i])) / (np.max(aem[i]) - np.min(aem[i]))
112
+ elif normalization_mode == "global":
113
+ # make local changes more drastic, but keeps the local max of the frame
114
+ aem_frame = aem[i]
115
+ aem_frame_max = np.max(aem_frame)
116
+ aem_frame = aem_frame**8
117
+ aem_frame = aem_frame / np.max(aem_frame) * aem_frame_max
118
+
119
+ aem_frame = (aem_mask_alpha * aem_frame * 255).astype(video.dtype)
120
+ # aem_frame = (aem_mask_alpha * aem[i] * 255).astype(video.dtype)
121
+ aem_frame_resized = PIL.Image.fromarray(aem_frame).resize(
122
+ (video_frame_shape[1], video_frame_shape[0])
123
+ )
124
+ # video[i, :, :, 2] = (video[i, :, :, 2] * (1 - aem_mask_alpha) + np.asarray(aem_frame_resized)).astype(video.dtype)
125
+ video[i, :, :, 2] = (video[i, :, :, 2] + np.asarray(aem_frame_resized)).astype(
126
+ video.dtype
127
+ )
128
+ video = np.clip(video, 0, 255)
129
+
130
+ # save video
131
+ save_path = Path(save_path).resolve().absolute()
132
+ audio_path = Path(audio_path).resolve().absolute()
133
+ if not save_path.suffix: # add a filename if a dir is given
134
+ save_path = save_path / f"{Path(audio_path).stem}.mp4"
135
+ save_path.mkdir(parents=True, exist_ok=True)
136
+ else:
137
+ save_path.parent.mkdir(parents=True, exist_ok=True)
138
+ save_path_tmp = save_path.with_stem(save_path.stem + "_tmp")
139
+ video = video.astype(np.uint8)
140
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
141
+ out = cv2.VideoWriter(str(save_path_tmp), fourcc, fps, video_frame_shape[::-1])
142
+ range_ = range(video.shape[0])
143
+ if verbose:
144
+ range_ = tqdm.tqdm(range_, desc="=> writing video...")
145
+ for frame_idx in range_:
146
+ out.write(video[frame_idx])
147
+ out.release()
148
+
149
+ # merge audio and video, only take the first channel from ambisonics
150
+ cmd = f"ffmpeg -hide_banner -y -i '{save_path_tmp}' -ss {audio_offset} -i '{audio_path}' -filter_complex \"[1:a:0]pan=mono|c0=c0[a_out]\" -map 0:v:0 -map \"[a_out]\" -c:v copy -c:a aac -shortest '{save_path}'"
151
+ # cmd = [
152
+ # "ffmpeg",
153
+ # "-hide_banner",
154
+ # "-y",
155
+ # "-i",
156
+ # f"{save_path_tmp}",
157
+ # "-ss",
158
+ # f"{audio_offset}",
159
+ # "-i",
160
+ # f"{audio_path}",
161
+ # "-filter_complex",
162
+ # "[1:a:0]pan=mono|c0=c0[a_out]",
163
+ # "-map",
164
+ # "[a_out]",
165
+ # "-c:v",
166
+ # "copy",
167
+ # "-c:a",
168
+ # "aac",
169
+ # "-shortest",
170
+ # f"{save_path}",
171
+ # ]
172
+ subprocess.call(cmd, shell=True)
173
+ # subprocess.check_call(cmd)
174
+ os.remove(save_path_tmp)
175
+
176
+ if verbose:
177
+ print(f"=> Video saved to {save_path}")
178
+
179
+
180
+ if __name__ == "__main__":
181
+ audio_path = "../test_files/test_aem_3oa.wav"
182
+ video_path = "../test_files/test_aem_video.mp4"
183
+
184
+ aem(
185
+ audio_path,
186
+ [video_path],
187
+ "../test_output",
188
+ duration=30,
189
+ audio_offset=23.3 + 270,
190
+ video_offset=32.5 + 270,
191
+ verbose=True,
192
+ aem_mask_alpha=0.5,
193
+ ambi_order=3,
194
+ to_db=True,
195
+ )
@@ -0,0 +1,275 @@
1
+ import datetime
2
+ import logging
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional, Tuple, Union
6
+
7
+ import librosa
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import scienceplots
11
+
12
+ from .ambisonics.audio_to_aem import compute_aem
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def compute_anglegram_from_aem(
18
+ aem_path: Optional[Union[str, os.PathLike, Path]] = None,
19
+ time_stamp=None,
20
+ phi_mesh=None,
21
+ nu_mesh=None,
22
+ aem=None,
23
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
24
+ if aem_path is not None:
25
+ assert (
26
+ time_stamp is None and phi_mesh is None and nu_mesh is None and aem is None
27
+ ), "If aem_path is provided, other parameters should be None."
28
+ else:
29
+ assert (
30
+ time_stamp is not None
31
+ and phi_mesh is not None
32
+ and nu_mesh is not None
33
+ and aem is not None
34
+ ), "If aem_path is not provided, other parameters should not be None."
35
+ if aem_path is not None:
36
+ data = np.load(aem_path)
37
+ time_stamp, phi_mesh, nu_mesh, aem = (
38
+ data["time_stamp"],
39
+ data["phi_mesh"],
40
+ data["nu_mesh"],
41
+ data["aem"],
42
+ )
43
+ phi_record, nu_record, energy_record = [], [], []
44
+
45
+ aem_argmax = [np.unravel_index(np.argmax(r), r.shape) for r in aem]
46
+ for i, point in enumerate(aem_argmax):
47
+ phi_record.append(phi_mesh[point[0], point[1]])
48
+ nu_record.append(nu_mesh[point[0], point[1]])
49
+ energy_record.append(aem[i, point[0], point[1]])
50
+ phi_record = np.array(phi_record)
51
+ nu_record = np.array(nu_record)
52
+ energy_record = np.array(energy_record)
53
+ energy_record = librosa.amplitude_to_db(energy_record, ref=np.max)
54
+
55
+ return time_stamp, phi_record, nu_record, energy_record
56
+
57
+
58
+ # def get_anglegram_mean_rms(audio_path) -> Tuple[float, np.ndarray]:
59
+ # """
60
+ # Calculate average rms of the audio file, made for noise estimation..
61
+ # """
62
+ # time_stamp, phi_mesh, nu_mesh, aem = compute_aem(
63
+ # audio_path=audio_path,
64
+ # fps=20,
65
+ # audio_frame_length=4800,
66
+ # aem_width=360, # angular_res = 1
67
+ # aem_height=180,
68
+ # gpu=True,
69
+ # batch_size=5,
70
+ # )
71
+ # phi_record, nu_record, energy_record = [], [], []
72
+
73
+ # aem_argmax = [np.unravel_index(np.argmax(r), r.shape) for r in aem]
74
+ # for i, point in enumerate(aem_argmax):
75
+ # phi_record.append(phi_mesh[point[0], point[1]])
76
+ # nu_record.append(nu_mesh[point[0], point[1]])
77
+ # energy_record.append(aem[i, point[0], point[1]])
78
+ # phi_record = np.array(phi_record)
79
+ # nu_record = np.array(nu_record)
80
+ # energy_record = np.array(energy_record)
81
+
82
+ # return np.mean(energy_record), energy_record
83
+
84
+
85
+ def plot_anglegram(
86
+ time_record,
87
+ phi_record,
88
+ nu_record,
89
+ rms_record,
90
+ db_threshold=-60,
91
+ db_max=-20,
92
+ title: Optional[str] = None,
93
+ save_path: Optional[Union[str, os.PathLike[str], Path]] = None,
94
+ ):
95
+ with plt.style.context(["science", "no-latex"]):
96
+ fig, ax = plt.subplots(2, 1, figsize=(12, 6))
97
+ if title:
98
+ fig.suptitle(title, fontsize=16)
99
+
100
+ sc = ax[0].scatter(
101
+ time_record[rms_record > db_threshold],
102
+ phi_record[rms_record > db_threshold],
103
+ c=rms_record[rms_record > db_threshold],
104
+ label="Azimuth",
105
+ alpha=0.9,
106
+ vmin=db_threshold,
107
+ vmax=db_max,
108
+ )
109
+ # find the closest multiple of 2pi to azimuth_min
110
+ azimuth_min_pi = np.floor(np.min(phi_record) / np.pi) * np.pi
111
+ azimuth_max_pi = np.ceil(np.max(phi_record) / np.pi) * np.pi
112
+ ax[0].set_ylim(azimuth_min_pi - 0.1, azimuth_max_pi + 0.1)
113
+ yticks = np.arange(azimuth_min_pi, azimuth_max_pi + 0.1, np.pi)
114
+ ax[0].set_yticks(yticks)
115
+ ax[0].set_yticklabels([f"{int(ytick / np.pi)}$\pi$" for ytick in yticks])
116
+
117
+ # plt.colorbar(sc, ax=ax[0], label="RMS (dB)")
118
+ ax[0].set(xlabel="Time (s)", ylabel="Azimuth (rad)")
119
+ # horizontal lines on pi and 2pi
120
+ for ytick in yticks:
121
+ if np.abs(ytick % (2 * np.pi)) < 1e-1:
122
+ ax[0].axhline(y=ytick, linestyle="-", color="black", alpha=0.5)
123
+ else:
124
+ ax[0].axhline(y=ytick, linestyle="--", alpha=0.3)
125
+
126
+ ax[0].grid(axis="x")
127
+
128
+ sc = ax[1].scatter(
129
+ time_record[rms_record > db_threshold],
130
+ nu_record[rms_record > db_threshold],
131
+ c=rms_record[rms_record > db_threshold],
132
+ label="Elevation",
133
+ alpha=0.9,
134
+ vmin=db_threshold,
135
+ vmax=db_max,
136
+ )
137
+ # ax[1].plot(time_record, nu_record, label='Elevation')
138
+ ax[1].set(
139
+ xlabel="Time (s)",
140
+ ylabel="Elevation (rad)",
141
+ ylim=(-0.5 * np.pi - 0.1, 0.5 * np.pi + 0.1),
142
+ )
143
+ ax[1].grid(axis="x")
144
+ yticks = np.array([-0.5 * np.pi, -0.25 * np.pi, 0, 0.25 * np.pi, 0.5 * np.pi])
145
+ ax[1].set_yticks(yticks)
146
+ ax[1].set_yticklabels(
147
+ [r"$-\pi/2$", r"$-\pi/4$", r"$0$", r"$\pi/4$", r"$\pi/2$"]
148
+ )
149
+ for ytick in yticks:
150
+ if ytick == 0:
151
+ ax[1].axhline(y=ytick, linestyle="-", color="black", alpha=0.5)
152
+ else:
153
+ ax[1].axhline(y=ytick, linestyle="--", alpha=0.3)
154
+ # plt.colorbar(sc, ax=ax[1], label="RMS (dB)")
155
+ fig.colorbar(sc, ax=ax.ravel().tolist(), label="RMS (dB)")
156
+
157
+ if save_path is not None:
158
+ plt.savefig(save_path)
159
+ logger.info(f"Figure saved to {save_path}")
160
+
161
+ return (
162
+ ax,
163
+ time_record[rms_record > db_threshold],
164
+ phi_record[rms_record > db_threshold],
165
+ nu_record[rms_record > db_threshold],
166
+ rms_record[rms_record > db_threshold],
167
+ )
168
+
169
+
170
+ def plot_anglemap(aem_path: str, save_path: Optional[str] = None, **kwargs):
171
+ data = np.load(aem_path)
172
+ time_stamp, _, _, aem = (
173
+ data["time_stamp"],
174
+ data["phi_mesh"],
175
+ data["nu_mesh"],
176
+ data["aem"],
177
+ )
178
+
179
+ logger.debug(f"aem shape: {aem.shape}") # (1093, 180, 360)
180
+
181
+ phi_img = np.mean(aem, axis=1) # (1093, 360)
182
+ phi_img = np.flip(phi_img, axis=1).T # (360, 1093), top left pi
183
+ logger.debug(f"phi_img shape: {phi_img.shape}")
184
+
185
+ nu_img = np.mean(aem, axis=2) # (1093, 180)
186
+ nu_img = np.flip(nu_img, axis=1).T # (180, 1093), top left pi/2
187
+ logger.debug(f"nu_img shape: {nu_img.shape}")
188
+
189
+ fig, ax = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
190
+ fig.suptitle(
191
+ f"{os.path.basename(aem_path)}\nmin: {aem.mean():.2f}, max: {aem.max():.2f}",
192
+ fontsize=16,
193
+ )
194
+ ax[0].imshow(phi_img, cmap="hot", aspect="auto", **kwargs)
195
+ ax[0].set_yticks([0, 89, 179, 269, 359])
196
+ ax[0].set_yticklabels([r"$\pi$", r"$\pi/2$", r"$0$", r"$-\pi/2$", r"$-\pi$"])
197
+ ax[0].set_xticks(np.arange(0, len(time_stamp), 200))
198
+ ax[0].set_xticklabels(np.arange(0, len(time_stamp), 200) / 20)
199
+
200
+ ax[0].set_ylabel("Azimuth (rad)")
201
+
202
+ ax[1].imshow(nu_img, cmap="hot", aspect="auto", **kwargs)
203
+ ax[1].set_xlabel("Time (s)")
204
+ ax[1].set_ylabel("Elevation (rad)")
205
+ ax[1].set_yticks([0, 44, 89, 134, 179])
206
+ ax[1].set_yticklabels([r"$\pi/2$", r"$\pi/4$", r"$0$", r"$-\pi/4$", r"$-\pi/2$"])
207
+
208
+ if save_path is not None:
209
+ plt.savefig(save_path)
210
+ logger.info(f"=> Figure saved to {save_path}")
211
+ else:
212
+ plt.show()
213
+
214
+
215
+ def frame_to_seconds(frame_idx, fps=30):
216
+ return frame_idx / fps
217
+
218
+
219
+ def seconds_to_timestring(seconds):
220
+ return str(datetime.timedelta(seconds=seconds))
221
+
222
+
223
+ def frame_to_timestring(frame_idx, fps=30):
224
+ return seconds_to_timestring(frame_to_seconds(frame_idx, fps))
225
+
226
+
227
+ def anglegram(
228
+ audio_path: Union[str, os.PathLike[str], Path],
229
+ save_path: Optional[Union[str, os.PathLike[str], Path]] = None,
230
+ fps: int = 20,
231
+ audio_frame_length: int = 4800,
232
+ aem_width: int = 90,
233
+ aem_height: int = 45,
234
+ gpu: bool = False,
235
+ db_threshold: int = -60,
236
+ db_max: int = -20,
237
+ title: Optional[str] = None,
238
+ batch_size: int = 5,
239
+ ):
240
+ logger.debug(f"Computing AEM. Audio path: {audio_path}")
241
+ aem, time_stamp, phi_mesh, nu_mesh = compute_aem(
242
+ audio_path=audio_path,
243
+ fps=fps,
244
+ audio_frame_length=audio_frame_length,
245
+ aem_width=aem_width,
246
+ aem_height=aem_height,
247
+ gpu=gpu,
248
+ batch_size=batch_size,
249
+ )
250
+
251
+ logger.debug(f"Computing anglegram. AEM shape: {aem.shape}")
252
+ timestamp, phi_record, nu_record, rms_record = compute_anglegram_from_aem(
253
+ time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
254
+ )
255
+ logger.debug(
256
+ f"rms record max: {rms_record.max()}, min: {rms_record.min()}, mean: {rms_record.mean()}"
257
+ )
258
+
259
+ logger.debug(f"Plotting anglegram. Save path: {save_path}")
260
+ if title is None:
261
+ title = os.path.basename(audio_path)
262
+
263
+ ax, *_ = plot_anglegram(
264
+ timestamp,
265
+ phi_record,
266
+ nu_record,
267
+ rms_record,
268
+ db_threshold=db_threshold,
269
+ db_max=db_max,
270
+ title=title,
271
+ save_path=save_path,
272
+ )
273
+
274
+ if not save_path:
275
+ return ax
@@ -0,0 +1,98 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Optional, Union
5
+
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import scienceplots
9
+
10
+ from .ambisonics.audio_to_aem import compute_aem
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def plot_directogram(
16
+ angles,
17
+ energy,
18
+ title=None,
19
+ figsize=(8, 8),
20
+ dpi=300,
21
+ ax=None,
22
+ save_path=None,
23
+ ):
24
+ with plt.style.context(["science", "no-latex"]):
25
+ if ax is None:
26
+ fig = plt.figure(figsize=figsize, dpi=dpi)
27
+ ax = fig.add_subplot(111, projection="polar")
28
+ ax.set_title(title if title is not None else "Directogram")
29
+ ax.bar(
30
+ x=angles / 180 * np.pi,
31
+ height=energy,
32
+ width=2 * np.pi / len(angles),
33
+ color="tab:blue",
34
+ alpha=0.7,
35
+ label="Summed directional energy",
36
+ )
37
+ ax.set_theta_zero_location("N")
38
+
39
+ if save_path is not None:
40
+ plt.savefig(save_path)
41
+ logger.info(f"Figure saved to {save_path}")
42
+
43
+ return ax
44
+
45
+
46
+ def compute_directogram_from_aem(aem, time_stamp, phi_mesh, nu_mesh):
47
+ """
48
+ Calculate the directogram from the AEM.
49
+
50
+ Args:
51
+ aem: np.ndarray, shape (n_frames, n_nu, n_phi)
52
+ time_stamp: np.ndarray, shape (n_frames,)
53
+ phi_mesh: np.ndarray, azimuth. shape: (n_nu, n_phi)
54
+ nu_mesh: np.ndarray, elevation. shape: (n_nu, n_phi)
55
+
56
+ Returns:
57
+ angles: np.ndarray, in degrees, shape (n_phi,)
58
+ energy: np.ndarray, shape (n_phi,)
59
+ """
60
+ horizontal_nu_idx = nu_mesh.shape[0] // 2
61
+ horizontal_aem = aem[:, horizontal_nu_idx, :]
62
+ angles = phi_mesh[0, :] / np.pi * 180
63
+ energy = np.zeros_like(angles)
64
+ for i in range(horizontal_aem.shape[0]):
65
+ argmax_idx = np.argmax(horizontal_aem[i])
66
+ energy[argmax_idx] += horizontal_aem[i, argmax_idx]
67
+
68
+ return angles, energy
69
+
70
+
71
+ def directogram(
72
+ audio_path: Union[str, os.PathLike, Path],
73
+ save_path: Optional[Union[str, os.PathLike, Path]] = None,
74
+ ax=None,
75
+ fps: int = 20,
76
+ audio_frame_length: int = 4800,
77
+ aem_width: int = 90,
78
+ aem_height: int = 45,
79
+ batch_size: int = 5,
80
+ gpu: bool = False,
81
+ ):
82
+ aem, time_stamp, phi_mesh, nu_mesh = compute_aem(
83
+ audio_path=audio_path,
84
+ fps=fps,
85
+ audio_frame_length=audio_frame_length,
86
+ aem_width=aem_width,
87
+ aem_height=aem_height,
88
+ gpu=gpu,
89
+ batch_size=batch_size,
90
+ )
91
+
92
+ logger.debug(f"Computing anglegram. AEM shape: {aem.shape}")
93
+ angles, energy = compute_directogram_from_aem(aem, time_stamp, phi_mesh, nu_mesh)
94
+
95
+ logger.debug(f"Plotting directogram. Save path: {save_path}")
96
+ ax = plot_directogram(angles, energy, ax=ax, save_path=save_path)
97
+
98
+ return ax, angles, energy
@@ -0,0 +1,24 @@
1
+ import numpy as np
2
+
3
+
4
+ def check_ambisonics_order(audio: np.ndarray) -> int:
5
+ """
6
+ Check the order of ambisonics.
7
+ Raises error if the number of channels does not correspond to a valid ambisonics order.
8
+
9
+ Parameters:
10
+ audio: (n_channels, n_samples)
11
+
12
+ Returns:
13
+ order: int, the order of ambisonics
14
+ """
15
+ assert len(audio.shape) == 2, "Audio should be 2D (n_channels, n_samples)"
16
+ n_channels = audio.shape[0]
17
+
18
+ order = int(np.sqrt(n_channels) - 1)
19
+ if (order + 1) ** 2 != n_channels:
20
+ raise ValueError(
21
+ f"Number of channels {n_channels} does not correspond to a valid ambisonics order."
22
+ )
23
+
24
+ return order
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: ambiviz
3
+ Version: 0.0.1
4
+ Summary: Visualization tools for ambisonic audio and 360 video
5
+ Author-email: Arthur Jinyue Guo <jinyue.guo@imv.uio.no>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=2.0.2
9
+ Requires-Dist: matplotlib>=3.7
10
+ Requires-Dist: librosa>=0.10
11
+ Requires-Dist: torchaudio>=2.0
12
+ Requires-Dist: opencv-python>=4.8
13
+ Requires-Dist: Pillow>=10.0
14
+ Requires-Dist: tqdm>=4.0
15
+ Requires-Dist: scipy>=1.10
16
+ Requires-Dist: scienceplots>=2.2.1
17
+ Requires-Dist: musicalgestures>=1.3.3
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+
21
+ # ambiviz
22
+
23
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
24
+ [![Actions status](https://github.com/fisheggg/ambiviz/workflows/ci.yml/badge.svg)](https://github.com/fisheggg/ambiviz/actions)
25
+
26
+
27
+ Visualization tools for ambisonic audio (and 360 video).
28
+
29
+
30
+ ## References
31
+
32
+ - The core ambisonics decoder is modified from [https://github.com/pedro-morgado/spatialaudiogen](https://github.com/pedro-morgado/spatialaudiogen).
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ ambiviz/__init__.py
4
+ ambiviz/aem.py
5
+ ambiviz/anglegram.py
6
+ ambiviz/directogram.py
7
+ ambiviz/utils.py
8
+ ambiviz.egg-info/PKG-INFO
9
+ ambiviz.egg-info/SOURCES.txt
10
+ ambiviz.egg-info/dependency_links.txt
11
+ ambiviz.egg-info/requires.txt
12
+ ambiviz.egg-info/top_level.txt
13
+ tests/test_aem.py
14
+ tests/test_anglegram.py
15
+ tests/test_directogram.py
16
+ tests/test_plot.py
@@ -0,0 +1,13 @@
1
+ numpy>=2.0.2
2
+ matplotlib>=3.7
3
+ librosa>=0.10
4
+ torchaudio>=2.0
5
+ opencv-python>=4.8
6
+ Pillow>=10.0
7
+ tqdm>=4.0
8
+ scipy>=1.10
9
+ scienceplots>=2.2.1
10
+ musicalgestures>=1.3.3
11
+
12
+ [dev]
13
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ ambiviz
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "ambiviz"
3
+ version = "0.0.1"
4
+ description = "Visualization tools for ambisonic audio and 360 video"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ authors = [
8
+ {name = "Arthur Jinyue Guo", email = "jinyue.guo@imv.uio.no"},
9
+ ]
10
+ dependencies = [
11
+ "numpy>=2.0.2",
12
+ "matplotlib>=3.7",
13
+ "librosa>=0.10",
14
+ "torchaudio>=2.0",
15
+ "opencv-python>=4.8",
16
+ "Pillow>=10.0",
17
+ "tqdm>=4.0",
18
+ "scipy>=1.10",
19
+ "scienceplots>=2.2.1",
20
+ "musicalgestures>=1.3.3",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7.0"]
25
+
26
+ [tool.pytest.ini_options]
27
+ markers = [
28
+ "integration: tests that require real audio/video files and ffmpeg (deselect with '-m not integration')",
29
+ ]
30
+
31
+ [tool.ruff]
32
+ ignore = ["F401"]
33
+
34
+ [tool.setuptools]
35
+ packages = ["ambiviz"]
36
+
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "ipykernel>=6.31.0",
41
+ "pytest>=8.4.2",
42
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,38 @@
1
+ """Unit and integration tests for ambiviz.aem."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock
5
+
6
+ import numpy as np
7
+ import pytest
8
+
9
+ from ambiviz.aem import aem
10
+
11
+
12
+ # Real test files (used by integration tests)
13
+ TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
14
+ REAL_AUDIO_PATH = TEST_FILES_DIR / "test_aem_3oa.wav"
15
+ REAL_VIDEO_PATH = TEST_FILES_DIR / "test_aem_video.mp4"
16
+ TEST_OUTPUT_DIR = Path(__file__).parent.parent / "test_output"
17
+
18
+
19
+ @pytest.mark.integration
20
+ def test_anglegram_integration():
21
+ """Integration test for aem"""
22
+
23
+ try:
24
+ aem(
25
+ audio_path=REAL_AUDIO_PATH,
26
+ video_paths=[str(REAL_VIDEO_PATH)],
27
+ save_path=str(TEST_OUTPUT_DIR / "test_aem_output.mp4"),
28
+ duration=30,
29
+ audio_offset=23.3 + 270,
30
+ video_offset=32.5 + 270,
31
+ aem_mask_alpha=0.5,
32
+ ambi_order=3,
33
+ to_db=True,
34
+ normalization_mode="local",
35
+ verbose=True,
36
+ )
37
+ except Exception as e:
38
+ pytest.fail(f"Integration test for aem failed with exception: {e}")
@@ -0,0 +1,131 @@
1
+ """Unit and integration tests for ambiviz.anglegram."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock
5
+
6
+ import numpy as np
7
+ import pytest
8
+
9
+ from ambiviz.anglegram import anglegram, compute_anglegram_from_aem
10
+
11
+ # Real test files (used by integration tests)
12
+ TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
13
+ REAL_AUDIO_PATH = TEST_FILES_DIR / "test_anglegram.wav"
14
+ TEST_OUTPUT_DIR = Path(__file__).parent.parent / "test_output"
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Helpers / fixtures
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def _make_aem_data(n_frames=10, height=4, width=8):
23
+ """Return fake (aem, time_stamp, phi_mesh, nu_mesh) as compute_aem would."""
24
+ rng = np.random.default_rng(0)
25
+ aem = rng.random((n_frames, height, width)).astype(np.float32)
26
+ time_stamp = np.linspace(0, 1, n_frames)
27
+ phi_mesh, nu_mesh = np.meshgrid(
28
+ np.linspace(-np.pi, np.pi, width),
29
+ np.linspace(-np.pi / 2, np.pi / 2, height),
30
+ )
31
+ return aem, time_stamp, phi_mesh, nu_mesh
32
+
33
+
34
+ def _make_plot_return():
35
+ n = 5
36
+ return (MagicMock(), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n))
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # compute_anglegram_from_aem unit tests
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ class TestComputeAnglegramFromAem:
45
+ def test_output_shapes(self):
46
+ aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data(n_frames=10)
47
+ ts, phi, nu, energy = compute_anglegram_from_aem(
48
+ time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
49
+ )
50
+ assert ts.shape == (10,)
51
+ assert phi.shape == (10,)
52
+ assert nu.shape == (10,)
53
+ assert energy.shape == (10,)
54
+
55
+ def test_energy_is_db(self):
56
+ """Energy record should be in dB: max value must be 0.0."""
57
+ aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data(n_frames=8)
58
+ _, _, _, energy = compute_anglegram_from_aem(
59
+ time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
60
+ )
61
+ assert np.max(energy) == pytest.approx(0.0, abs=1e-5)
62
+
63
+ def test_energy_nonpositive(self):
64
+ """All dB values should be <= 0."""
65
+ aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data(n_frames=8)
66
+ _, _, _, energy = compute_anglegram_from_aem(
67
+ time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
68
+ )
69
+ assert np.all(energy <= 0.0)
70
+
71
+ def test_picks_argmax_coordinates(self):
72
+ """phi/nu should come from the grid point with the highest AEM value per frame."""
73
+ n_frames, height, width = 3, 4, 8
74
+ aem = np.zeros((n_frames, height, width), dtype=np.float32)
75
+ phi_mesh, nu_mesh = np.meshgrid(
76
+ np.linspace(-np.pi, np.pi, width),
77
+ np.linspace(-np.pi / 2, np.pi / 2, height),
78
+ )
79
+ time_stamp = np.arange(n_frames, dtype=float)
80
+
81
+ # Place the maximum at known positions
82
+ expected_phi = []
83
+ expected_nu = []
84
+ for i in range(n_frames):
85
+ row, col = i % height, (i * 2) % width
86
+ aem[i, row, col] = 1.0
87
+ expected_phi.append(phi_mesh[row, col])
88
+ expected_nu.append(nu_mesh[row, col])
89
+
90
+ _, phi, nu, _ = compute_anglegram_from_aem(
91
+ time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
92
+ )
93
+ np.testing.assert_allclose(phi, expected_phi)
94
+ np.testing.assert_allclose(nu, expected_nu)
95
+
96
+ def test_requires_consistent_args(self):
97
+ """Passing aem_path together with other args should raise AssertionError."""
98
+ aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data()
99
+ with pytest.raises(AssertionError):
100
+ compute_anglegram_from_aem(
101
+ aem_path="dummy.npz",
102
+ time_stamp=time_stamp,
103
+ phi_mesh=phi_mesh,
104
+ nu_mesh=nu_mesh,
105
+ aem=aem,
106
+ )
107
+
108
+ def test_requires_all_args_when_no_path(self):
109
+ """Omitting any required arg when aem_path is None should raise AssertionError."""
110
+ aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data()
111
+ with pytest.raises(AssertionError):
112
+ compute_anglegram_from_aem(
113
+ time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh
114
+ )
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Integration test
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ @pytest.mark.integration
123
+ def test_anglegram_integration():
124
+ """Integration test for anglegram function."""
125
+ try:
126
+ anglegram(
127
+ audio_path=REAL_AUDIO_PATH,
128
+ save_path=TEST_OUTPUT_DIR / "test_anglegram.png",
129
+ )
130
+ except Exception as e:
131
+ pytest.fail(f"Integration test failed with exception: {e}")
@@ -0,0 +1,33 @@
1
+ """Unit and integration tests for ambiviz.directogram."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from ambiviz.directogram import directogram
9
+
10
+ # Real test files (used by integration tests)
11
+ TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
12
+ REAL_AUDIO_PATH = TEST_FILES_DIR / "test_directogram.wav"
13
+ TEST_OUTPUT_DIR = Path(__file__).parent.parent / "test_output"
14
+
15
+ logging.basicConfig(level=logging.WARNING)
16
+ logger = logging.getLogger(__name__)
17
+ logger.setLevel(logging.DEBUG)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Integration test
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ @pytest.mark.integration
25
+ def test_directogram_integration():
26
+ """Integration test for directogram function."""
27
+ try:
28
+ directogram(
29
+ audio_path=REAL_AUDIO_PATH,
30
+ save_path=TEST_OUTPUT_DIR / "test_directogram.png",
31
+ )
32
+ except Exception as e:
33
+ pytest.fail(f"Integration test failed with exception: {e}")
@@ -0,0 +1,117 @@
1
+ # """Unit and integration tests for ambiviz.plot.visualize_aem."""
2
+ # from pathlib import Path
3
+
4
+ # import numpy as np
5
+ # import pytest
6
+
7
+ # from ambiviz.aem import visualize_aem
8
+
9
+ # AUDIO_PATH = "fake/audio.wav"
10
+ # VIDEO_PATH = "fake/video.mp4"
11
+
12
+ # # Real test files (used by integration tests)
13
+ # TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
14
+ # REAL_AUDIO_PATH = TEST_FILES_DIR / "test_audio_foa.wav"
15
+ # REAL_VIDEO_PATH = TEST_FILES_DIR / "test_video.mp4"
16
+
17
+
18
+ # def _call(tmp_path, normalization_mode=None, **kwargs):
19
+ # """Helper: invoke visualize_aem with a concrete save path."""
20
+ # visualize_aem(
21
+ # AUDIO_PATH,
22
+ # VIDEO_PATH,
23
+ # str(tmp_path / "out.mp4"),
24
+ # duration=2.0,
25
+ # audio_offset=0.0,
26
+ # video_offset=0.0,
27
+ # normalization_mode=normalization_mode,
28
+ # **kwargs,
29
+ # )
30
+
31
+
32
+ # # ---------------------------------------------------------------------------
33
+ # # Validation
34
+ # # ---------------------------------------------------------------------------
35
+
36
+ # def test_invalid_normalization_mode_raises(tmp_path):
37
+ # with pytest.raises(AssertionError):
38
+ # _call(tmp_path, normalization_mode="bad_mode")
39
+
40
+
41
+ # def test_invalid_aem_mode_raises(tmp_path):
42
+ # with pytest.raises(AssertionError):
43
+ # _call(tmp_path, aem_mode="bad_mode")
44
+
45
+
46
+ # # ---------------------------------------------------------------------------
47
+ # # Smoke tests — one per normalization mode
48
+ # # ---------------------------------------------------------------------------
49
+
50
+ # @pytest.mark.parametrize("normalization_mode", [None, "global", "local"])
51
+ # def test_smoke(patched_env, normalization_mode):
52
+ # """Function completes without error for every normalization mode."""
53
+ # _call(patched_env["tmp_path"], normalization_mode=normalization_mode)
54
+
55
+
56
+ # # ---------------------------------------------------------------------------
57
+ # # Side-effect verification
58
+ # # ---------------------------------------------------------------------------
59
+
60
+ # def test_ffmpeg_called_once(patched_env):
61
+ # _call(patched_env["tmp_path"])
62
+ # patched_env["p_subprocess"].assert_called_once()
63
+
64
+
65
+ # def test_temp_file_removed(patched_env):
66
+ # _call(patched_env["tmp_path"])
67
+ # patched_env["p_remove"].assert_called_once()
68
+
69
+
70
+ # def test_writer_called_per_frame(patched_env):
71
+ # """cv2.VideoWriter.write() must be called once per video frame."""
72
+ # n_video_frames = patched_env["video_array"].shape[0]
73
+ # _call(patched_env["tmp_path"])
74
+ # assert patched_env["mock_writer"].write.call_count == n_video_frames
75
+
76
+
77
+ # # ---------------------------------------------------------------------------
78
+ # # AEM overlay logic
79
+ # # ---------------------------------------------------------------------------
80
+
81
+ # def test_channel2_increased_by_aem(patched_env):
82
+ # """Channel index 2 of every written frame must be >= the original (AEM adds energy)."""
83
+ # original_ch2 = patched_env["video_array"][:, :, :, 2].astype(np.float64)
84
+
85
+ # _call(patched_env["tmp_path"])
86
+
87
+ # written_frames = [
88
+ # call_args[0][0] # first positional arg to write()
89
+ # for call_args in patched_env["mock_writer"].write.call_args_list
90
+ # ]
91
+ # for i, frame in enumerate(written_frames):
92
+ # assert np.all(frame[:, :, 2].astype(np.float64) >= original_ch2[i] * 0.94), (
93
+ # f"Frame {i}: channel 2 should not decrease below original × 0.95 brightness factor"
94
+ # )
95
+
96
+
97
+ # # ---------------------------------------------------------------------------
98
+ # # Integration tests — require real files and ffmpeg
99
+ # # Run with: pytest -m integration
100
+ # # ---------------------------------------------------------------------------
101
+
102
+ # # @pytest.mark.integration
103
+ # # @pytest.mark.parametrize("normalization_mode", [None, "global", "local"])
104
+ # # def test_integration_smoke(tmp_path, normalization_mode):
105
+ # # """End-to-end run with real audio/video files; verifies output .mp4 is created."""
106
+ # # out = tmp_path / "out.mp4"
107
+ # # visualize_aem(
108
+ # # str(REAL_AUDIO_PATH),
109
+ # # str(REAL_VIDEO_PATH),
110
+ # # str(out),
111
+ # # duration=1.0,
112
+ # # audio_offset=0.0,
113
+ # # video_offset=0.0,
114
+ # # normalization_mode=normalization_mode,
115
+ # # )
116
+ # # assert out.exists(), "Output video file was not created"
117
+ # # assert out.stat().st_size > 0, "Output video file is empty"