1"""The result of a simulation: the listener pressure, the movie frames, and what the run reported."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from typing import Any
8import numpy as np
10from .params import Listener, Strike
11from .plate import Plate
12from .spectrogram import Spectrogram, spectrogram
13from .wav import Level, apply_level, level_gain, peak_dbfs, write_wav
17class Sound:
18 pressure: np.ndarray
19 """Listener sound pressure [Pa] at ``fs``, from the moment of impact."""
20 fs: int
21 plate: Plate
22 strike: Strike
23 listener: Listener
24 frames: np.ndarray | None = None
25 """Transverse displacement [m] at ``fps`` frames per second, ``(n_frames, ny, nx)``, or None."""
26 fps: float = 120.0
27 info: dict[str, Any] = field(default_factory=dict)
28 """What the run reported: ``model``, ``peak_force`` [N], ``contact_time`` [s], and more."""
31 def duration(self) -> float:
32 return len(self.pressure) / self.fs
35 def time(self) -> np.ndarray:
36 """Sample times [s]."""
37 return np.arange(len(self.pressure)) / self.fs
40 def peak_spl(self) -> float:
41 """Peak sound pressure level [dB SPL re 20 uPa]."""
42 m = float(np.abs(self.pressure).max()) if len(self.pressure) else 0.0
43 return 20 * np.log10(m / 20e-6) if m > 0 else -np.inf
45 def samples(self, level: Level = "peak", gain_db: float = 0.0) -> np.ndarray:
46 """The signal mapped to full scale in [-1, 1] (see ``cymsim.wav.level_gain``), soft-clipped."""
47 return apply_level(self.pressure, level_gain(self.pressure, self.fs, level, gain_db))
49 def to_wav(self, path, level: Level = "peak", gain_db: float = 0.0) -> None:
50 """Writes a 16-bit mono WAV file. ``level`` is ``"peak"`` (the peak at -1 dBFS),
51 ``"loudness"`` (-20 dBFS RMS) or ``"absolute"`` (1 Pa at 0 dBFS plus ``gain_db``)."""
52 write_wav(path, self.samples(level, gain_db), self.fs)
54 def spectrogram(self, window: int = 2048, hop: int = 256) -> Spectrogram:
55 return spectrogram(self.pressure, self.fs, window, hop)
57 def audio(self, level: Level = "peak", gain_db: float = 0.0):
58 """An ``IPython.display.Audio`` widget for playback in a notebook."""
59 from IPython.display import Audio
61 return Audio(self.samples(level, gain_db), rate=self.fs, normalize=False)
63 def frame_time(self, index: int) -> float:
64 return index / self.fps
66 def __repr__(self) -> str:
67 model = self.info.get("model", "?")
68 return f"Sound({model} model, {self.duration:.2f} s at {self.fs} Hz, peak {self.peak_spl:.0f} dB SPL" + (f", {len(self.frames)} frames)" if self.frames is not None else ")")
70 def peak_dbfs(self, level: Level = "peak", gain_db: float = 0.0) -> float:
71 return peak_dbfs(self.samples(level, gain_db))