openaudiofix 0.1.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.
- openaudiofix/__init__.py +2 -0
- openaudiofix/__main__.py +5 -0
- openaudiofix/algorithms/__init__.py +0 -0
- openaudiofix/algorithms/align.py +39 -0
- openaudiofix/algorithms/aspade.py +114 -0
- openaudiofix/algorithms/declick.py +164 -0
- openaudiofix/algorithms/declick_detect.py +179 -0
- openaudiofix/algorithms/declip.py +261 -0
- openaudiofix/algorithms/phase_warp.py +312 -0
- openaudiofix/algorithms/v3methods/__init__.py +0 -0
- openaudiofix/algorithms/v3methods/ar48_ls.py +21 -0
- openaudiofix/algorithms/v3methods/ar_bidir_xfade.py +297 -0
- openaudiofix/algorithms/v3methods/ar_bidir_xfade_js.py +3 -0
- openaudiofix/algorithms/v3methods/ar_bidir_xfade_ls.py +26 -0
- openaudiofix/algorithms/v3methods/ar_ls_excite.py +365 -0
- openaudiofix/algorithms/v3methods/ar_ls_excite_js.py +3 -0
- openaudiofix/algorithms/v3methods/ensemble_select.py +74 -0
- openaudiofix/algorithms/v3methods/join_smooth.py +34 -0
- openaudiofix/algorithms/v3methods/pitch_wsola.py +420 -0
- openaudiofix/algorithms/wavio.py +95 -0
- openaudiofix/chain.py +63 -0
- openaudiofix/cli.py +274 -0
- openaudiofix/errors.py +36 -0
- openaudiofix/gpu-requirements.txt +47 -0
- openaudiofix/gpu.py +204 -0
- openaudiofix/io.py +173 -0
- openaudiofix/modules/__init__.py +44 -0
- openaudiofix/modules/base.py +82 -0
- openaudiofix/modules/declip.py +41 -0
- openaudiofix/modules/denoise.py +131 -0
- openaudiofix/modules/glitch.py +43 -0
- openaudiofix/modules/speech.py +81 -0
- openaudiofix/paths.py +60 -0
- openaudiofix/report.py +30 -0
- openaudiofix/speech.py +538 -0
- openaudiofix/synth.py +69 -0
- openaudiofix-0.1.0.dist-info/METADATA +168 -0
- openaudiofix-0.1.0.dist-info/RECORD +58 -0
- openaudiofix-0.1.0.dist-info/WHEEL +5 -0
- openaudiofix-0.1.0.dist-info/entry_points.txt +2 -0
- openaudiofix-0.1.0.dist-info/licenses/LICENSE +21 -0
- openaudiofix-0.1.0.dist-info/top_level.txt +2 -0
- server/__init__.py +0 -0
- server/app.py +1020 -0
- server/db.py +276 -0
- server/errors.py +26 -0
- server/fsbrowse.py +100 -0
- server/gpu_manager.py +313 -0
- server/gpu_selftest.py +72 -0
- server/launcher.py +93 -0
- server/locations.py +120 -0
- server/migrate.py +80 -0
- server/models.py +67 -0
- server/previews.py +59 -0
- server/ranges.py +71 -0
- server/storage.py +155 -0
- server/visual.py +106 -0
- server/worker.py +359 -0
openaudiofix/__init__.py
ADDED
openaudiofix/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Resampling and fixed-latency alignment for model outputs (copied from separation/runners/_common.py)."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
MAX_LAG = 4096 # alignment search range (+-samples); the model delay is usually within a few hundred samples
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resample(x, sr_in, sr_out):
|
|
8
|
+
if sr_in == sr_out:
|
|
9
|
+
return x
|
|
10
|
+
from scipy.signal import resample_poly
|
|
11
|
+
from math import gcd
|
|
12
|
+
g = gcd(sr_in, sr_out)
|
|
13
|
+
return resample_poly(x, sr_out // g, sr_in // g).astype(np.float32)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def align_to(ref, y):
|
|
17
|
+
"""Align the model output y to the original ref: compensate a fixed delay and trim to equal length.
|
|
18
|
+
Returns (y_aligned, lag). lag>0 means y is lag samples later than ref. Found by cross-correlating the whole
|
|
19
|
+
signal; speech-enhancement models do not change the time axis, so there should be only one fixed delay, and a
|
|
20
|
+
peak that is not prominent enough is treated as 0 (no spurious shifting)."""
|
|
21
|
+
n = min(len(ref), len(y))
|
|
22
|
+
a = ref[:n].astype(np.float64); b = y[:n].astype(np.float64)
|
|
23
|
+
L = 1 << int(np.ceil(np.log2(2 * n)))
|
|
24
|
+
A = np.fft.rfft(a, L); B = np.fft.rfft(b, L)
|
|
25
|
+
c = np.fft.irfft(np.conj(A) * B, L)
|
|
26
|
+
cand = np.r_[c[:MAX_LAG + 1], c[-MAX_LAG:]]
|
|
27
|
+
lags = np.r_[np.arange(0, MAX_LAG + 1), np.arange(-MAX_LAG, 0)]
|
|
28
|
+
k = int(np.argmax(cand))
|
|
29
|
+
lag = int(lags[k])
|
|
30
|
+
if cand[k] < 3 * np.median(np.abs(cand)):
|
|
31
|
+
lag = 0
|
|
32
|
+
if lag > 0:
|
|
33
|
+
y = np.r_[y[lag:], np.zeros(lag, np.float32)]
|
|
34
|
+
elif lag < 0:
|
|
35
|
+
y = np.r_[np.zeros(-lag, np.float32), y[:lag]]
|
|
36
|
+
if len(y) < len(ref):
|
|
37
|
+
y = np.r_[y, np.zeros(len(ref) - len(y), np.float32)]
|
|
38
|
+
return y[:len(ref)].astype(np.float32), lag
|
|
39
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""A-SPADE (analysis sparse audio declipper), for a direct comparison with the whole-frame joint AR
|
|
2
|
+
(declip.declip_joint).
|
|
3
|
+
|
|
4
|
+
Algorithm: Kitić, Bertin, Gribonval 2015; ADMM form and parameters follow Záviška et al. 2019/2021.
|
|
5
|
+
Each frame (after windowing) solves
|
|
6
|
+
min ||A x||_0 s.t. x ∈ Γ
|
|
7
|
+
A = 2x-redundant DFT (Parseval tight frame: A^H A = I), Γ = clipping-consistent set:
|
|
8
|
+
reliable samples x = y; positive clipping x ≥ y; negative clipping x ≤ y (y is the windowed observation).
|
|
9
|
+
ADMM:
|
|
10
|
+
z = H_k(A x + u) hard threshold, keep only the k largest coefficients
|
|
11
|
+
x = P_Γ(A^H (z − u)) project back onto the consistent set
|
|
12
|
+
stop if ||A x − z|| ≤ ε, otherwise u += A x − z, and every r iterations k += s
|
|
13
|
+
The fundamental difference from the AR family: the prior is "time-frequency sparsity", not "linear predictability".
|
|
14
|
+
For periodic speech, harmonics are already sparse in frequency, so in theory it suits dense clipping; the cost is many
|
|
15
|
+
iterations.
|
|
16
|
+
|
|
17
|
+
Frame synthesis: each frame is solved in the windowed domain (x_w ≈ g·x), multiplied by g again when overlap-adding,
|
|
18
|
+
and finally divided by Σg², which is correct for any overlap ratio.
|
|
19
|
+
Only frames containing clipping are processed; regions without clipping are unchanged sample by sample.
|
|
20
|
+
"""
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
# frame length (43 ms): speech is shorter than the music Záviška tested; start here, sweep later on the benchmark
|
|
24
|
+
W = 2048
|
|
25
|
+
HOP = 512 # 75% overlap
|
|
26
|
+
REDUND = 2 # DFT redundancy factor
|
|
27
|
+
S = 1 # sparsity relaxed at each step
|
|
28
|
+
R = 1 # relax once every R iterations
|
|
29
|
+
EPS = 0.1 # stopping criterion ||Ax − z|| (same as Záviška; relative to the scale of the windowed signal)
|
|
30
|
+
MAX_IT = 1000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _A(x, n):
|
|
34
|
+
return np.fft.fft(x, n, norm="ortho")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _AH(z, w):
|
|
38
|
+
return np.real(np.fft.ifft(z, norm="ortho"))[:w]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _hard(z, k):
|
|
42
|
+
"""Keep the k coefficients with the largest magnitude (conjugate pairs counted together: the threshold is decided
|
|
43
|
+
from the first half of the spectrum, which keeps the symmetry of a real signal)."""
|
|
44
|
+
a = np.abs(z)
|
|
45
|
+
if k >= len(a):
|
|
46
|
+
return z
|
|
47
|
+
thr = np.partition(a, len(a) - k)[len(a) - k]
|
|
48
|
+
return np.where(a >= thr, z, 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def aspade_frame(y, rel, cp, cn, eps=EPS, s=S, r=R, max_it=MAX_IT):
|
|
52
|
+
"""y: windowed frame; rel/cp/cn: reliable / positive-clip / negative-clip masks.
|
|
53
|
+
Returns the reconstructed frame (in the windowed domain)."""
|
|
54
|
+
w = len(y); n = REDUND * w
|
|
55
|
+
x = y.copy(); u = np.zeros(n, dtype=complex)
|
|
56
|
+
k = s
|
|
57
|
+
for it in range(1, max_it + 1):
|
|
58
|
+
Ax = _A(x, n)
|
|
59
|
+
z = _hard(Ax + u, k)
|
|
60
|
+
v = _AH(z - u, w)
|
|
61
|
+
x = v.copy() # projection onto Γ
|
|
62
|
+
x[rel] = y[rel]
|
|
63
|
+
x[cp] = np.maximum(v[cp], y[cp])
|
|
64
|
+
x[cn] = np.minimum(v[cn], y[cn])
|
|
65
|
+
Ax = _A(x, n)
|
|
66
|
+
res = np.linalg.norm(Ax - z)
|
|
67
|
+
if res <= eps:
|
|
68
|
+
break
|
|
69
|
+
u = u + Ax - z
|
|
70
|
+
if it % r == 0:
|
|
71
|
+
k += 2 * s # 2 = conjugate pair; makes the "true" sparsity grow by s each time
|
|
72
|
+
return x, it
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def declip_aspade(x, sr, thr_pos=None, thr_neg=None, w=W, hop=HOP, eps=EPS, max_it=MAX_IT, progress=None):
|
|
76
|
+
"""Process the whole signal. If thr is not given, it is inferred with declip.estimate_threshold. Returns (y,
|
|
77
|
+
info)."""
|
|
78
|
+
x = np.asarray(x, dtype=np.float64)
|
|
79
|
+
if thr_pos is None:
|
|
80
|
+
from . import declip as _D
|
|
81
|
+
thr_pos, thr_neg, det = _D.estimate_threshold(x)
|
|
82
|
+
if not det:
|
|
83
|
+
return x.copy(), dict(detected=False)
|
|
84
|
+
cp_all = x >= thr_pos; cn_all = x <= thr_neg
|
|
85
|
+
clipped = cp_all | cn_all
|
|
86
|
+
g = np.hanning(w + 2)[1:-1]
|
|
87
|
+
num = np.zeros(len(x)); den = np.zeros(len(x))
|
|
88
|
+
its = []
|
|
89
|
+
starts = range(-w + hop, len(x), hop)
|
|
90
|
+
for st in starts:
|
|
91
|
+
a, b = st, st + w
|
|
92
|
+
a0, b0 = max(a, 0), min(b, len(x))
|
|
93
|
+
if not clipped[a0:b0].any():
|
|
94
|
+
continue
|
|
95
|
+
seg = np.zeros(w); seg[a0 - a:b0 - a] = x[a0:b0]
|
|
96
|
+
cp = np.zeros(w, bool); cn = np.zeros(w, bool)
|
|
97
|
+
cp[a0 - a:b0 - a] = cp_all[a0:b0]; cn[a0 - a:b0 - a] = cn_all[a0:b0]
|
|
98
|
+
rel = ~(cp | cn)
|
|
99
|
+
yw = seg * g
|
|
100
|
+
xr, it = aspade_frame(yw, rel, cp, cn, eps=eps, max_it=max_it)
|
|
101
|
+
its.append(it)
|
|
102
|
+
if progress is not None and len(its) % 25 == 0:
|
|
103
|
+
progress(max(st, 0), len(x))
|
|
104
|
+
num[a0:b0] += (xr * g)[a0 - a:b0 - a]
|
|
105
|
+
den[a0:b0] += (g * g)[a0 - a:b0 - a]
|
|
106
|
+
y = x.copy()
|
|
107
|
+
m = clipped & (den > 1e-9)
|
|
108
|
+
y[m] = num[m] / den[m]
|
|
109
|
+
# Consistency: reconstructed values must not fall back inside the threshold (numerical error at window edges can
|
|
110
|
+
# cause tiny violations)
|
|
111
|
+
y[cp_all] = np.maximum(y[cp_all], thr_pos)
|
|
112
|
+
y[cn_all] = np.minimum(y[cn_all], thr_neg)
|
|
113
|
+
return y, dict(detected=True, n_frames=len(its), iters_median=int(np.median(its)) if its else 0,
|
|
114
|
+
peak=float(np.abs(y).max()))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Repair of phase jumps from dropped samples (single file): detect, then phase-aligned micro-warp / per-hole
|
|
2
|
+
interpolation, then verify the residue.
|
|
3
|
+
|
|
4
|
+
"Track" here corresponds to "channel" (mono=1, stereo=2).
|
|
5
|
+
Uses the same logic as the whole-file pipeline for a long real recording: adjacent/overlapping
|
|
6
|
+
seams are first merged into one discard zone and solved at once; after repair the detector is rerun, residues first
|
|
7
|
+
fall back to the safe path and, if that still fails, are widened, and every round reapplies all patches
|
|
8
|
+
from the original file. The detector is the generic single-file version (`declick_detect.py`).
|
|
9
|
+
"""
|
|
10
|
+
import time
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from . import declick_detect as D, phase_warp
|
|
14
|
+
|
|
15
|
+
MAX_PASS = 4
|
|
16
|
+
CLUSTER_MARGIN = 330 # seams closer than this are merged into one discard zone and solved at once
|
|
17
|
+
RESID_NEAR = 200 # residual events within ± this range of an existing group count as the same spot
|
|
18
|
+
MAX_GAP = 960
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def merge_groups(seams, margin=CLUSTER_MARGIN):
|
|
22
|
+
"""seams: {p: {gap, za, zb, ...}} → merge nearby / overlapping windows [za-gap/2, zb+gap/2) into groups.
|
|
23
|
+
Returns a list of dict(za, zb, subs=[(a,b),...], positions=[p,...], force=False), sorted by position."""
|
|
24
|
+
items = []
|
|
25
|
+
for p, s in sorted(seams.items()):
|
|
26
|
+
half = s["gap"] // 2
|
|
27
|
+
a = min(s["za"] - half, s["za"] - 1)
|
|
28
|
+
b = max(s["zb"] + half, s["zb"] + 1)
|
|
29
|
+
if b - a < s["gap"]:
|
|
30
|
+
b = a + s["gap"]
|
|
31
|
+
items.append(dict(p=p, za=s["za"], zb=s["zb"], a=a, b=b, gap=s["gap"]))
|
|
32
|
+
groups = []
|
|
33
|
+
for it in items:
|
|
34
|
+
if groups and it["a"] <= groups[-1]["zb"] + margin:
|
|
35
|
+
g = groups[-1]
|
|
36
|
+
g["zb"] = max(g["zb"], it["zb"]); g["b"] = max(g["b"], it["b"])
|
|
37
|
+
g["subs"].append((it["a"], it["b"])); g["positions"].append(it["p"])
|
|
38
|
+
else:
|
|
39
|
+
groups.append(dict(za=it["za"], zb=it["zb"], a=it["a"], b=it["b"],
|
|
40
|
+
subs=[(it["a"], it["b"])], positions=[it["p"]]))
|
|
41
|
+
for g in groups:
|
|
42
|
+
g["force"] = any(seams[p].get("force", False) for p in g["positions"])
|
|
43
|
+
return groups
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_pass(X, groups, sr, on_group=None):
|
|
47
|
+
"""Call phase_warp.repair_joint on each group in turn; returns (new channel arrays, per-group repair records).
|
|
48
|
+
on_group(done, total): optional; reports progress after each batch of groups is repaired (does not affect the
|
|
49
|
+
result)."""
|
|
50
|
+
Y = [x.copy() for x in X]
|
|
51
|
+
records = []
|
|
52
|
+
prev_end = -10 ** 9
|
|
53
|
+
for gi, g in enumerate(groups):
|
|
54
|
+
if on_group and gi % 25 == 0:
|
|
55
|
+
on_group(gi, len(groups))
|
|
56
|
+
roomL = g["za"] - max(prev_end, groups[gi - 1]["zb"] if gi else -10 ** 9)
|
|
57
|
+
roomR = (groups[gi + 1]["za"] if gi + 1 < len(groups) else 10 ** 12) - g["zb"]
|
|
58
|
+
WL_av = roomL - 32
|
|
59
|
+
WR_av = roomR // 2 - 32
|
|
60
|
+
# Zero everything when there is not enough room (rather than passing a small non-zero value): a warp window
|
|
61
|
+
# shorter
|
|
62
|
+
# than the pitch period makes phase_warp's internal "extension start continuity correction" read out of bounds.
|
|
63
|
+
# The whole-file pipeline always had this safety margin (its `tight` check); the single-file pipeline initially
|
|
64
|
+
# missed
|
|
65
|
+
# this step, and an end-to-end test on a real clip with dense defects caught it (the stereo test clip triggered
|
|
66
|
+
# a
|
|
67
|
+
# broadcast error in render()), so it was added here too.
|
|
68
|
+
tight = 2 * (phase_warp.W_AVAIL_MIN + 32) + 64
|
|
69
|
+
if roomR < tight:
|
|
70
|
+
WR_av = 0
|
|
71
|
+
if roomL < tight:
|
|
72
|
+
WL_av = 0
|
|
73
|
+
if g["za"] - phase_warp.NEED - phase_warp.W_MAX < 0 or g["zb"] + phase_warp.NEED + phase_warp.W_MAX > len(Y[0]):
|
|
74
|
+
res = None
|
|
75
|
+
else:
|
|
76
|
+
res = phase_warp.repair_joint(Y, g["za"], g["zb"], g["subs"], sr, WL_av=max(WL_av, 0),
|
|
77
|
+
WR_av=max(WR_av, 0), roomL=max(roomL, 0), roomR=max(roomR, 0),
|
|
78
|
+
force_fallback=g["force"])
|
|
79
|
+
if res is None:
|
|
80
|
+
lo0, hi0 = g["a"], g["b"]
|
|
81
|
+
for t in range(len(Y)):
|
|
82
|
+
o = max(lo0 - 16384, 0)
|
|
83
|
+
ctx = Y[t][o:hi0 + 16384].copy(); ctx[lo0 - o:hi0 - o] = np.nan
|
|
84
|
+
from .v3methods import ensemble_select
|
|
85
|
+
yv = ensemble_select.fill(ctx, lo0 - o, hi0 - o, sr)
|
|
86
|
+
Y[t][lo0:hi0] = yv
|
|
87
|
+
info = dict(mode="fallback-edge", lo=lo0, hi=hi0)
|
|
88
|
+
end = hi0
|
|
89
|
+
else:
|
|
90
|
+
end = g["b"]
|
|
91
|
+
for t, (lo, y, info_t) in enumerate(res):
|
|
92
|
+
Y[t][lo:lo + len(y)] = y
|
|
93
|
+
end = max(end, lo + len(y))
|
|
94
|
+
info = res[0][2]
|
|
95
|
+
records.append(dict(group=g, info=info))
|
|
96
|
+
prev_end = end
|
|
97
|
+
return Y, records
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def repair_file(channels, sr, job_dir, progress=None, detected=None):
|
|
101
|
+
"""channels: list of 1D float64 np.array (equal length). Returns (Y, seams_final, grid_info, history).
|
|
102
|
+
detected: optional (seams, grid), e.g. the result of detecting on another signal (the original, before declip
|
|
103
|
+
repair);
|
|
104
|
+
if given, detection is not rerun on channels."""
|
|
105
|
+
X0 = [x.copy() for x in channels]
|
|
106
|
+
if detected is None:
|
|
107
|
+
seams, grid = D.detect_seams(X0, sr)
|
|
108
|
+
else:
|
|
109
|
+
seams, grid = {p: dict(s) for p, s in detected[0].items()}, detected[1]
|
|
110
|
+
if progress:
|
|
111
|
+
progress(state="repairing", grid=grid, n_seams_initial=len(seams))
|
|
112
|
+
history = []
|
|
113
|
+
Y = X0
|
|
114
|
+
for it in range(1, MAX_PASS + 1):
|
|
115
|
+
groups = merge_groups(seams)
|
|
116
|
+
t0 = time.time()
|
|
117
|
+
Y, records = run_pass(X0, groups, sr, on_group=(lambda i, n, _it=it: progress(
|
|
118
|
+
state="repairing", pass_=_it, done=i, total=n)) if progress else None)
|
|
119
|
+
if progress:
|
|
120
|
+
progress(state="repairing", pass_=it, n_groups=len(groups),
|
|
121
|
+
n_seams=len(seams), pass_seconds=round(time.time() - t0, 1))
|
|
122
|
+
evs, sts = [], []
|
|
123
|
+
for x in Y:
|
|
124
|
+
e, s = D.detect(x)
|
|
125
|
+
evs.append(e); sts.append(s)
|
|
126
|
+
tri = D.coincident_all(evs) if len(Y) > 1 else evs[0]
|
|
127
|
+
grid_tuple = (grid["L"], set(grid["residues"]), grid["coverage"], grid["confidence"])
|
|
128
|
+
resid = tri if len(Y) > 1 else np.array(
|
|
129
|
+
sorted(p for p, ntr, ongrid, strength in D.build_seam_list(evs, sts, grid_tuple) if ongrid or ntr >= 1))
|
|
130
|
+
widened = forced = added = 0
|
|
131
|
+
for t in resid.astype(int):
|
|
132
|
+
hit = [g for g in groups if g["za"] - RESID_NEAR <= t <= g["zb"] + RESID_NEAR]
|
|
133
|
+
if hit:
|
|
134
|
+
g = min(hit, key=lambda g: min(abs(g["za"] - t), abs(g["zb"] - t)))
|
|
135
|
+
if not g["force"]:
|
|
136
|
+
# this group used phase warping in the previous round and the residue is still there -> this round
|
|
137
|
+
# falls back to the safe path (more reliable than widening)
|
|
138
|
+
for p in g["positions"]:
|
|
139
|
+
seams[p]["force"] = True
|
|
140
|
+
forced += 1
|
|
141
|
+
else:
|
|
142
|
+
# already on the safe path and still residual -> widen the window and repair again
|
|
143
|
+
key_p = min(g["positions"], key=lambda p: abs(p - t))
|
|
144
|
+
s = seams[key_p]
|
|
145
|
+
if s["gap"] < MAX_GAP:
|
|
146
|
+
ext = min(s["gap"] // 2, (MAX_GAP - s["gap"]) // 2)
|
|
147
|
+
s["gap"] += 2 * ext; s["za"] -= ext; s["zb"] += ext
|
|
148
|
+
widened += 1
|
|
149
|
+
else:
|
|
150
|
+
Lg, residues, conf = grid["L"], set(grid["residues"]), grid["confidence"]
|
|
151
|
+
ongrid = (t % Lg) in residues if residues else False
|
|
152
|
+
if ongrid and conf >= D.GRID_CONFIDENCE_MIN:
|
|
153
|
+
seams[t] = dict(gap=D.GAP0, za=t, zb=t, ntracks=1, ongrid=True, strength=0.0, force=False)
|
|
154
|
+
added += 1
|
|
155
|
+
history.append(dict(pass_=it, residual=int(len(resid)), forced_fallback=forced, widened=widened, added=added))
|
|
156
|
+
if progress:
|
|
157
|
+
progress(state="repairing", history=history)
|
|
158
|
+
if forced == 0 and widened == 0 and added == 0:
|
|
159
|
+
break
|
|
160
|
+
mode_by_pos = {}
|
|
161
|
+
for rec in records:
|
|
162
|
+
for p in rec["group"]["positions"]:
|
|
163
|
+
mode_by_pos[p] = rec["info"].get("mode", "?")
|
|
164
|
+
return Y, seams, grid, history, mode_by_pos
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Phase-jump detection for dropped samples in a single uploaded file.
|
|
2
|
+
|
|
3
|
+
Adapted from an earlier pipeline: the same second-difference local-scale detection, cross-channel simultaneous (±TOL)
|
|
4
|
+
verification, and automatic buffer-grid inference. The difference is that "track" becomes "channel" here: a mono
|
|
5
|
+
upload has only 1 track, so the cross-track rule degrades to relying on the grid prior alone (more conservative, see the
|
|
6
|
+
note on `infer_grid`); a stereo upload has 2 tracks and can use the same "seen on >=2 tracks at once" rule as a
|
|
7
|
+
multi-track recording, which is much more accurate.
|
|
8
|
+
|
|
9
|
+
Looks for this one defect only: dropped samples caused by a driver/ASIO buffer underrun, where the signal jumps to
|
|
10
|
+
another phase within one sample and normal audio follows on both sides - not clipping, not an arbitrary click, not zero
|
|
11
|
+
insertion.
|
|
12
|
+
"""
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy.ndimage import maximum_filter1d
|
|
15
|
+
|
|
16
|
+
BLOCK = 2048 # block length for the local scale (median)
|
|
17
|
+
THRESH = 12.0 # d2 / local median above this value makes a candidate
|
|
18
|
+
MERGE = 64 # candidates within <= MERGE samples count as the same event; keep the strongest
|
|
19
|
+
TOL = 4 # tolerance (samples) for being "simultaneous" across channels
|
|
20
|
+
GAP0 = 96 # default gap (samples)
|
|
21
|
+
GAP_BIG = 160 # gap when the phase step is large
|
|
22
|
+
BIG_STEP = 40.0 # |x[p]-x[p-1]| / local median of the first difference above this counts as a "large phase step"
|
|
23
|
+
# a candidate seen on only one track/channel must exceed this strength and lie on the grid to be accepted
|
|
24
|
+
SINGLE_MIN_STRENGTH = 40.0
|
|
25
|
+
GRID_CANDIDATES = (32, 64, 128, 256, 512)
|
|
26
|
+
# infer_grid concentration below this ratio -> distrust the grid, use only the cross-channel rule
|
|
27
|
+
GRID_CONFIDENCE_MIN = 4.0
|
|
28
|
+
# for mono grid inference, filter out weak candidates first (see the note in detect_seams below)
|
|
29
|
+
GRID_STRENGTH_MIN = 24.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detect(x):
|
|
33
|
+
"""Returns (array of event positions, strength of each event). Strength = second difference / local median."""
|
|
34
|
+
d2 = np.abs(x[2:] - 2 * x[1:-1] + x[:-2]) # d2[i] corresponds to sample i+1
|
|
35
|
+
n = len(d2) // BLOCK * BLOCK
|
|
36
|
+
if n == 0:
|
|
37
|
+
return np.array([], int), np.array([])
|
|
38
|
+
med = np.median(d2[:n].reshape(-1, BLOCK), axis=1)
|
|
39
|
+
med = maximum_filter1d(np.maximum(med, 1e-9), 3)
|
|
40
|
+
sc = np.concatenate([np.repeat(med, BLOCK), np.full(len(d2) - n, med[-1] if len(med) else 1e-9)])
|
|
41
|
+
r = d2 / sc
|
|
42
|
+
cand = np.where(r > THRESH)[0] + 1
|
|
43
|
+
if not len(cand):
|
|
44
|
+
return np.array([], int), np.array([])
|
|
45
|
+
ev, st = [], []
|
|
46
|
+
peak, pv, last = cand[0], r[cand[0] - 1], cand[0]
|
|
47
|
+
for c in cand[1:]:
|
|
48
|
+
if c - last <= MERGE:
|
|
49
|
+
if r[c - 1] > pv:
|
|
50
|
+
peak, pv = c, r[c - 1]
|
|
51
|
+
else:
|
|
52
|
+
ev.append(peak); st.append(pv); peak, pv = c, r[c - 1]
|
|
53
|
+
last = c
|
|
54
|
+
ev.append(peak); st.append(pv)
|
|
55
|
+
return np.array(ev), np.array(st)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def nearest_dist(a, b):
|
|
59
|
+
if not len(b):
|
|
60
|
+
return np.full(len(a), np.inf)
|
|
61
|
+
j = np.searchsorted(b, a)
|
|
62
|
+
j0 = np.clip(j - 1, 0, len(b) - 1)
|
|
63
|
+
j1 = np.clip(j, 0, len(b) - 1)
|
|
64
|
+
return np.minimum(np.abs(b[j0] - a), np.abs(b[j1] - a))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def coincident_all(evs, tol=TOL):
|
|
68
|
+
"""Events that appear within ±tol on all channels (positions taken from the first channel). Returned unchanged for
|
|
69
|
+
mono."""
|
|
70
|
+
if len(evs) <= 1:
|
|
71
|
+
return evs[0]
|
|
72
|
+
a = evs[0]
|
|
73
|
+
m = np.ones(len(a), bool)
|
|
74
|
+
for b in evs[1:]:
|
|
75
|
+
m &= nearest_dist(a, b) <= tol
|
|
76
|
+
return a[m]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def infer_grid(pos, cands=GRID_CANDIDATES):
|
|
80
|
+
"""Infer the buffer length: for each candidate L, compute the share of "the two adjacent bins where the residues
|
|
81
|
+
are most concentrated". Returns (L, residue set, share, ratio relative to a uniform distribution)."""
|
|
82
|
+
if len(pos) == 0:
|
|
83
|
+
return (cands[0], set(), 0.0, 0.0)
|
|
84
|
+
stats = []
|
|
85
|
+
for L in cands:
|
|
86
|
+
h = np.bincount(pos % L, minlength=L)
|
|
87
|
+
top = np.argsort(h)[::-1][:2]
|
|
88
|
+
stats.append((L, set(int(t) for t in top), h[top].sum() / max(len(pos), 1)))
|
|
89
|
+
fmax = max(f for _, _, f in stats)
|
|
90
|
+
L, res, f = max((s for s in stats if s[2] >= 0.8 * fmax), key=lambda s: s[0])
|
|
91
|
+
return (L, res, f, f / (2 / L))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_seam_list(evs, sts, grid, tol=TOL):
|
|
95
|
+
"""Cluster each channel's candidate events into a seam list: seen on >=2 tracks across channels at once, or on one
|
|
96
|
+
track, on the grid and strong enough. Returns [(position, n_tracks_matched, on_grid, max_strength), ...]."""
|
|
97
|
+
allp = np.sort(np.concatenate(evs)) if any(len(e) for e in evs) else np.array([], int)
|
|
98
|
+
if not len(allp):
|
|
99
|
+
return []
|
|
100
|
+
L, residues, _, _ = grid
|
|
101
|
+
clusters, cur = [], [allp[0]]
|
|
102
|
+
for p in allp[1:]:
|
|
103
|
+
if p - cur[-1] <= tol:
|
|
104
|
+
cur.append(p)
|
|
105
|
+
else:
|
|
106
|
+
clusters.append(cur); cur = [p]
|
|
107
|
+
clusters.append(cur)
|
|
108
|
+
rows = []
|
|
109
|
+
for c in clusters:
|
|
110
|
+
p = int(min(c)); ntr = 0; smax = 0.0
|
|
111
|
+
for e, s in zip(evs, sts):
|
|
112
|
+
if not len(e):
|
|
113
|
+
continue
|
|
114
|
+
d = np.abs(e - p); j = int(np.argmin(d))
|
|
115
|
+
if d[j] <= tol:
|
|
116
|
+
ntr += 1; smax = max(smax, float(s[j]))
|
|
117
|
+
ongrid = (p % L) in residues if residues else False
|
|
118
|
+
if ntr >= 2 or (ongrid and smax > SINGLE_MIN_STRENGTH):
|
|
119
|
+
rows.append((p, ntr, ongrid, smax))
|
|
120
|
+
return rows
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def step_ratio(x, p):
|
|
124
|
+
lo, hi = max(0, p - 1024), min(len(x), p + 1024)
|
|
125
|
+
d1 = np.abs(np.diff(x[lo:hi]))
|
|
126
|
+
return float(abs(x[p] - x[p - 1]) / (np.median(d1) + 1e-12)) if 0 < p < len(x) else 0.0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cut_of(p, grid):
|
|
130
|
+
"""Convert a detected candidate position into the exact cut point "first real sample on the right" (accurate only
|
|
131
|
+
when the grid is trusted; otherwise returns p itself, and the repair side later adds a safety margin, treating it
|
|
132
|
+
as a tiny gap rather than a single-point splice)."""
|
|
133
|
+
L, residues, _, conf = grid
|
|
134
|
+
if conf >= GRID_CONFIDENCE_MIN and len(residues) >= 2:
|
|
135
|
+
lo = min(residues)
|
|
136
|
+
if p % L == lo:
|
|
137
|
+
return p + 1
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def detect_seams(channels, sr):
|
|
142
|
+
"""channels: list of 1D float64 array (one per channel, equal length).
|
|
143
|
+
Returns dict: {position: {"gap": int, "za": int, "zb": int, "ntracks": int, "ongrid": bool,
|
|
144
|
+
"strength": float, "grid_confident": bool}}, plus the grid info (for the front end to
|
|
145
|
+
display)."""
|
|
146
|
+
evs, sts = [], []
|
|
147
|
+
for x in channels:
|
|
148
|
+
e, s = detect(x)
|
|
149
|
+
evs.append(e); sts.append(s)
|
|
150
|
+
tri = coincident_all(evs)
|
|
151
|
+
if len(channels) > 1:
|
|
152
|
+
# simultaneous appearance across channels is strong evidence in itself, use it directly (real candidates are
|
|
153
|
+
# generally far more numerous than in the mono case, so the SNR is high enough)
|
|
154
|
+
grid = infer_grid(tri)
|
|
155
|
+
else:
|
|
156
|
+
# Mono: most of all candidates are ordinary transients (THRESH=12 is very loose), which dilute the residue
|
|
157
|
+
# distribution
|
|
158
|
+
# of the grid until it cannot be inferred. Grid inference uses only strong candidates (less noise), but once the
|
|
159
|
+
# grid is
|
|
160
|
+
# trustworthy, a weak candidate that lies on the grid can still enter build_seam_list (see the single-track
|
|
161
|
+
# branch
|
|
162
|
+
# below), so it is not blocked by this filter.
|
|
163
|
+
grid = infer_grid(evs[0][sts[0] >= GRID_STRENGTH_MIN])
|
|
164
|
+
if grid[3] < GRID_CONFIDENCE_MIN and len(channels) == 1:
|
|
165
|
+
# grid concentration still low: do not guess hard, to avoid taking ordinary transients for seams
|
|
166
|
+
grid = (grid[0], set(), 0.0, 0.0)
|
|
167
|
+
rows = build_seam_list(evs, sts, grid)
|
|
168
|
+
grid_confident = grid[3] >= GRID_CONFIDENCE_MIN
|
|
169
|
+
seams = {}
|
|
170
|
+
for p, ntr, ongrid, strength in rows:
|
|
171
|
+
gap = GAP_BIG if any(step_ratio(x, p) > BIG_STEP for x in channels) else GAP0
|
|
172
|
+
cp = cut_of(p, grid) if grid_confident else p
|
|
173
|
+
# when the grid is not trusted, leave a small gap instead of assuming a single-point splice
|
|
174
|
+
margin = 0 if grid_confident else 2
|
|
175
|
+
seams[int(p)] = dict(gap=gap, za=int(cp - margin), zb=int(cp + margin), ntracks=ntr,
|
|
176
|
+
ongrid=bool(ongrid), strength=round(float(strength), 1), force=False)
|
|
177
|
+
return seams, dict(L=grid[0], residues=sorted(grid[1]), coverage=round(grid[2], 4),
|
|
178
|
+
confidence=round(grid[3], 2), confident=grid_confident,
|
|
179
|
+
n_candidates=[int(len(e)) for e in evs], n_coincident=int(len(tri)))
|