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.
Files changed (58) hide show
  1. openaudiofix/__init__.py +2 -0
  2. openaudiofix/__main__.py +5 -0
  3. openaudiofix/algorithms/__init__.py +0 -0
  4. openaudiofix/algorithms/align.py +39 -0
  5. openaudiofix/algorithms/aspade.py +114 -0
  6. openaudiofix/algorithms/declick.py +164 -0
  7. openaudiofix/algorithms/declick_detect.py +179 -0
  8. openaudiofix/algorithms/declip.py +261 -0
  9. openaudiofix/algorithms/phase_warp.py +312 -0
  10. openaudiofix/algorithms/v3methods/__init__.py +0 -0
  11. openaudiofix/algorithms/v3methods/ar48_ls.py +21 -0
  12. openaudiofix/algorithms/v3methods/ar_bidir_xfade.py +297 -0
  13. openaudiofix/algorithms/v3methods/ar_bidir_xfade_js.py +3 -0
  14. openaudiofix/algorithms/v3methods/ar_bidir_xfade_ls.py +26 -0
  15. openaudiofix/algorithms/v3methods/ar_ls_excite.py +365 -0
  16. openaudiofix/algorithms/v3methods/ar_ls_excite_js.py +3 -0
  17. openaudiofix/algorithms/v3methods/ensemble_select.py +74 -0
  18. openaudiofix/algorithms/v3methods/join_smooth.py +34 -0
  19. openaudiofix/algorithms/v3methods/pitch_wsola.py +420 -0
  20. openaudiofix/algorithms/wavio.py +95 -0
  21. openaudiofix/chain.py +63 -0
  22. openaudiofix/cli.py +274 -0
  23. openaudiofix/errors.py +36 -0
  24. openaudiofix/gpu-requirements.txt +47 -0
  25. openaudiofix/gpu.py +204 -0
  26. openaudiofix/io.py +173 -0
  27. openaudiofix/modules/__init__.py +44 -0
  28. openaudiofix/modules/base.py +82 -0
  29. openaudiofix/modules/declip.py +41 -0
  30. openaudiofix/modules/denoise.py +131 -0
  31. openaudiofix/modules/glitch.py +43 -0
  32. openaudiofix/modules/speech.py +81 -0
  33. openaudiofix/paths.py +60 -0
  34. openaudiofix/report.py +30 -0
  35. openaudiofix/speech.py +538 -0
  36. openaudiofix/synth.py +69 -0
  37. openaudiofix-0.1.0.dist-info/METADATA +168 -0
  38. openaudiofix-0.1.0.dist-info/RECORD +58 -0
  39. openaudiofix-0.1.0.dist-info/WHEEL +5 -0
  40. openaudiofix-0.1.0.dist-info/entry_points.txt +2 -0
  41. openaudiofix-0.1.0.dist-info/licenses/LICENSE +21 -0
  42. openaudiofix-0.1.0.dist-info/top_level.txt +2 -0
  43. server/__init__.py +0 -0
  44. server/app.py +1020 -0
  45. server/db.py +276 -0
  46. server/errors.py +26 -0
  47. server/fsbrowse.py +100 -0
  48. server/gpu_manager.py +313 -0
  49. server/gpu_selftest.py +72 -0
  50. server/launcher.py +93 -0
  51. server/locations.py +120 -0
  52. server/migrate.py +80 -0
  53. server/models.py +67 -0
  54. server/previews.py +59 -0
  55. server/ranges.py +71 -0
  56. server/storage.py +155 -0
  57. server/visual.py +106 -0
  58. server/worker.py +359 -0
@@ -0,0 +1,2 @@
1
+ """OpenAudioFix: self-hosted audio repair (glitch, declip, background noise)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)))