3688ceacymsim: Python port of cymbalsim with a modal linear model and a PyTorch von Karman modelJeremy Magland 1"""Mono 16-bit PCM WAV output and the level mapping from pascals to full scale."""
3from __future__ import annotations
5import wave
6from typing import Literal
8import numpy as np
10Level = Literal["peak", "loudness", "absolute"]
12KNEE = 0.9
13PEAK_TARGET = 10 ** (-1 / 20) # -1 dBFS
14LOUDNESS_TARGET = 10 ** (-20 / 20) # -20 dBFS RMS
15LOUDNESS_SKIP_S = 0.05
18def level_gain(samples: np.ndarray, fs: float, level: Level = "peak", gain_db: float = 0.0) -> float:
19 """Gain that maps a pressure signal [Pa] to full scale: ``"peak"`` normalizes
20 the peak to -1 dBFS, ``"loudness"`` the RMS after the first 50 ms to -20 dBFS,
21 and ``"absolute"`` maps 1 Pa to 0 dBFS shifted by ``gain_db``."""
22 if level == "absolute":
23 return 10 ** (gain_db / 20)
24 if level == "peak":
25 m = float(np.abs(samples).max()) if len(samples) else 0.0
26 return PEAK_TARGET / m if m > 0 else 1.0
27 if level == "loudness":
28 start = min(len(samples), round(LOUDNESS_SKIP_S * fs))
29 tail = samples[start:]
30 rms = float(np.sqrt((tail.astype(float) ** 2).mean())) if len(tail) else 0.0
31 return LOUDNESS_TARGET / rms if rms > 0 else 1.0
32 raise ValueError(f"unknown level {level!r}")
35def apply_level(samples: np.ndarray, gain: float) -> np.ndarray:
36 """Applies the gain and a soft clipper: below 0.9 of full scale the identity,
37 above it the excess is squashed with a tanh so that the output stays within
38 (-1, 1) with a continuous slope at the knee."""
39 y = samples.astype(np.float64) * gain
40 a = np.abs(y)
41 room = 1 - KNEE
42 clipped = KNEE + room * np.tanh((a - KNEE) / room)
43 return np.where(a <= KNEE, y, np.sign(y) * clipped).astype(np.float32)
46def write_wav(path, samples: np.ndarray, fs: int) -> None:
47 """Writes samples in [-1, 1] as a 16-bit PCM mono WAV file."""
48 pcm = np.round(np.clip(np.nan_to_num(samples), -1, 1) * 32767).astype("<i2")
49 with wave.open(str(path), "wb") as w:
50 w.setnchannels(1)
51 w.setsampwidth(2)
52 w.setframerate(int(round(fs)))
53 w.writeframes(pcm.tobytes())
56def peak_dbfs(samples: np.ndarray) -> float:
57 m = float(np.abs(samples).max()) if len(samples) else 0.0
58 return 20 * np.log10(m) if m > 0 else -np.inf