micforge 1.3.0__py3-none-any.whl
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.
- micforge/__init__.py +3 -0
- micforge/__main__.py +4 -0
- micforge/analysis/__init__.py +0 -0
- micforge/analysis/stats.py +133 -0
- micforge/audio/__init__.py +0 -0
- micforge/audio/backend.py +32 -0
- micforge/audio/cues.py +110 -0
- micforge/audio/device_watch.py +237 -0
- micforge/audio/devices.py +160 -0
- micforge/audio/engine.py +746 -0
- micforge/audio/fileio.py +45 -0
- micforge/audio/latency.py +169 -0
- micforge/audio/loopback.py +113 -0
- micforge/audio/metrics.py +134 -0
- micforge/audio/noise_profile.py +250 -0
- micforge/audio/recorder.py +100 -0
- micforge/audio/ring.py +89 -0
- micforge/cli.py +293 -0
- micforge/commands/__init__.py +0 -0
- micforge/commands/calibrate.py +47 -0
- micforge/commands/common.py +69 -0
- micforge/commands/devices_cmd.py +86 -0
- micforge/commands/diagnostics_cmd.py +68 -0
- micforge/commands/doctor.py +127 -0
- micforge/commands/files.py +48 -0
- micforge/commands/help_cmd.py +449 -0
- micforge/commands/latency_test.py +65 -0
- micforge/commands/live.py +321 -0
- micforge/commands/preset_cmd.py +142 -0
- micforge/commands/test_mic.py +98 -0
- micforge/commands/wizard.py +70 -0
- micforge/config.py +279 -0
- micforge/diagnostics.py +308 -0
- micforge/dsp/__init__.py +0 -0
- micforge/dsp/aec.py +388 -0
- micforge/dsp/base.py +30 -0
- micforge/dsp/biquad.py +144 -0
- micforge/dsp/chain.py +153 -0
- micforge/dsp/cofilite.py +427 -0
- micforge/dsp/compressor.py +109 -0
- micforge/dsp/deesser.py +64 -0
- micforge/dsp/denoise.py +181 -0
- micforge/dsp/deverb.py +227 -0
- micforge/dsp/export_cofilite.py +111 -0
- micforge/dsp/gate.py +76 -0
- micforge/dsp/leveler.py +74 -0
- micforge/dsp/proximity.py +164 -0
- micforge/dsp/util.py +80 -0
- micforge/errors.py +25 -0
- micforge/logging_setup.py +25 -0
- micforge/models/cofilite.onnx +0 -0
- micforge/presets.py +284 -0
- micforge/profile_store.py +41 -0
- micforge/ui/__init__.py +0 -0
- micforge/ui/dashboard.py +573 -0
- micforge/ui/hotkeys.py +169 -0
- micforge/ui/keys.py +64 -0
- micforge/ui/report.py +88 -0
- micforge/ui/settings_gui.py +899 -0
- micforge/ui/theme.py +42 -0
- micforge/ui/tray.py +276 -0
- micforge/ui/tuning.py +138 -0
- micforge/ui/widgets.py +109 -0
- micforge/util/autostart.py +56 -0
- micforge-1.3.0.dist-info/METADATA +334 -0
- micforge-1.3.0.dist-info/RECORD +70 -0
- micforge-1.3.0.dist-info/WHEEL +5 -0
- micforge-1.3.0.dist-info/entry_points.txt +2 -0
- micforge-1.3.0.dist-info/licenses/LICENSE +22 -0
- micforge-1.3.0.dist-info/top_level.txt +1 -0
micforge/audio/fileio.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""WAV reading/writing with mono down-mix and resampling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from math import gcd
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.io import wavfile
|
|
9
|
+
from scipy.signal import resample_poly
|
|
10
|
+
|
|
11
|
+
from ..errors import MicforgeError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resample(x: np.ndarray, sr_in: int, sr_out: int) -> np.ndarray:
|
|
15
|
+
if sr_in == sr_out:
|
|
16
|
+
return x
|
|
17
|
+
g = gcd(sr_in, sr_out)
|
|
18
|
+
return resample_poly(x, sr_out // g, sr_in // g).astype(np.float32)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_wav(path: str | Path, target_rate: int | None = None) -> tuple[np.ndarray, int]:
|
|
22
|
+
try:
|
|
23
|
+
sr, data = wavfile.read(str(path))
|
|
24
|
+
except (OSError, ValueError) as exc:
|
|
25
|
+
raise MicforgeError(f"Cannot read {path}: {exc}", hint="Only PCM/float WAV files are supported (convert with ffmpeg).") from exc
|
|
26
|
+
if np.issubdtype(data.dtype, np.integer):
|
|
27
|
+
scale = float(np.iinfo(data.dtype).max) + 1.0
|
|
28
|
+
if data.dtype == np.uint8:
|
|
29
|
+
data = data.astype(np.float32) - 128.0
|
|
30
|
+
scale = 128.0
|
|
31
|
+
x = data.astype(np.float32) / scale
|
|
32
|
+
else:
|
|
33
|
+
x = data.astype(np.float32)
|
|
34
|
+
if x.ndim == 2:
|
|
35
|
+
x = x.mean(axis=1)
|
|
36
|
+
if target_rate and target_rate != sr:
|
|
37
|
+
x = resample(x, sr, target_rate)
|
|
38
|
+
sr = target_rate
|
|
39
|
+
return np.ascontiguousarray(x, dtype=np.float32), int(sr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_wav(path: str | Path, x: np.ndarray, sr: int) -> None:
|
|
43
|
+
pcm = (np.clip(x, -1.0, 1.0) * 32767.0).astype(np.int16)
|
|
44
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
wavfile.write(str(path), sr, pcm)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""End-to-end latency measurement primitives: loopback chirp cross-correlation, clap detection, and stage breakdown."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .backend import get_sd
|
|
13
|
+
from .devices import DeviceInfo, extra_settings, find_device, list_devices
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("micforge.latency")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LatencyBreakdown:
|
|
20
|
+
"""Estimated per-stage latency breakdown in milliseconds."""
|
|
21
|
+
input_hardware_ms: float = 10.0
|
|
22
|
+
dsp_pipeline_ms: float = 1.5
|
|
23
|
+
jitter_cushion_ms: float = 15.0
|
|
24
|
+
output_hardware_ms: float = 10.0
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def total_estimated_ms(self) -> float:
|
|
28
|
+
return self.input_hardware_ms + self.dsp_pipeline_ms + self.jitter_cushion_ms + self.output_hardware_ms
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class LatencyResult:
|
|
33
|
+
"""Outcome of an end-to-end latency benchmark."""
|
|
34
|
+
roundtrip_ms: float
|
|
35
|
+
one_way_ms: float
|
|
36
|
+
confidence_score: float # 0.0 to 1.0
|
|
37
|
+
method: str # "loopback_chirp" | "transient_clap" | "stage_estimate"
|
|
38
|
+
breakdown: LatencyBreakdown = field(default_factory=LatencyBreakdown)
|
|
39
|
+
recommendations: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_probe_chirp(sample_rate: int = 48000, duration_s: float = 0.06) -> np.ndarray:
|
|
43
|
+
"""Generate a clean band-limited logarithmic chirp (800 Hz -> 4000 Hz) with windowing."""
|
|
44
|
+
n_samples = int(duration_s * sample_rate)
|
|
45
|
+
t = np.linspace(0, duration_s, n_samples, endpoint=False, dtype=np.float32)
|
|
46
|
+
f0, f1 = 800.0, 4000.0
|
|
47
|
+
# Logarithmic chirp instantaneous phase
|
|
48
|
+
phase = 2.0 * np.pi * f0 * ((f1 / f0) ** (t / duration_s) - 1.0) / math.log(f1 / f0)
|
|
49
|
+
chirp = 0.4 * np.sin(phase).astype(np.float32)
|
|
50
|
+
# Cosine taper edges (Hanning window) to prevent click artifacts
|
|
51
|
+
window = np.hanning(n_samples).astype(np.float32)
|
|
52
|
+
return chirp * window
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_cross_correlation_delay(
|
|
56
|
+
reference: np.ndarray,
|
|
57
|
+
recorded: np.ndarray,
|
|
58
|
+
sample_rate: int = 48000,
|
|
59
|
+
) -> tuple[float, float]:
|
|
60
|
+
"""Compute delay in seconds and confidence (0.0-1.0) using normalized cross-correlation."""
|
|
61
|
+
if len(recorded) < len(reference) or len(reference) == 0:
|
|
62
|
+
return 0.0, 0.0
|
|
63
|
+
|
|
64
|
+
# Fast normalized cross-correlation via FFT
|
|
65
|
+
ref_norm = reference / (np.linalg.norm(reference) + 1e-12)
|
|
66
|
+
rec_norm = recorded / (np.linalg.norm(recorded) + 1e-12)
|
|
67
|
+
|
|
68
|
+
corr = np.correlate(rec_norm, ref_norm, mode="valid")
|
|
69
|
+
abs_corr = np.abs(corr)
|
|
70
|
+
peak_idx = int(np.argmax(abs_corr))
|
|
71
|
+
peak_val = float(abs_corr[peak_idx])
|
|
72
|
+
|
|
73
|
+
# Calculate Peak-to-Sidelobe Ratio (PSLR) for confidence
|
|
74
|
+
# Exclude peak neighborhood (+- 20 samples)
|
|
75
|
+
sidelobe_mask = np.ones_like(abs_corr, dtype=bool)
|
|
76
|
+
lo = max(0, peak_idx - 25)
|
|
77
|
+
hi = min(len(abs_corr), peak_idx + 25)
|
|
78
|
+
sidelobe_mask[lo:hi] = False
|
|
79
|
+
|
|
80
|
+
sidelobes = abs_corr[sidelobe_mask]
|
|
81
|
+
if len(sidelobes) > 0 and sidelobes.max() > 1e-9:
|
|
82
|
+
pslr_db = 20.0 * math.log10(peak_val / (sidelobes.max() + 1e-12))
|
|
83
|
+
confidence = float(np.clip((pslr_db - 3.0) / 15.0, 0.0, 1.0))
|
|
84
|
+
else:
|
|
85
|
+
confidence = 0.8 if peak_val > 0.3 else 0.2
|
|
86
|
+
|
|
87
|
+
delay_s = peak_idx / sample_rate
|
|
88
|
+
return delay_s, confidence
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def measure_end_to_end_latency(
|
|
92
|
+
input_device: DeviceInfo | int | str | None = None,
|
|
93
|
+
output_device: DeviceInfo | int | str | None = None,
|
|
94
|
+
sample_rate: int = 48000,
|
|
95
|
+
block_size: int = 480,
|
|
96
|
+
jitter_blocks: float = 1.5,
|
|
97
|
+
) -> LatencyResult:
|
|
98
|
+
"""Measure actual round-trip latency by playing a probe chirp and recording return signal."""
|
|
99
|
+
sd = get_sd()
|
|
100
|
+
pool = list_devices(all_apis=True)
|
|
101
|
+
in_dev = find_device(input_device, "input", pool)
|
|
102
|
+
out_dev = find_device(output_device, "output", pool)
|
|
103
|
+
|
|
104
|
+
probe = generate_probe_chirp(sample_rate, duration_s=0.06)
|
|
105
|
+
silence_pre = np.zeros(int(0.15 * sample_rate), dtype=np.float32)
|
|
106
|
+
silence_post = np.zeros(int(0.60 * sample_rate), dtype=np.float32)
|
|
107
|
+
play_buf = np.concatenate([silence_pre, probe, silence_post])
|
|
108
|
+
|
|
109
|
+
rec_buf = np.zeros(len(play_buf), dtype=np.float32)
|
|
110
|
+
rec_ptr = 0
|
|
111
|
+
play_ptr = 0
|
|
112
|
+
done_event = time.time() + 1.2
|
|
113
|
+
|
|
114
|
+
# If both devices are valid and sounddevice allows playrec:
|
|
115
|
+
try:
|
|
116
|
+
# Simultaneous play and record
|
|
117
|
+
out_data = play_buf[:, None] if out_dev.max_output == 1 else np.repeat(play_buf[:, None], min(2, out_dev.max_output), axis=1)
|
|
118
|
+
rec_raw = sd.playrec(
|
|
119
|
+
out_data,
|
|
120
|
+
samplerate=sample_rate,
|
|
121
|
+
channels=1,
|
|
122
|
+
input_device=in_dev.index,
|
|
123
|
+
output_device=out_dev.index,
|
|
124
|
+
dtype=np.float32,
|
|
125
|
+
)
|
|
126
|
+
sd.wait()
|
|
127
|
+
rec_signal = rec_raw[:, 0]
|
|
128
|
+
delay_s, conf = compute_cross_correlation_delay(probe, rec_signal, sample_rate)
|
|
129
|
+
# Account for pre-silence offset
|
|
130
|
+
measured_delay = max(0.005, delay_s - 0.15)
|
|
131
|
+
roundtrip_ms = measured_delay * 1000.0
|
|
132
|
+
one_way_ms = roundtrip_ms / 2.0
|
|
133
|
+
method = "loopback_chirp"
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
log.warning("Hardware loopback test failed (%s); estimating from pipeline parameters", exc)
|
|
136
|
+
# Synthetic mathematical breakdown fallback
|
|
137
|
+
roundtrip_ms = (block_size / sample_rate * 2 + jitter_blocks * block_size / sample_rate + 0.003) * 1000.0 * 2.0
|
|
138
|
+
one_way_ms = roundtrip_ms / 2.0
|
|
139
|
+
conf = 0.5
|
|
140
|
+
method = "stage_estimate"
|
|
141
|
+
|
|
142
|
+
# Build Stage Breakdown
|
|
143
|
+
breakdown = LatencyBreakdown(
|
|
144
|
+
input_hardware_ms=(block_size / sample_rate) * 1000.0,
|
|
145
|
+
dsp_pipeline_ms=1.8,
|
|
146
|
+
jitter_cushion_ms=(jitter_blocks * block_size / sample_rate) * 1000.0,
|
|
147
|
+
output_hardware_ms=(block_size / sample_rate) * 1000.0,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Actionable guidance
|
|
151
|
+
recs = []
|
|
152
|
+
if one_way_ms > 90.0:
|
|
153
|
+
recs.append("Latency is high (>90ms). In config.json, lower block_size from 480 to 256 samples.")
|
|
154
|
+
recs.append("On Windows, ensure your audio device is using WASAPI instead of high-latency MME.")
|
|
155
|
+
elif one_way_ms > 50.0:
|
|
156
|
+
recs.append("Latency is acceptable (~50-90ms) for Discord voice chat and casual gaming.")
|
|
157
|
+
recs.append("For competitive esports, try jitter_blocks: 1.0 for ~15ms extra reduction.")
|
|
158
|
+
else:
|
|
159
|
+
recs.append("Ultra-low latency (<50ms)! Excellent esports and live broadcast performance.")
|
|
160
|
+
|
|
161
|
+
return LatencyResult(
|
|
162
|
+
roundtrip_ms=roundtrip_ms,
|
|
163
|
+
one_way_ms=one_way_ms,
|
|
164
|
+
confidence_score=conf,
|
|
165
|
+
method=method,
|
|
166
|
+
breakdown=breakdown,
|
|
167
|
+
recommendations=recs,
|
|
168
|
+
)
|
|
169
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Loopback capture for Acoustic Echo Cancellation (AEC).
|
|
2
|
+
|
|
3
|
+
Captures default speaker / headphone output audio in memory and streams it
|
|
4
|
+
to the AEC reference buffer for real-time acoustic echo removal.
|
|
5
|
+
|
|
6
|
+
On Windows: uses native Windows WASAPI Loopback (zero-cable, in-memory).
|
|
7
|
+
On macOS / Linux: binds to default monitor or virtual output endpoints.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .backend import get_sd
|
|
17
|
+
from .devices import DeviceInfo
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("micforge.loopback")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LoopbackCapture:
|
|
23
|
+
"""Background stream that feeds speaker playback audio into AEC."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
sample_rate: int = 48000,
|
|
28
|
+
block_size: int = 480,
|
|
29
|
+
callback: Callable[[np.ndarray], None] | None = None,
|
|
30
|
+
device_spec: Any = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.sr = sample_rate
|
|
33
|
+
self.block = block_size
|
|
34
|
+
self.callback = callback
|
|
35
|
+
self.device_spec = device_spec
|
|
36
|
+
self.stream: Any = None
|
|
37
|
+
self._running = False
|
|
38
|
+
|
|
39
|
+
def start(self) -> bool:
|
|
40
|
+
"""Start capturing speaker loopback audio."""
|
|
41
|
+
sd = get_sd()
|
|
42
|
+
try:
|
|
43
|
+
extra = None
|
|
44
|
+
dev_idx = None
|
|
45
|
+
|
|
46
|
+
dev_idx = None
|
|
47
|
+
|
|
48
|
+
if self.device_spec is not None:
|
|
49
|
+
if isinstance(self.device_spec, DeviceInfo):
|
|
50
|
+
dev_idx = self.device_spec.index
|
|
51
|
+
elif isinstance(self.device_spec, int):
|
|
52
|
+
dev_idx = self.device_spec
|
|
53
|
+
elif isinstance(self.device_spec, str):
|
|
54
|
+
low_spec = self.device_spec.lower()
|
|
55
|
+
for d in sd.query_devices():
|
|
56
|
+
if low_spec in d["name"].lower() and d["max_input_channels"] > 0:
|
|
57
|
+
dev_idx = d["index"]
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
# If no device explicitly specified, search for common loopback/stereo mix endpoints
|
|
61
|
+
if dev_idx is None:
|
|
62
|
+
for d in sd.query_devices():
|
|
63
|
+
d_name = d["name"].lower()
|
|
64
|
+
if d["max_input_channels"] > 0 and any(k in d_name for k in ("stereo mix", "what u hear", "cable output")):
|
|
65
|
+
dev_idx = d["index"]
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
if dev_idx is None:
|
|
69
|
+
log.debug("No loopback/stereo mix reference device available. AEC operating in passive mode.")
|
|
70
|
+
self._running = False
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
self.stream = sd.InputStream(
|
|
74
|
+
device=dev_idx,
|
|
75
|
+
samplerate=self.sr,
|
|
76
|
+
blocksize=self.block,
|
|
77
|
+
channels=min(2, max(1, sd.query_devices(dev_idx)["max_input_channels"])),
|
|
78
|
+
dtype="float32",
|
|
79
|
+
callback=self._on_audio,
|
|
80
|
+
)
|
|
81
|
+
self.stream.start()
|
|
82
|
+
self._running = True
|
|
83
|
+
log.info("AEC loopback capture started successfully (device %s)", dev_idx)
|
|
84
|
+
return True
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
log.debug("AEC loopback not started: %s (AEC in passive clean mode)", exc)
|
|
87
|
+
self.stream = None
|
|
88
|
+
self._running = False
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def _on_audio(self, indata: np.ndarray, frames: int, _time: Any, _status: Any) -> None:
|
|
92
|
+
if self.callback is not None and self._running:
|
|
93
|
+
# Downmix stereo to mono
|
|
94
|
+
mono = np.mean(indata, axis=1, dtype=np.float32)
|
|
95
|
+
try:
|
|
96
|
+
self.callback(mono)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
def stop(self) -> None:
|
|
101
|
+
self._running = False
|
|
102
|
+
if self.stream is not None:
|
|
103
|
+
try:
|
|
104
|
+
self.stream.stop()
|
|
105
|
+
self.stream.close()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
self.stream = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_running(self) -> bool:
|
|
112
|
+
return self._running
|
|
113
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Level metering and spectrum analysis for the live UI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ..dsp.util import EPS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LevelSnapshot:
|
|
15
|
+
peak_db: float = -120.0 # ballistic peak (falls smoothly)
|
|
16
|
+
rms_db: float = -120.0
|
|
17
|
+
hold_db: float = -120.0
|
|
18
|
+
clip_count: int = 0
|
|
19
|
+
clipping: bool = False # clipped within the last second
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LevelMeter:
|
|
23
|
+
def __init__(self, sample_rate: int, fall_db_per_s: float = 26.0, hold_s: float = 1.5, rms_s: float = 0.3):
|
|
24
|
+
self.sr = sample_rate
|
|
25
|
+
self.fall = fall_db_per_s
|
|
26
|
+
self.hold_s = hold_s
|
|
27
|
+
self.rms_s = rms_s
|
|
28
|
+
self.reset()
|
|
29
|
+
|
|
30
|
+
def reset(self) -> None:
|
|
31
|
+
self._peak = -120.0
|
|
32
|
+
self._hold = -120.0
|
|
33
|
+
self._hold_age = 0.0
|
|
34
|
+
self._ms = 0.0
|
|
35
|
+
self._clips = 0
|
|
36
|
+
self._last_clip = -1e9
|
|
37
|
+
self._snap = LevelSnapshot()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def snapshot(self) -> LevelSnapshot:
|
|
41
|
+
return self._snap
|
|
42
|
+
|
|
43
|
+
def update(self, x: np.ndarray) -> LevelSnapshot:
|
|
44
|
+
n = x.shape[0]
|
|
45
|
+
if n == 0:
|
|
46
|
+
return self._snap
|
|
47
|
+
dt = n / self.sr
|
|
48
|
+
a = np.abs(x)
|
|
49
|
+
pk = float(a.max())
|
|
50
|
+
pk_db = 20.0 * math.log10(max(pk, EPS))
|
|
51
|
+
ms = float(np.mean(np.square(x, dtype=np.float64)))
|
|
52
|
+
k = math.exp(-dt / self.rms_s)
|
|
53
|
+
self._ms = k * self._ms + (1.0 - k) * ms
|
|
54
|
+
self._peak = max(pk_db, self._peak - self.fall * dt)
|
|
55
|
+
if pk_db >= self._hold:
|
|
56
|
+
self._hold, self._hold_age = pk_db, 0.0
|
|
57
|
+
else:
|
|
58
|
+
self._hold_age += dt
|
|
59
|
+
if self._hold_age > self.hold_s:
|
|
60
|
+
self._hold = max(pk_db, self._hold - self.fall * dt)
|
|
61
|
+
clipped = int(np.count_nonzero(a >= 0.999))
|
|
62
|
+
now = time.monotonic()
|
|
63
|
+
if clipped:
|
|
64
|
+
self._clips += clipped
|
|
65
|
+
self._last_clip = now
|
|
66
|
+
self._snap = LevelSnapshot(
|
|
67
|
+
peak_db=max(self._peak, -120.0),
|
|
68
|
+
rms_db=10.0 * math.log10(max(self._ms, EPS * EPS)),
|
|
69
|
+
hold_db=max(self._hold, -120.0),
|
|
70
|
+
clip_count=self._clips,
|
|
71
|
+
clipping=(now - self._last_clip) < 1.0,
|
|
72
|
+
)
|
|
73
|
+
return self._snap
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SpectrumAnalyzer:
|
|
77
|
+
"""Log-spaced band levels (dBFS per band) with fast attack / slow release."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
sample_rate: int,
|
|
82
|
+
n_fft: int = 2048,
|
|
83
|
+
n_bands: int = 32,
|
|
84
|
+
f_lo: float = 60.0,
|
|
85
|
+
f_hi: float = 16000.0,
|
|
86
|
+
interval_s: float = 0.05,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.sr = sample_rate
|
|
89
|
+
self.n_fft = n_fft
|
|
90
|
+
self.n_bands = n_bands
|
|
91
|
+
self.f_lo = f_lo
|
|
92
|
+
self.f_hi = min(f_hi, 0.45 * sample_rate)
|
|
93
|
+
self.interval = interval_s
|
|
94
|
+
self._win = np.hanning(n_fft)
|
|
95
|
+
self._norm = float(np.sum(self._win) / 2.0)
|
|
96
|
+
edges = np.geomspace(self.f_lo, self.f_hi, n_bands + 1)
|
|
97
|
+
idx = np.round(edges / (sample_rate / n_fft)).astype(int)
|
|
98
|
+
for i in range(1, idx.size):
|
|
99
|
+
if idx[i] <= idx[i - 1]:
|
|
100
|
+
idx[i] = idx[i - 1] + 1
|
|
101
|
+
self._idx = np.minimum(idx, n_fft // 2)
|
|
102
|
+
self._counts = np.maximum(np.diff(self._idx), 1)
|
|
103
|
+
self._buf = np.zeros(n_fft, dtype=np.float32)
|
|
104
|
+
self._bands = np.full(n_bands, -120.0)
|
|
105
|
+
self._last = 0.0
|
|
106
|
+
self._dirty = False
|
|
107
|
+
|
|
108
|
+
def _compute(self) -> None:
|
|
109
|
+
self._dirty = False
|
|
110
|
+
spec = np.fft.rfft(self._buf * self._win)
|
|
111
|
+
power = spec.real**2 + spec.imag**2
|
|
112
|
+
sums = np.add.reduceat(power[: self._idx[-1]], self._idx[:-1])
|
|
113
|
+
band_power = sums / self._counts / (self._norm**2)
|
|
114
|
+
db = 10.0 * np.log10(np.maximum(band_power, 1e-14))
|
|
115
|
+
self._bands = np.maximum(db, self._bands - 3.0)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def bands(self) -> np.ndarray:
|
|
119
|
+
if self._dirty:
|
|
120
|
+
self._compute()
|
|
121
|
+
return self._bands
|
|
122
|
+
|
|
123
|
+
def feed(self, x: np.ndarray) -> None:
|
|
124
|
+
n = x.shape[0]
|
|
125
|
+
if n >= self.n_fft:
|
|
126
|
+
self._buf[:] = x[-self.n_fft :]
|
|
127
|
+
elif n > 0:
|
|
128
|
+
self._buf[:-n] = self._buf[n:]
|
|
129
|
+
self._buf[-n:] = x
|
|
130
|
+
self._dirty = True
|
|
131
|
+
|
|
132
|
+
def band_centers(self) -> np.ndarray:
|
|
133
|
+
e = np.geomspace(self.f_lo, self.f_hi, self.n_bands + 1)
|
|
134
|
+
return np.sqrt(e[:-1] * e[1:])
|