Initial commit

This commit is contained in:
2026-10-09 02:48:24 +02:00
commit e05d973a8d
133 changed files with 101507 additions and 0 deletions

87
ackit/verify/loopback.py Normal file
View File

@@ -0,0 +1,87 @@
"""WASAPI loopback capture of the default speaker + detection of a reference sound in it."""
import threading
import warnings
import numpy as np
RATE = 48000
class LoopbackRecorder(threading.Thread):
def __init__(self, seconds, rate=RATE):
super().__init__(daemon=True)
self.seconds = seconds
self.rate = rate
self.chunks = []
self.stop_flag = False
self.error = None
def run(self):
try:
import soundcard as sc
sp = sc.default_speaker()
mic = sc.get_microphone(id=str(sp.name), include_loopback=True)
block = self.rate // 10
with warnings.catch_warnings():
warnings.simplefilter("ignore") # "data discontinuity" warnings
with mic.recorder(samplerate=self.rate, blocksize=block) as rec:
n = int(self.seconds * 10)
for _ in range(n):
if self.stop_flag:
break
self.chunks.append(rec.record(numframes=block))
except Exception as e: # noqa
self.error = repr(e)
def audio(self):
if not self.chunks:
return np.zeros(0, dtype=np.float32)
a = np.concatenate(self.chunks, axis=0)
return a.mean(axis=1).astype(np.float32) if a.ndim == 2 else a.astype(np.float32)
def resample(x, src_rate, dst_rate):
if src_rate == dst_rate:
return x.astype(np.float32)
n = int(round(len(x) * dst_rate / src_rate))
t = np.linspace(0, len(x) - 1, n)
return np.interp(t, np.arange(len(x)), x).astype(np.float32)
def detect(capture, ref, rate=RATE):
"""Max normalized cross-correlation of ref against every same-length window of capture.
Returns (score 0..1, time_offset_seconds, ref_rms_in_window)."""
if len(capture) < len(ref) or len(ref) == 0:
return 0.0, None, 0.0
ref = ref - ref.mean()
rn = np.linalg.norm(ref)
if rn == 0:
return 0.0, None, 0.0
n = 1 << int(np.ceil(np.log2(len(capture) + len(ref))))
xc = np.fft.irfft(np.fft.rfft(capture, n) * np.conj(np.fft.rfft(ref, n)), n)[:len(capture) - len(ref) + 1]
c2 = np.concatenate([[0.0], np.cumsum(capture.astype(np.float64) ** 2)])
win = np.sqrt(np.maximum(c2[len(ref):] - c2[:-len(ref)], 1e-12))
ncc = xc / (rn * win)
# ignore near-silent windows
ncc[win < 1e-3 * np.sqrt(len(ref))] = 0
i = int(np.argmax(np.abs(ncc)))
return float(abs(ncc[i])), i / rate, float(win[i] / np.sqrt(len(ref)))
def detect_chunks(capture, ref, rate=RATE, chunk_s=1.0, step_s=0.5):
"""Best NCC over 1 s chunks of the reference (robust to partial playback and resampler drift).
Returns (best score, chunk start in ref [s], position in capture [s])."""
c = int(chunk_s * rate)
s = int(step_s * rate)
best = (0.0, None, None)
if len(ref) <= c:
sc, t, _ = detect(capture, ref, rate)
return sc, 0.0, t
for i in range(0, len(ref) - c + 1, s):
seg = ref[i:i + c]
if np.sqrt(np.mean(seg ** 2)) < 1e-3:
continue
sc, t, _ = detect(capture, seg, rate)
if sc > best[0]:
best = (sc, i / rate, t)
return best