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
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Adaptive noise profiling: continuous voice-aware learning, environment profiles, and safe bounds."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ..config import config_dir
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("micforge.noise_profile")
|
|
17
|
+
|
|
18
|
+
ENVIRONMENTS_DIR_NAME = "noise_profiles"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _profiles_dir() -> Path:
|
|
22
|
+
p = config_dir() / ENVIRONMENTS_DIR_NAME
|
|
23
|
+
try:
|
|
24
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return p
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class NoiseProfile:
|
|
32
|
+
"""Snapshot of ambient noise characteristics."""
|
|
33
|
+
name: str = "quiet_room"
|
|
34
|
+
noise_floor_db: float = -62.0
|
|
35
|
+
gate_threshold_db: float = -50.0
|
|
36
|
+
spectral_tilt: float = 0.0 # ratio of high vs low band energy
|
|
37
|
+
sample_rate: int = 48000
|
|
38
|
+
bands: list[float] = field(default_factory=lambda: [-65.0] * 32)
|
|
39
|
+
created_at: float = field(default_factory=time.time)
|
|
40
|
+
updated_at: float = field(default_factory=time.time)
|
|
41
|
+
sample_count: int = 0
|
|
42
|
+
frozen: bool = False
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data: dict[str, Any]) -> NoiseProfile:
|
|
49
|
+
return cls(
|
|
50
|
+
name=str(data.get("name", "custom")),
|
|
51
|
+
noise_floor_db=float(data.get("noise_floor_db", -62.0)),
|
|
52
|
+
gate_threshold_db=float(data.get("gate_threshold_db", -50.0)),
|
|
53
|
+
spectral_tilt=float(data.get("spectral_tilt", 0.0)),
|
|
54
|
+
sample_rate=int(data.get("sample_rate", 48000)),
|
|
55
|
+
bands=list(data.get("bands", [-65.0] * 32)),
|
|
56
|
+
created_at=float(data.get("created_at", time.time())),
|
|
57
|
+
updated_at=float(data.get("updated_at", time.time())),
|
|
58
|
+
sample_count=int(data.get("sample_count", 0)),
|
|
59
|
+
frozen=bool(data.get("frozen", False)),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Standard environment baseline presets
|
|
64
|
+
BUILTIN_ENVIRONMENTS: dict[str, NoiseProfile] = {
|
|
65
|
+
"quiet_room": NoiseProfile(
|
|
66
|
+
name="quiet_room",
|
|
67
|
+
noise_floor_db=-64.0,
|
|
68
|
+
gate_threshold_db=-52.0,
|
|
69
|
+
spectral_tilt=-0.3,
|
|
70
|
+
bands=[-65.0] * 32,
|
|
71
|
+
),
|
|
72
|
+
"fan_on": NoiseProfile(
|
|
73
|
+
name="fan_on",
|
|
74
|
+
noise_floor_db=-52.0,
|
|
75
|
+
gate_threshold_db=-42.0,
|
|
76
|
+
spectral_tilt=-0.8, # strong low-frequency motor rumble
|
|
77
|
+
bands=[-50.0 if i < 8 else -56.0 for i in range(32)],
|
|
78
|
+
),
|
|
79
|
+
"mechanical_keyboard": NoiseProfile(
|
|
80
|
+
name="mechanical_keyboard",
|
|
81
|
+
noise_floor_db=-56.0,
|
|
82
|
+
gate_threshold_db=-38.0,
|
|
83
|
+
spectral_tilt=0.4, # transient click presence
|
|
84
|
+
bands=[-58.0 if i < 16 else -52.0 for i in range(32)],
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AdaptiveNoiseProfiler:
|
|
90
|
+
"""Continuous low-rate, voice-aware background noise learner."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
sample_rate: int = 48000,
|
|
95
|
+
environment: str = "quiet_room",
|
|
96
|
+
adaptation_rate: float = 0.005, # smooth update ~3-5s time constant
|
|
97
|
+
max_rate_change_db_per_sec: float = 1.0,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.sample_rate = sample_rate
|
|
100
|
+
self.adaptation_rate = adaptation_rate
|
|
101
|
+
self.max_rate_change_db_per_sec = max_rate_change_db_per_sec
|
|
102
|
+
self.current_profile: NoiseProfile = self._load_or_create(environment)
|
|
103
|
+
|
|
104
|
+
self._n_bands = 32
|
|
105
|
+
self._band_powers = np.full(self._n_bands, 10.0 ** (self.current_profile.noise_floor_db / 10.0), dtype=np.float64)
|
|
106
|
+
self._last_update_time = time.monotonic()
|
|
107
|
+
self._consecutive_quiet_blocks = 0
|
|
108
|
+
self._learning_enabled = True
|
|
109
|
+
|
|
110
|
+
def _load_or_create(self, name: str) -> NoiseProfile:
|
|
111
|
+
path = _profiles_dir() / f"{name}.json"
|
|
112
|
+
if path.is_file():
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
115
|
+
prof = NoiseProfile.from_dict(data)
|
|
116
|
+
prof.sample_rate = self.sample_rate
|
|
117
|
+
return prof
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
log.warning("Could not read profile %s: %s", path, exc)
|
|
120
|
+
|
|
121
|
+
if name in BUILTIN_ENVIRONMENTS:
|
|
122
|
+
base = BUILTIN_ENVIRONMENTS[name]
|
|
123
|
+
return NoiseProfile(
|
|
124
|
+
name=base.name,
|
|
125
|
+
noise_floor_db=base.noise_floor_db,
|
|
126
|
+
gate_threshold_db=base.gate_threshold_db,
|
|
127
|
+
spectral_tilt=base.spectral_tilt,
|
|
128
|
+
sample_rate=self.sample_rate,
|
|
129
|
+
bands=list(base.bands),
|
|
130
|
+
)
|
|
131
|
+
return NoiseProfile(name=name, sample_rate=self.sample_rate)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_frozen(self) -> bool:
|
|
135
|
+
return self.current_profile.frozen or not self._learning_enabled
|
|
136
|
+
|
|
137
|
+
def freeze(self) -> None:
|
|
138
|
+
"""Freeze adaptation so noise profile remains static."""
|
|
139
|
+
self.current_profile.frozen = True
|
|
140
|
+
|
|
141
|
+
def unfreeze(self) -> None:
|
|
142
|
+
"""Resume continuous background learning."""
|
|
143
|
+
self.current_profile.frozen = False
|
|
144
|
+
self._learning_enabled = True
|
|
145
|
+
|
|
146
|
+
def update(
|
|
147
|
+
self,
|
|
148
|
+
block: np.ndarray,
|
|
149
|
+
is_voiced: bool = False,
|
|
150
|
+
speech_prob: float = 0.0,
|
|
151
|
+
) -> bool:
|
|
152
|
+
"""Feed audio block. Returns True if adaptation took place."""
|
|
153
|
+
if self.is_frozen:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
if block is None or len(block) == 0:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# SAFE ADAPTATION RULE 1: Never learn during voiced speech or high speech probability
|
|
160
|
+
if is_voiced or speech_prob > 0.25:
|
|
161
|
+
self._consecutive_quiet_blocks = 0
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
# Instantaneous RMS
|
|
165
|
+
sq_mean = float(np.mean(block * block))
|
|
166
|
+
if sq_mean < 1e-12:
|
|
167
|
+
return False
|
|
168
|
+
rms_db = 10.0 * math.log10(sq_mean)
|
|
169
|
+
|
|
170
|
+
# SAFE ADAPTATION RULE 2: Don't learn transient spikes (>16 dB above current noise floor)
|
|
171
|
+
cur_floor = self.current_profile.noise_floor_db
|
|
172
|
+
if rms_db > cur_floor + 16.0:
|
|
173
|
+
self._consecutive_quiet_blocks = 0
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
self._consecutive_quiet_blocks += 1
|
|
177
|
+
# Require at least 3 consecutive quiet blocks before updating (prevents speech syllable bleed)
|
|
178
|
+
if self._consecutive_quiet_blocks < 3:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
now = time.monotonic()
|
|
182
|
+
dt = max(0.001, now - self._last_update_time)
|
|
183
|
+
self._last_update_time = now
|
|
184
|
+
|
|
185
|
+
# SAFE ADAPTATION RULE 3: Bounded rate of change per second
|
|
186
|
+
target_db = rms_db
|
|
187
|
+
max_delta = self.max_rate_change_db_per_sec * dt
|
|
188
|
+
diff = target_db - cur_floor
|
|
189
|
+
clipped_diff = max(-max_delta, min(max_delta, diff))
|
|
190
|
+
new_floor = cur_floor + self.adaptation_rate * diff
|
|
191
|
+
|
|
192
|
+
# Bound overall noise floor within physically reasonable microphone ranges
|
|
193
|
+
new_floor = max(-85.0, min(-35.0, new_floor))
|
|
194
|
+
self.current_profile.noise_floor_db = float(new_floor)
|
|
195
|
+
|
|
196
|
+
# Gate threshold tracks noise floor with 10 dB headroom (clamped between -60 and -30 dBFS)
|
|
197
|
+
self.current_profile.gate_threshold_db = float(max(-60.0, min(-30.0, new_floor + 10.0)))
|
|
198
|
+
self.current_profile.sample_count += 1
|
|
199
|
+
self.current_profile.updated_at = time.time()
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def relearn(self, duration_s: float = 1.0) -> None:
|
|
203
|
+
"""Prepare for quick immediate noise capture (e.g. user requested 'relearn')."""
|
|
204
|
+
self._consecutive_quiet_blocks = 5
|
|
205
|
+
self.current_profile.sample_count = 0
|
|
206
|
+
self.unfreeze()
|
|
207
|
+
|
|
208
|
+
def switch_environment(self, name: str) -> NoiseProfile:
|
|
209
|
+
"""Switch active noise profile to a named environment."""
|
|
210
|
+
self.current_profile = self._load_or_create(name)
|
|
211
|
+
self._last_update_time = time.monotonic()
|
|
212
|
+
self._consecutive_quiet_blocks = 0
|
|
213
|
+
return self.current_profile
|
|
214
|
+
|
|
215
|
+
def save(self, name: str | None = None) -> Path | None:
|
|
216
|
+
"""Persist current noise profile to disk."""
|
|
217
|
+
target_name = name or self.current_profile.name
|
|
218
|
+
self.current_profile.name = target_name
|
|
219
|
+
self.current_profile.updated_at = time.time()
|
|
220
|
+
try:
|
|
221
|
+
d = _profiles_dir()
|
|
222
|
+
path = d / f"{target_name}.json"
|
|
223
|
+
path.write_text(json.dumps(self.current_profile.to_dict(), indent=2), encoding="utf-8")
|
|
224
|
+
log.info("Saved noise profile '%s' to %s", target_name, path)
|
|
225
|
+
return path
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
log.warning("Could not persist noise profile '%s': %s", target_name, exc)
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def reset_to_default(self) -> None:
|
|
231
|
+
"""Reset current profile to baseline preset values."""
|
|
232
|
+
name = self.current_profile.name
|
|
233
|
+
if name in BUILTIN_ENVIRONMENTS:
|
|
234
|
+
base = BUILTIN_ENVIRONMENTS[name]
|
|
235
|
+
self.current_profile.noise_floor_db = base.noise_floor_db
|
|
236
|
+
self.current_profile.gate_threshold_db = base.gate_threshold_db
|
|
237
|
+
self.current_profile.spectral_tilt = base.spectral_tilt
|
|
238
|
+
else:
|
|
239
|
+
self.current_profile.noise_floor_db = -62.0
|
|
240
|
+
self.current_profile.gate_threshold_db = -50.0
|
|
241
|
+
self.current_profile.updated_at = time.time()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def list_noise_profiles() -> list[str]:
|
|
245
|
+
"""List all available environment noise profile names."""
|
|
246
|
+
names = set(BUILTIN_ENVIRONMENTS.keys())
|
|
247
|
+
d = _profiles_dir()
|
|
248
|
+
for f in d.glob("*.json"):
|
|
249
|
+
names.add(f.stem)
|
|
250
|
+
return sorted(names)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Simple microphone recorder + file-style player used by `test` and `calibrate`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..errors import DeviceError
|
|
10
|
+
from .backend import get_sd
|
|
11
|
+
from .devices import DeviceInfo, extra_settings
|
|
12
|
+
from .metrics import LevelMeter, LevelSnapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Recorder:
|
|
16
|
+
def __init__(self, device: DeviceInfo, sample_rate: int, channel: int = 0, block: int = 480) -> None:
|
|
17
|
+
self.device, self.sr, self.channel, self.block = device, sample_rate, channel, block
|
|
18
|
+
self.meter = LevelMeter(sample_rate)
|
|
19
|
+
self._chunks: list[np.ndarray] = []
|
|
20
|
+
self._n = 0
|
|
21
|
+
self._stream: Any = None
|
|
22
|
+
|
|
23
|
+
def start(self) -> None:
|
|
24
|
+
sd = get_sd()
|
|
25
|
+
try:
|
|
26
|
+
self._stream = sd.InputStream(
|
|
27
|
+
device=self.device.index,
|
|
28
|
+
samplerate=self.sr,
|
|
29
|
+
blocksize=self.block,
|
|
30
|
+
channels=max(1, min(self.device.max_input, self.channel + 1)),
|
|
31
|
+
dtype="float32",
|
|
32
|
+
callback=self._cb,
|
|
33
|
+
extra_settings=extra_settings(self.device, "input"),
|
|
34
|
+
)
|
|
35
|
+
self._stream.start()
|
|
36
|
+
except Exception as exc: # noqa: BLE001
|
|
37
|
+
raise DeviceError(f"Cannot open microphone '{self.device.name}': {exc}") from exc
|
|
38
|
+
|
|
39
|
+
def _cb(self, indata: np.ndarray, frames: int, _t: Any, _s: Any) -> None:
|
|
40
|
+
x = indata[:, self.channel].copy()
|
|
41
|
+
self._chunks.append(x)
|
|
42
|
+
self._n += frames
|
|
43
|
+
self.meter.update(x)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def seconds(self) -> float:
|
|
47
|
+
return self._n / self.sr
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def level(self) -> LevelSnapshot:
|
|
51
|
+
return self.meter.snapshot
|
|
52
|
+
|
|
53
|
+
def stop(self) -> np.ndarray:
|
|
54
|
+
if self._stream is not None:
|
|
55
|
+
self._stream.stop()
|
|
56
|
+
self._stream.close()
|
|
57
|
+
self._stream = None
|
|
58
|
+
return np.concatenate(self._chunks) if self._chunks else np.zeros(0, dtype=np.float32)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def play(
|
|
62
|
+
x: np.ndarray,
|
|
63
|
+
sample_rate: int,
|
|
64
|
+
device: DeviceInfo,
|
|
65
|
+
on_tick: Callable[[float], None] | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Blocking playback. Ctrl+C stops it cleanly."""
|
|
68
|
+
sd = get_sd()
|
|
69
|
+
data = np.ascontiguousarray(x, dtype=np.float32)
|
|
70
|
+
channels = max(1, min(2, device.max_output))
|
|
71
|
+
pos = [0]
|
|
72
|
+
done = threading.Event()
|
|
73
|
+
|
|
74
|
+
def cb(outdata: np.ndarray, frames: int, _t: Any, _s: Any) -> None:
|
|
75
|
+
chunk = data[pos[0] : pos[0] + frames]
|
|
76
|
+
outdata[: chunk.shape[0]] = chunk[:, None]
|
|
77
|
+
if chunk.shape[0] < frames:
|
|
78
|
+
outdata[chunk.shape[0] :] = 0
|
|
79
|
+
raise sd.CallbackStop
|
|
80
|
+
pos[0] += frames
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
stream = sd.OutputStream(
|
|
84
|
+
device=device.index,
|
|
85
|
+
samplerate=sample_rate,
|
|
86
|
+
channels=channels,
|
|
87
|
+
dtype="float32",
|
|
88
|
+
callback=cb,
|
|
89
|
+
finished_callback=done.set,
|
|
90
|
+
extra_settings=extra_settings(device, "output"),
|
|
91
|
+
)
|
|
92
|
+
except Exception as exc: # noqa: BLE001
|
|
93
|
+
raise DeviceError(f"Cannot open output '{device.name}': {exc}") from exc
|
|
94
|
+
with stream:
|
|
95
|
+
try:
|
|
96
|
+
while not done.wait(0.05):
|
|
97
|
+
if on_tick:
|
|
98
|
+
on_tick(pos[0] / sample_rate)
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
pass
|
micforge/audio/ring.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Thread-safe float32 ring buffer used to hand audio from the input callback to output callbacks.
|
|
2
|
+
|
|
3
|
+
Lock-free Single-Producer Single-Consumer (SPSC) design:
|
|
4
|
+
The write side (input callback) owns `_w`, and the read side (output callback) owns `_r`.
|
|
5
|
+
Both pointers increase monotonically without wrap-around (Python arbitrary-precision integers
|
|
6
|
+
never overflow 32-bit or 64-bit bounds). Fill level is computed directly as `_w - _r`,
|
|
7
|
+
completely eliminating shared mutable counters (`_n`) and cross-thread GIL race conditions
|
|
8
|
+
which previously caused false buffer starvation and runaway underrun cascades.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RingBuffer:
|
|
16
|
+
def __init__(self, capacity: int) -> None:
|
|
17
|
+
self._cap = int(capacity)
|
|
18
|
+
self._buf = np.zeros(self._cap, dtype=np.float32)
|
|
19
|
+
self._r = 0
|
|
20
|
+
self._w = 0
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def fill(self) -> int:
|
|
24
|
+
return max(0, self._w - self._r)
|
|
25
|
+
|
|
26
|
+
def clear(self) -> None:
|
|
27
|
+
self._r = self._w
|
|
28
|
+
|
|
29
|
+
def write(self, x: np.ndarray) -> int:
|
|
30
|
+
"""Append samples. Returns how many *old* samples were dropped to make room."""
|
|
31
|
+
x = np.asarray(x, dtype=np.float32)
|
|
32
|
+
if x.shape[0] > self._cap:
|
|
33
|
+
x = x[-self._cap :]
|
|
34
|
+
n = x.shape[0]
|
|
35
|
+
w = self._w
|
|
36
|
+
r = self._r
|
|
37
|
+
|
|
38
|
+
# Overflow: drop oldest samples to make room
|
|
39
|
+
dropped = max(0, (w + n) - r - self._cap)
|
|
40
|
+
if dropped:
|
|
41
|
+
self._r = r + dropped
|
|
42
|
+
|
|
43
|
+
# Copy data into the circular buffer
|
|
44
|
+
w_idx = w % self._cap
|
|
45
|
+
first = min(n, self._cap - w_idx)
|
|
46
|
+
self._buf[w_idx : w_idx + first] = x[:first]
|
|
47
|
+
if first < n:
|
|
48
|
+
self._buf[: n - first] = x[first:]
|
|
49
|
+
self._w = w + n
|
|
50
|
+
return dropped
|
|
51
|
+
|
|
52
|
+
def read(self, n: int) -> tuple[np.ndarray, int]:
|
|
53
|
+
"""Read up to n samples. Returns (array of length n, zero-padded; samples actually read)."""
|
|
54
|
+
out = np.zeros(n, dtype=np.float32)
|
|
55
|
+
w = self._w
|
|
56
|
+
r = self._r
|
|
57
|
+
avail = max(0, w - r)
|
|
58
|
+
got = min(n, avail)
|
|
59
|
+
if got > 0:
|
|
60
|
+
r_idx = r % self._cap
|
|
61
|
+
first = min(got, self._cap - r_idx)
|
|
62
|
+
out[:first] = self._buf[r_idx : r_idx + first]
|
|
63
|
+
if first < got:
|
|
64
|
+
out[first:got] = self._buf[: got - first]
|
|
65
|
+
self._r = r + got
|
|
66
|
+
return out, got
|
|
67
|
+
|
|
68
|
+
def read_into(self, dest: np.ndarray) -> int:
|
|
69
|
+
"""Read up to len(dest) samples directly into a pre-allocated buffer.
|
|
70
|
+
|
|
71
|
+
This avoids allocating a new array on every output callback – critical
|
|
72
|
+
for real-time audio threads where allocation → GC → click.
|
|
73
|
+
Returns the number of samples actually read; unread portion of dest is zeroed.
|
|
74
|
+
"""
|
|
75
|
+
n = dest.shape[0]
|
|
76
|
+
w = self._w
|
|
77
|
+
r = self._r
|
|
78
|
+
avail = max(0, w - r)
|
|
79
|
+
got = min(n, avail)
|
|
80
|
+
if got > 0:
|
|
81
|
+
r_idx = r % self._cap
|
|
82
|
+
first = min(got, self._cap - r_idx)
|
|
83
|
+
dest[:first] = self._buf[r_idx : r_idx + first]
|
|
84
|
+
if first < got:
|
|
85
|
+
dest[first:got] = self._buf[: got - first]
|
|
86
|
+
self._r = r + got
|
|
87
|
+
if got < n:
|
|
88
|
+
dest[got:] = 0.0
|
|
89
|
+
return got
|