Tilawa Enhancer commited on
Commit
02a3daa
·
1 Parent(s): 0c43558

S162: SV-B1 isteidad filename, SV-B2 keepalive localhost, rm tajalli+itiqan-v5, add safaa-v3

Browse files
Files changed (4) hide show
  1. app.py +2 -2
  2. engine_itiqan_v5-3.py +0 -0
  3. engine_safaa_v3_fixed.py +863 -0
  4. engine_tajalli_v1.py +0 -947
app.py CHANGED
@@ -35,7 +35,7 @@ BASE = Path(__file__).parent
35
  ENGINE_SCRIPTS = {
36
  "v11.0": BASE / "engine_safaa_v3_fixed.py", # S149
37
  "v11.1": BASE / "true_engine_itiqan_v2_fixed.py",
38
- "v11.2": BASE / "engine_isteidad_v12.py",
39
  "v10.0": BASE / "engine_safaa_v3_fixed.py", # S149
40
  "v9.0": BASE / "engine_v90.py",
41
  "v8.9": BASE / "engine_v89.py",
@@ -137,7 +137,7 @@ threading.Thread(target=_janitor, daemon=True).start()
137
  def _keepalive():
138
  import urllib.request
139
  time.sleep(120)
140
- url = os.environ.get("SPACE_URL", "https://carm5333-tilawa-server.hf.space/ping")
141
  while True:
142
  try: urllib.request.urlopen(url, timeout=15)
143
  except Exception: pass
 
35
  ENGINE_SCRIPTS = {
36
  "v11.0": BASE / "engine_safaa_v3_fixed.py", # S149
37
  "v11.1": BASE / "true_engine_itiqan_v2_fixed.py",
38
+ "v11.2": BASE / "engine_isteidad_v21.py", # S162-SV-B1
39
  "v10.0": BASE / "engine_safaa_v3_fixed.py", # S149
40
  "v9.0": BASE / "engine_v90.py",
41
  "v8.9": BASE / "engine_v89.py",
 
137
  def _keepalive():
138
  import urllib.request
139
  time.sleep(120)
140
+ url = os.environ.get("SPACE_URL", "http://127.0.0.1:7860/ping") # S162-SV-B2
141
  while True:
142
  try: urllib.request.urlopen(url, timeout=15)
143
  except Exception: pass
engine_itiqan_v5-3.py DELETED
The diff for this file is too large to render. See raw diff
 
engine_safaa_v3_fixed.py ADDED
@@ -0,0 +1,863 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ الصفاء v3 — Dedicated Dereverberation Engine
4
+ إزالة الصدى والريفيرب
5
+
6
+ FIXES vs v2:
7
+ B1 _jalaa() late window: 150ms → 450ms (was reading 50-200ms; §3.3 says 50-500ms)
8
+ B2 _decode() now uses pcm_f32le → float32; soundfile when available (no 16-bit truncation)
9
+ B3 _band_energy() samples across full file (was FFT of first 1024 samples only)
10
+ B4 G4 Ra-trill: scans full audio in overlapping 1s windows (was first 1s only)
11
+ B5 _rt60() fallback slope condition was malformed (med-4 guard logic fixed)
12
+
13
+ IMPROVEMENTS vs v2:
14
+ I1 DF3 three passes now run in parallel (ThreadPoolExecutor) — 3× faster
15
+ I2 _enc() intermediates are mono; only final output is stereo
16
+ I3 _tailnr() reduces nr by 1 when JALAA already ran (avoid double-attenuation)
17
+ I4 DF3 speech-pass attenuation capped to §79 per-style limits:
18
+ Murattal ≤ 18 dB / Mujawwad ≤ 6 dB
19
+ I5 WPE LRA check uses 50% overlapping frames (was non-overlapping → noisy)
20
+ I6 SafaaState gains guard_pass/guard_warn lists (structured; JSON report improved)
21
+ I7 LF EQ: LF band RT60 scaled by 1.3× per §3.4 (LF decays slower than broadband)
22
+ I8 process() tracks all temp paths; single cleanup on exit (no leaks)
23
+ I9 _decode() try/finally ensures temp file removed on exception
24
+ I10 DRR-weighted JALAA: skip when DRR already > 6 dB (room is already reasonably dry)
25
+
26
+ PIPELINE (unchanged order)
27
+ S1 RT60 estimation (Schroeder backward integration)
28
+ S2 Sub-band LF room mode removal (3 bands, LF-scaled depth) [B5/I7]
29
+ S3 WPE dereverberation RT60 > 1.0s
30
+ S4 DF3 reverb-adapted (speech/transition/tail, parallel) [I1/I4]
31
+ S5 JALAA per-frame DRR gate [B1/I10]
32
+ S6 Tail floor NR (afftdn calibrated) [I3]
33
+ S7 Arabic phoneme guards [B3/B4]
34
+
35
+ USAGE
36
+ python3 engine_safaa_v3.py input.wav output.wav [--tier X] [--mujawwad 0.0] [--rt60 0.0]
37
+
38
+ KB REFS: §3 §28 §35 §36 §52 §79 §109 §138 §140 §143 §145 §151 §152 §154
39
+ """
40
+ from __future__ import annotations
41
+
42
+ __version__ = 'v3'
43
+
44
+ import os, shutil, subprocess, tempfile, time, warnings # S156: time for temp uniqueness
45
+ from concurrent.futures import ThreadPoolExecutor, as_completed
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import List, Optional, Tuple
49
+ warnings.filterwarnings('ignore')
50
+
51
+ _TMP = tempfile.gettempdir()
52
+
53
+ try:
54
+ import numpy as np
55
+ NUMPY_OK = True
56
+ except ImportError:
57
+ NUMPY_OK = False
58
+
59
+ try:
60
+ import soundfile as SF
61
+ SF_OK = True
62
+ except ImportError:
63
+ SF_OK = False
64
+
65
+ try:
66
+ from scipy.io import wavfile as _scipy_wavfile
67
+ SCIPY_WAV_OK = True
68
+ except ImportError:
69
+ SCIPY_WAV_OK = False
70
+
71
+ # DF3 binary
72
+ _DF3_CLI_BIN = ''
73
+ for _c in ['deep-filter', 'deepfilter', 'deep_filter', '/usr/local/bin/deep-filter']:
74
+ if shutil.which(_c):
75
+ _DF3_CLI_BIN = _c; break
76
+ DF3_OK = bool(_DF3_CLI_BIN)
77
+
78
+ # nara_wpe
79
+ WPE_OK = False
80
+ try:
81
+ from nara_wpe.wpe import wpe_v8
82
+ from nara_wpe.utils import stft as _wpe_stft, istft as _wpe_istft
83
+ WPE_OK = True
84
+ except ImportError:
85
+ pass
86
+
87
+ # ─── Constants ────────────────────────────────────────────────────────────────
88
+ SR = 48000
89
+ WAV_CODEC = 'pcm_s24le'
90
+ RT60_MIN = 0.15 # below: nothing to do
91
+ RT60_WPE_MIN = 1.00 # §109.6
92
+ RT60_TAILNR = 0.30
93
+ RT60_AGGR = 1.50
94
+ MUJ_RT60_FLOOR = 1.20 # §145.3
95
+
96
+ # §79 per-style DF attenuation hard limits
97
+ _DF3_LIM_MURATTAL = 18 # dB maximum
98
+ _DF3_LIM_MUJAWWAD = 6 # dB maximum
99
+ _DF3_SPEECH = 12
100
+ _DF3_TRANS = 20
101
+ _DF3_TAIL = 28
102
+
103
+ _CHUNK_S = 0.100
104
+ _XFADE_N = 960
105
+
106
+ _RMS_MAX_DELTA = 1.0
107
+ _LRA_MAX_DELTA = 0.5 # §109.4
108
+
109
+ DRR_ALREADY_DRY = 6.0 # dB — skip JALAA if DRR already above this [I10]
110
+
111
+
112
+ # ─── State ────────────────────────────────────────────────────────────────────
113
+ @dataclass
114
+ class SafaaState:
115
+ input_path: str = ''
116
+ source_tier: str = 'TIER_UNKNOWN'
117
+ mujawwad_conf: float = 0.0
118
+ rt60_initial: float = 0.0
119
+ drr_before: float = 0.0
120
+ drr_after: float = 0.0
121
+ lf_eq: bool = False
122
+ wpe: bool = False
123
+ df3: bool = False
124
+ jalaa: bool = False
125
+ tail_nr: bool = False
126
+ guard_reverts: int = 0
127
+ guard_pass: List[str] = field(default_factory=list) # [I6]
128
+ guard_warn: List[str] = field(default_factory=list) # [I6]
129
+ log: List[str] = field(default_factory=list)
130
+ _tmps: List[str] = field(default_factory=list, repr=False)
131
+
132
+ def _L(st, msg):
133
+ st.log.append(msg); print(msg)
134
+
135
+ def _track(st, path):
136
+ """Register a temp path for cleanup. Returns path."""
137
+ if path and path not in (st.input_path,):
138
+ st._tmps.append(path)
139
+ return path
140
+
141
+ def _cleanup_all(st):
142
+ for p in st._tmps:
143
+ try:
144
+ if p and os.path.exists(p): os.unlink(p)
145
+ except Exception:
146
+ pass
147
+ st._tmps.clear()
148
+
149
+
150
+ # ─── FFmpeg helpers ───────────────────────────────────────────────────────────
151
+ def _run(cmd, timeout=600):
152
+ r = subprocess.run(cmd, capture_output=True, timeout=timeout)
153
+ return r.returncode, r.stdout, r.stderr
154
+
155
+ def _tmp(tag, st=None):
156
+ # S156-B2: add nanosecond suffix — avoids collision between concurrent server jobs
157
+ p = os.path.join(_TMP, f'safaa3_{tag}_{os.getpid()}_{time.time_ns()}.wav')
158
+ if st is not None:
159
+ st._tmps.append(p)
160
+ return p
161
+
162
+ def _cleanup(*paths):
163
+ for p in paths:
164
+ try:
165
+ if p and os.path.exists(p): os.unlink(p)
166
+ except Exception:
167
+ pass
168
+
169
+ def _decode(path, st=None):
170
+ """
171
+ Float32 mono at SR. [B2/I9]
172
+ Uses soundfile when available (faster, native float32).
173
+ Falls back to ffmpeg pcm_f32le + wave read.
174
+ try/finally ensures temp file removed on exception.
175
+ """
176
+ if not NUMPY_OK:
177
+ return None
178
+ t = None
179
+ try:
180
+ if SF_OK:
181
+ # ffmpeg → pcm_f32le temp, then soundfile reads it natively
182
+ t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}_{time.time_ns()}.wav') # S156-B2
183
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
184
+ '-acodec', 'pcm_f32le',
185
+ '-ar', str(SR), '-ac', '1',
186
+ '-loglevel', 'error', t])
187
+ if rc or not os.path.exists(t):
188
+ return None
189
+ data, _ = SF.read(t, dtype='float32', always_2d=False)
190
+ return data
191
+ elif SCIPY_WAV_OK:
192
+ # scipy.io.wavfile can read pcm_f32le natively
193
+ t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}_{time.time_ns()}.wav') # S156-B2
194
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
195
+ '-acodec', 'pcm_f32le',
196
+ '-ar', str(SR), '-ac', '1',
197
+ '-loglevel', 'error', t])
198
+ if rc or not os.path.exists(t):
199
+ return None
200
+ _, data = _scipy_wavfile.read(t)
201
+ if data.dtype != np.float32:
202
+ data = data.astype(np.float32) / np.iinfo(data.dtype).max
203
+ return data.copy()
204
+ else:
205
+ # Final fallback: pcm_s16le → int16 → float32 normalised
206
+ t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}_{time.time_ns()}.wav') # S156-B2
207
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
208
+ '-acodec', 'pcm_s16le',
209
+ '-ar', str(SR), '-ac', '1',
210
+ '-loglevel', 'error', t])
211
+ if rc or not os.path.exists(t):
212
+ return None
213
+ import wave as _w
214
+ with _w.open(t, 'rb') as f:
215
+ raw = f.readframes(f.getnframes())
216
+ return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
217
+ except Exception:
218
+ return None
219
+ finally:
220
+ if t and os.path.exists(t):
221
+ try: os.unlink(t)
222
+ except Exception: pass
223
+
224
+ def _enc_mono(src, dst):
225
+ """Intermediate encode: mono 24-bit. [I2]"""
226
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', src,
227
+ '-acodec', WAV_CODEC,
228
+ '-ar', str(SR), '-ac', '1',
229
+ '-loglevel', 'error', dst])
230
+ return rc == 0 and os.path.exists(dst)
231
+
232
+ def _enc_stereo(src, dst):
233
+ """Final output encode: stereo 24-bit. [I2]"""
234
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', src,
235
+ '-acodec', WAV_CODEC,
236
+ '-ar', str(SR), '-ac', '2',
237
+ '-loglevel', 'error', dst])
238
+ return rc == 0 and os.path.exists(dst)
239
+
240
+ def _rmsdb(s):
241
+ return float(20 * np.log10(np.sqrt(np.mean(s ** 2)) + 1e-10))
242
+
243
+ def _band_energy(s, flo, fhi, n_fft=2048, n_samples=8):
244
+ """
245
+ Band energy estimate sampled across the full signal. [B3]
246
+ Averages n_samples evenly-spaced windows rather than only the first n_fft samples.
247
+ """
248
+ if len(s) < n_fft:
249
+ return 0.0
250
+ step = max(n_fft, len(s) // (n_samples + 1))
251
+ positions = range(0, len(s) - n_fft, step)
252
+ energies = []
253
+ fr = np.fft.rfftfreq(n_fft, 1.0 / SR)
254
+ mask = (fr >= flo) & (fr <= fhi)
255
+ for pos in positions:
256
+ sp = np.abs(np.fft.rfft(s[pos:pos + n_fft], n=n_fft))
257
+ energies.append(float(np.mean(sp[mask] ** 2) + 1e-20))
258
+ return float(np.mean(energies)) if energies else 0.0
259
+
260
+
261
+ # ─── Stage 1: RT60 (Schroeder backward integration) ──────────────────────────
262
+ def _rt60(samples, sr=SR):
263
+ """
264
+ Schroeder backward integration (§3.1).
265
+ Find -5 dB and -25 dB crossings of backward energy → T20 × 3 = T60.
266
+ Fallback: linear regression on the decay region. [B5]
267
+ """
268
+ if not NUMPY_OK or samples is None or len(samples) < sr * 3:
269
+ return 0.0
270
+ fn = int(0.020 * sr)
271
+ n = len(samples) // fn
272
+ if n < 30:
273
+ return 0.0
274
+
275
+ energy = np.array([float(np.mean(samples[i * fn:(i + 1) * fn] ** 2)) for i in range(n)])
276
+ energy = np.maximum(energy, 1e-20)
277
+ sch = np.cumsum(energy[::-1])[::-1]
278
+ sch_db = 10 * np.log10(sch / (sch[0] + 1e-20))
279
+
280
+ t5 = t25 = None
281
+ for i, v in enumerate(sch_db):
282
+ if t5 is None and v <= -5.0: t5 = i * 0.020
283
+ if t25 is None and v <= -25.0: t25 = i * 0.020; break
284
+ if t5 is not None and t25 is not None and t25 > t5:
285
+ return float(np.clip((t25 - t5) * 3.0, 0.0, 6.0))
286
+
287
+ # Fallback: slope estimation on the decay tail [B5]
288
+ edb = 10 * np.log10(energy)
289
+ med = float(np.median(edb))
290
+ # Find the decay region: frames below median but above noise floor
291
+ decay_mask = (edb > med - 30) & (edb < med - 2)
292
+ if decay_mask.sum() < 6:
293
+ return 0.0
294
+ t_arr = np.where(decay_mask)[0] * 0.020
295
+ e_arr = edb[decay_mask]
296
+ slope = float(np.polyfit(t_arr, e_arr, 1)[0])
297
+ if slope >= -1.0:
298
+ return 0.0
299
+ return float(np.clip(60.0 / abs(slope), 0.0, 4.0))
300
+
301
+
302
+ def _drr(samples, sr=SR):
303
+ """
304
+ DRR: energy ratio early(0-50ms) vs late(50-500ms) (§3.3/§28.6).
305
+ """
306
+ if not NUMPY_OK or samples is None:
307
+ return 0.0
308
+ en = int(0.050 * sr)
309
+ ln = int(0.450 * sr) # late window: 50-500ms
310
+ step = int(0.200 * sr)
311
+ vals = []
312
+ for s in range(0, len(samples) - en - ln, step):
313
+ er = float(np.sqrt(np.mean(samples[s:s + en] ** 2)) + 1e-10)
314
+ lr = float(np.sqrt(np.mean(samples[s + en:s + en + ln] ** 2)) + 1e-10)
315
+ if er > 1e-5 and lr > 1e-5:
316
+ vals.append(20.0 * np.log10(er / lr))
317
+ return float(np.median(vals)) if vals else 0.0
318
+
319
+
320
+ # ─── Stage 2: Sub-band LF EQ (§3.4) ─────────────────────────────────────────
321
+ def _lf_eq(wav, samples, rt60, st):
322
+ """
323
+ Three EQ bands with LF-scaled depth. [I7]
324
+ §3.4: LF bands have longer RT60 (empirically ~1.3× broadband).
325
+ Apply the LF multiplier before computing per-band depth.
326
+ Mujawwad: reduce depth and enforce RT60 floor (§145.3).
327
+ """
328
+ if rt60 < RT60_MIN:
329
+ return wav
330
+ lf_rt60 = rt60 * 1.30 # §3.4: LF decays ~30% slower [I7]
331
+ scale = float(np.clip(lf_rt60 / 0.5, 1.0, 6.0))
332
+ d_sub = float(np.clip(scale * 1.0, 1.0, 6.0))
333
+ d_lo = float(np.clip(scale * 0.7, 0.7, 4.2))
334
+ d_room = float(np.clip(scale * 0.4, 0.4, 2.4))
335
+
336
+ if st.mujawwad_conf > 0.6:
337
+ d_sub *= 0.5; d_lo *= 0.5; d_room *= 0.5
338
+ if rt60 < MUJ_RT60_FLOOR * 1.5:
339
+ r = float(np.clip((rt60 - MUJ_RT60_FLOOR) / (MUJ_RT60_FLOOR * 0.5 + 0.001), 0, 1))
340
+ d_sub *= r; d_lo *= r; d_room *= r
341
+ _L(st, f' [S2-LF] Mujawwad floor limiter ratio={r:.2f}')
342
+
343
+ flt = []
344
+ if d_sub > 0.2: flt.append(f'equalizer=f=150:width_type=o:width=1.4:g=-{d_sub:.1f}')
345
+ if d_lo > 0.2: flt.append(f'equalizer=f=300:width_type=o:width=1.2:g=-{d_lo:.1f}')
346
+ if d_room > 0.2: flt.append(f'equalizer=f=500:width_type=o:width=1.0:g=-{d_room:.1f}')
347
+ if not flt:
348
+ return wav
349
+
350
+ out = _tmp('s2', st)
351
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', wav, '-af', ','.join(flt),
352
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1', # mono [I2]
353
+ '-loglevel', 'error', out])
354
+ if rc or not os.path.exists(out):
355
+ return wav
356
+
357
+ post = _decode(out)
358
+ if post is not None and samples is not None:
359
+ d = _rmsdb(post) - _rmsdb(samples)
360
+ if abs(d) > 3.0: # relaxed: LF EQ at high RT60 can legitimately remove >1dB
361
+ _cleanup(out); st.guard_reverts += 1
362
+ _L(st, f' [S2-LF] RMS Δ={d:+.2f}dB — REVERT'); return wav
363
+
364
+ st.lf_eq = True
365
+ _L(st, f' [S2-LF] ✓ lf_rt60={lf_rt60:.2f}s sub={d_sub:.1f} lo={d_lo:.1f} room={d_room:.1f} dB')
366
+ return out
367
+
368
+
369
+ # ─── Stage 3: WPE (threshold 1.0s, §109.6) ───────────────────────────────────
370
+ def _lra_overlapping(s, frame_s=0.4, hop_s=0.2, sr=SR):
371
+ """LRA estimate using 50% overlapping frames. [I5]"""
372
+ fn = int(frame_s * sr)
373
+ hn = int(hop_s * sr)
374
+ if len(s) < fn:
375
+ return 0.0
376
+ db = [float(20 * np.log10(np.sqrt(np.mean(s[i:i + fn] ** 2)) + 1e-10))
377
+ for i in range(0, len(s) - fn, hn)]
378
+ return float(np.percentile(db, 95) - np.percentile(db, 10)) if len(db) >= 4 else 0.0
379
+
380
+ def _wpe(wav, rt60, st):
381
+ if not WPE_OK:
382
+ _L(st, ' [S3-WPE] nara_wpe not installed → pip install nara_wpe soundfile')
383
+ return wav
384
+ if rt60 < RT60_WPE_MIN:
385
+ _L(st, f' [S3-WPE] RT60={rt60:.2f}s < {RT60_WPE_MIN}s — skip (§109.6)')
386
+ return wav
387
+
388
+ _L(st, f' [S3-WPE] RT60={rt60:.2f}s — running WPE')
389
+ d = tempfile.mkdtemp(prefix='safaa3_wpe_')
390
+ try:
391
+ mi = os.path.join(d, 'in.wav')
392
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
393
+ '-acodec', 'pcm_f32le', '-ar', str(SR), '-ac', '1',
394
+ '-loglevel', 'error', mi])
395
+ if rc or not os.path.exists(mi):
396
+ return wav
397
+ if SF_OK:
398
+ y, _ = SF.read(mi, dtype='float32', always_2d=False)
399
+ else:
400
+ import wave as _w
401
+ with _w.open(mi, 'rb') as f: raw = f.readframes(f.getnframes())
402
+ y = np.frombuffer(raw, dtype=np.float32).copy()
403
+
404
+ if st.mujawwad_conf > 0.6: taps, iters = 5, 2
405
+ elif rt60 > 4.0: taps, iters = 12, 3
406
+ elif rt60 > 2.0: taps, iters = 10, 3
407
+ else: taps, iters = 8, 3
408
+ delay = 3
409
+
410
+ Y = _wpe_stft(y, size=512, shift=128)
411
+ Z = wpe_v8(Y[..., np.newaxis], taps=taps, delay=delay, iterations=iters)
412
+ z = _wpe_istft(Z[..., 0], size=512, shift=128)
413
+ z = z[:len(y)] if len(z) > len(y) else np.pad(z, (0, len(y) - len(z)))
414
+
415
+ ld = abs(_lra_overlapping(y) - _lra_overlapping(z)) # [I5]
416
+ if ld > _LRA_MAX_DELTA:
417
+ _L(st, f' [S3-WPE] LRA Δ={ld:.2f}LU — retry iters-1')
418
+ Z2 = wpe_v8(Y[..., np.newaxis], taps=taps, delay=delay, iterations=max(1, iters - 1))
419
+ z2 = _wpe_istft(Z2[..., 0], size=512, shift=128)
420
+ z2 = z2[:len(y)] if len(z2) > len(y) else np.pad(z2, (0, len(y) - len(z2)))
421
+ ld2 = abs(_lra_overlapping(y) - _lra_overlapping(z2))
422
+ if ld2 > _LRA_MAX_DELTA:
423
+ st.guard_reverts += 1
424
+ _L(st, f' [S3-WPE] LRA {ld2:.2f}LU after retry — REVERT'); return wav
425
+ z = z2; ld = ld2
426
+
427
+ mo = os.path.join(d, 'out.wav')
428
+ if SF_OK:
429
+ SF.write(mo, z.astype(np.float32), SR, subtype='FLOAT')
430
+ else:
431
+ import wave as _w
432
+ b16 = (np.clip(z, -1, 1) * 32767).astype(np.int16)
433
+ with _w.open(mo, 'wb') as f:
434
+ f.setnchannels(1); f.setsampwidth(2); f.setframerate(SR)
435
+ f.writeframes(b16.tobytes())
436
+ out = _tmp('s3', st)
437
+ if not _enc_mono(mo, out):
438
+ return wav
439
+ st.wpe = True
440
+ _L(st, f' [S3-WPE] ✓ taps={taps} delay={delay} iters={iters} LRA Δ={ld:.2f}LU')
441
+ return out
442
+ except Exception as e:
443
+ _L(st, f' [S3-WPE] exception: {e}'); return wav
444
+ finally:
445
+ shutil.rmtree(d, ignore_errors=True)
446
+
447
+
448
+ # ─── Stage 4: DF3 reverb-adapted pass (parallel) [I1/I4] ─────────────────────
449
+ def _df3_run_one(args):
450
+ """Worker: run DF3 at one attenuation level. Returns (name, array|None)."""
451
+ name, at, cli_bin, fi, od, SR_ = args
452
+ import wave as _w
453
+ r = subprocess.run([cli_bin, '--atten-lim-db', str(at), '-o', od, fi],
454
+ capture_output=True, timeout=600)
455
+ wp = os.path.join(od, os.path.basename(fi))
456
+ if r.returncode or not os.path.exists(wp):
457
+ return name, None
458
+ with _w.open(wp, 'rb') as f:
459
+ raw = f.readframes(f.getnframes())
460
+ return name, np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
461
+
462
+ def _df3(wav, samples, rt60, st):
463
+ if not DF3_OK:
464
+ _L(st, ' [S4-DF3] deep-filter not found — skip'); return wav
465
+
466
+ # Attenuation levels
467
+ ta = _DF3_TAIL + (4 if rt60 > RT60_AGGR else 0)
468
+ ra = _DF3_TRANS + (2 if rt60 > RT60_AGGR else 0)
469
+ sa = _DF3_SPEECH
470
+
471
+ # §79 per-style hard limits [I4]
472
+ if st.mujawwad_conf > 0.6:
473
+ lim = _DF3_LIM_MUJAWWAD
474
+ else:
475
+ lim = _DF3_LIM_MURATTAL
476
+ sa = min(sa, lim)
477
+ ra = min(ra, lim + 8) # transition/tail may exceed speech limit but respect style spirit
478
+ ta = min(ta, lim + 16)
479
+ if st.mujawwad_conf > 0.6:
480
+ ta = min(ta, int(ta * 0.70)); ra = min(ra, int(ra * 0.70)); sa = min(sa, int(sa * 0.80))
481
+
482
+ import wave as _w
483
+ d = tempfile.mkdtemp(prefix='safaa3_df3_')
484
+ try:
485
+ fi = os.path.join(d, 'in.wav')
486
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
487
+ '-acodec', 'pcm_s16le', '-ar', str(SR), '-ac', '1',
488
+ '-loglevel', 'error', fi])
489
+ if rc or not os.path.exists(fi):
490
+ return wav
491
+ with _w.open(fi, 'rb') as f: raw = f.readframes(f.getnframes())
492
+ s16 = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
493
+
494
+ cn = int(_CHUNK_S * SR); nc = len(s16) // cn
495
+ if nc < 1:
496
+ return wav
497
+
498
+ rms = np.array([float(np.sqrt(np.mean(s16[i*cn:(i+1)*cn]**2)) + 1e-10) for i in range(nc)])
499
+ zcr = np.array([float(np.mean(np.abs(np.diff(np.sign(s16[i*cn:(i+1)*cn]))))) for i in range(nc)])
500
+ rp30 = float(np.percentile(rms, 30)); rp70 = float(np.percentile(rms, 70))
501
+ zm = float(np.median(zcr))
502
+ labels = np.where((rms >= rp70) & (zcr >= zm * 0.7), 0,
503
+ np.where((rms <= rp30) & (zcr < zm * 0.5), 2, 1))
504
+
505
+ # Parallel DF3 passes [I1]
506
+ jobs = []
507
+ for nm, at in [('speech', sa), ('transition', ra), ('tail', ta)]:
508
+ od = os.path.join(d, nm); os.makedirs(od, exist_ok=True)
509
+ jobs.append((nm, at, _DF3_CLI_BIN, fi, od, SR))
510
+
511
+ pas = {}
512
+ with ThreadPoolExecutor(max_workers=3) as ex:
513
+ futs = {ex.submit(_df3_run_one, j): j[0] for j in jobs}
514
+ for fut in as_completed(futs):
515
+ nm, arr = fut.result()
516
+ if arr is None:
517
+ _L(st, f' [S4-DF3] {nm} failed — abort'); return wav
518
+ pas[nm] = arr
519
+ atten = next(j[1] for j in jobs if j[0] == nm)
520
+ _L(st, f' [S4-DF3] {nm:10s} {atten:2d}dB ✓')
521
+
522
+ pa = [pas['speech'], pas['transition'], pas['tail']]
523
+ ml = min(len(s16), min(len(a) for a in pa))
524
+ out_s = np.empty(ml, dtype=np.float32)
525
+ t = np.linspace(0, 1, _XFADE_N, dtype=np.float32)
526
+ ci_ = 0.5 * (1 - np.cos(np.pi * t)).astype(np.float32); co_ = 1 - ci_
527
+ pl = int(labels[0])
528
+ for ci in range(nc):
529
+ s = ci * cn; e = min((ci + 1) * cn, ml)
530
+ if e > ml: break
531
+ lb = int(labels[ci])
532
+ if lb != pl and ci > 0 and s + _XFADE_N <= ml:
533
+ b = min(_XFADE_N, e - s)
534
+ out_s[s:s+b] = pa[pl][s:s+b] * co_[:b] + pa[lb][s:s+b] * ci_[:b]
535
+ if e > s + _XFADE_N: out_s[s+_XFADE_N:e] = pa[lb][s+_XFADE_N:e]
536
+ else:
537
+ out_s[s:e] = pa[lb][s:e]
538
+ pl = lb
539
+
540
+ bm = os.path.join(d, 'blend.wav')
541
+ b16 = (np.clip(out_s, -1, 1) * 32767).astype(np.int16)
542
+ with _w.open(bm, 'wb') as f:
543
+ f.setnchannels(1); f.setsampwidth(2); f.setframerate(SR)
544
+ f.writeframes(b16.tobytes())
545
+ out = _tmp('s4', st)
546
+ if not _enc_mono(bm, out):
547
+ return wav
548
+
549
+ delta = _rmsdb(b16.astype(np.float32) / 32768.0) - _rmsdb(s16)
550
+ if abs(delta) > _RMS_MAX_DELTA * 2:
551
+ _cleanup(out); st.guard_reverts += 1
552
+ _L(st, f' [S4-DF3] RMS Δ={delta:+.2f}dB — REVERT'); return wav
553
+ st.df3 = True
554
+ _L(st, f' [S4-DF3] ✓ sa={sa} ra={ra} ta={ta} RMS Δ={delta:+.2f}dB '
555
+ f'speech={int(np.sum(labels==0))} trans={int(np.sum(labels==1))} '
556
+ f'tail={int(np.sum(labels==2))}')
557
+ return out
558
+ except Exception as e:
559
+ _L(st, f' [S4-DF3] exception: {e}'); return wav
560
+ finally:
561
+ shutil.rmtree(d, ignore_errors=True)
562
+
563
+
564
+ # ─── Stage 5: JALAA per-frame DRR gate (§28.6) ───────────────────────────────
565
+ def _jalaa(wav, samples, rt60, drr_before, st):
566
+ """
567
+ Per-frame DRR classification → calibrated afftdn on reverb-dominated frames.
568
+ [B1] Late window corrected to 450ms (50-500ms per §3.3).
569
+ [I10] Skip entirely if DRR already > DRR_ALREADY_DRY (room is dry enough).
570
+ """
571
+ if not NUMPY_OK or samples is None or rt60 < 0.30:
572
+ _L(st, f' [S5-JALAA] RT60={rt60:.2f}s < 0.30 — skip'); return wav
573
+ if drr_before > DRR_ALREADY_DRY:
574
+ _L(st, f' [S5-JALAA] DRR={drr_before:.1f}dB already dry — skip [I10]'); return wav
575
+
576
+ fn = int(0.200 * SR)
577
+ en = int(0.050 * SR)
578
+ ln = int(0.450 * SR) # [B1] was 0.150 → 450ms (50-500ms window)
579
+ n = len(samples) // fn
580
+ if n < 5:
581
+ return wav
582
+
583
+ nf_vals = []
584
+ for i in range(n):
585
+ s = i * fn; chunk = samples[s:s + fn]
586
+ if len(chunk) < en + ln:
587
+ continue
588
+ er = float(np.sqrt(np.mean(chunk[:en] ** 2)) + 1e-10)
589
+ lr = float(np.sqrt(np.mean(chunk[en:en + ln] ** 2)) + 1e-10)
590
+ if er < 1e-5: continue
591
+ if 20.0 * np.log10(er / lr) < 3.0: # reverb-dominated
592
+ nf_vals.append(float(20 * np.log10(lr + 1e-10)))
593
+
594
+ if not nf_vals:
595
+ _L(st, ' [S5-JALAA] no reverb frames — skip'); return wav
596
+
597
+ nf = float(np.clip(float(np.median(nf_vals)) + 3, -72, -25))
598
+ nr = 2 if rt60 > RT60_AGGR else 1
599
+ if st.mujawwad_conf > 0.6: nr = max(1, nr - 1)
600
+
601
+ out = _tmp('s5', st)
602
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
603
+ '-af', f'afftdn=nr={nr}:nf={nf:.0f}:tn=1',
604
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1',
605
+ '-loglevel', 'error', out])
606
+ if rc or not os.path.exists(out):
607
+ return wav
608
+
609
+ post = _decode(out)
610
+ if post is not None:
611
+ d = _rmsdb(post) - _rmsdb(samples)
612
+ if abs(d) > _RMS_MAX_DELTA:
613
+ _cleanup(out); st.guard_reverts += 1
614
+ _L(st, f' [S5-JALAA] RMS Δ={d:+.2f}dB — REVERT'); return wav
615
+
616
+ st.jalaa = True
617
+ _L(st, f' [S5-JALAA] ✓ reverb_frames={len(nf_vals)}/{n} nf={nf:.0f}dB nr={nr}')
618
+ return out
619
+
620
+
621
+ # ─── Stage 6: Tail floor NR ─────────────────────────────────────────────────���─
622
+ def _tailnr(wav, samples, rt60, st):
623
+ """
624
+ [I3] If JALAA already ran, reduce nr by 1 to avoid double-attenuation.
625
+ """
626
+ if rt60 < RT60_TAILNR or samples is None or not NUMPY_OK:
627
+ return wav
628
+ fn = int(0.200 * SR)
629
+ overall = _rmsdb(samples)
630
+ fdb = np.array([float(20 * np.log10(np.sqrt(np.mean(samples[i:i + fn] ** 2)) + 1e-10))
631
+ for i in range(0, len(samples) - fn, fn)])
632
+ quiet = fdb[fdb < overall - 10]
633
+ if len(quiet) == 0:
634
+ return wav
635
+ nf = float(np.clip(float(np.median(quiet)) + 4, -72, -30))
636
+ nr = 3 if rt60 > RT60_AGGR else 2
637
+ if st.mujawwad_conf > 0.6: nr = max(1, nr - 1)
638
+ if st.jalaa: nr = max(1, nr - 1) # [I3] JALAA already ran
639
+ out = _tmp('s6', st)
640
+ rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
641
+ '-af', f'afftdn=nr={nr}:nf={nf:.0f}:tn=1',
642
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1',
643
+ '-loglevel', 'error', out])
644
+ if rc or not os.path.exists(out):
645
+ return wav
646
+ st.tail_nr = True
647
+ jalaa_note = ' (JALAA-adjusted)' if st.jalaa else ''
648
+ _L(st, f' [S6-tailNR] ✓ nr={nr}{jalaa_note} nf={nf:.0f}dB')
649
+ return out
650
+
651
+
652
+ # ─── Stage 7: Arabic phoneme guards (§35/§52/§143/§152) ──────────────────────
653
+ def _arabic_guards(orig_s, proc_wav, st):
654
+ """
655
+ Seven guards verifying Tajweed-critical features survived processing.
656
+ WARN-only by design — reverb is worse than mild phoneme loss.
657
+ Results stored in st.guard_pass / st.guard_warn (structured). [I6]
658
+ [B3] _band_energy() now samples across full file.
659
+ [B4] G4 Ra-trill scans full audio in overlapping 1s windows.
660
+ """
661
+ if not NUMPY_OK or orig_s is None:
662
+ return proc_wav
663
+ proc_s = _decode(proc_wav)
664
+ if proc_s is None:
665
+ return proc_wav
666
+ n = min(len(orig_s), len(proc_s))
667
+ o = orig_s[:n]; p = proc_s[:n]
668
+
669
+ def chk(name, cond, detail):
670
+ entry = f'{name}: {detail}'
671
+ if cond:
672
+ st.guard_pass.append(entry); _L(st, f' [S7-PASS] {entry}')
673
+ else:
674
+ st.guard_warn.append(entry); _L(st, f' [S7-WARN] ⚠ {entry}')
675
+
676
+ # G1 Ghunnah 250-300 Hz (§152.3)
677
+ go = _band_energy(o, 250, 300); gp = _band_energy(p, 250, 300)
678
+ if go > 1e-15:
679
+ d = 10 * np.log10(gp / go + 1e-20)
680
+ chk('G1-Ghunnah', d >= -3.0, f'{d:+.1f}dB {"✓" if d>=-3 else "⚠ nasal murmur lost"}')
681
+
682
+ # G2 Ikhfa 250-400 Hz (§52.5)
683
+ io = _band_energy(o, 250, 400); ip = _band_energy(p, 250, 400)
684
+ if io > 1e-15:
685
+ d = 10 * np.log10(ip / io + 1e-20)
686
+ chk('G2-Ikhfa', d >= -4.0, f'{d:+.1f}dB {"✓" if d>=-4 else "⚠ ikhfa nasalisation lost"}')
687
+
688
+ # G3 Qalqalah burst (§52.7, §143 Class 2)
689
+ sil_n = int(0.020 * SR); bst_n = int(0.030 * SR)
690
+ total = viol = 0
691
+ for i in range(0, n - sil_n - bst_n, sil_n):
692
+ sr_ = float(np.sqrt(np.mean(o[i:i + sil_n] ** 2)) + 1e-10)
693
+ br_ = float(np.sqrt(np.mean(o[i + sil_n:i + sil_n + bst_n] ** 2)) + 1e-10)
694
+ if sr_ < 0.005 and br_ > sr_ * 5:
695
+ total += 1
696
+ bp = float(np.sqrt(np.mean(p[i + sil_n:i + sil_n + bst_n] ** 2)) + 1e-10)
697
+ if 20 * np.log10(bp / br_ + 1e-10) < -6.0: viol += 1
698
+ if total > 0:
699
+ pct = viol / total * 100
700
+ chk('G3-Qalqalah', pct <= 20,
701
+ f'{total} bursts {pct:.0f}% violated {"✓" if pct<=20 else "⚠ echo burst attenuated"}')
702
+
703
+ # G4 Ra trill AM 25-35 Hz — FULL AUDIO scan in overlapping 1s windows [B4]
704
+ win = SR; hop = SR // 2
705
+ am_ratios = []
706
+ for pos in range(0, n - win, hop):
707
+ ef_o = np.abs(np.fft.rfft(np.abs(o[pos:pos + win]), n=win))
708
+ ef_p = np.abs(np.fft.rfft(np.abs(p[pos:pos + win]), n=win))
709
+ am_o = float(np.mean(ef_o[25:36])); am_p = float(np.mean(ef_p[25:36]))
710
+ if am_o > 1e-8:
711
+ am_ratios.append(am_p / am_o)
712
+ if am_ratios:
713
+ r = float(np.median(am_ratios))
714
+ chk('G4-Ra-trill', r >= 0.70,
715
+ f'AM ratio={r:.2f} (median over {len(am_ratios)} windows) '
716
+ f'{"✓" if r>=0.70 else "⚠ ر trill may be smeared"}')
717
+
718
+ # G5 Safir 5.5-12 kHz — ص س ز (§152.3)
719
+ so = _band_energy(o, 5500, 12000); sp = _band_energy(p, 5500, 12000)
720
+ if so > 1e-15:
721
+ d = 10 * np.log10(sp / so + 1e-20)
722
+ chk('G5-Safir', d >= -5.0,
723
+ f'{d:+.1f}dB {"✓" if d>=-5 else "⚠ ص س ز may be dull"}')
724
+
725
+ # G6 Tafasshi 3-8 kHz — ش (§152.3)
726
+ to = _band_energy(o, 3000, 8000); tp = _band_energy(p, 3000, 8000)
727
+ if to > 1e-15:
728
+ d = 10 * np.log10(tp / to + 1e-20)
729
+ chk('G6-Tafasshi', d >= -4.0,
730
+ f'{d:+.1f}dB {"✓" if d>=-4 else "⚠ ش may lose spread"}')
731
+
732
+ # G7 Izhar silence count (§52.5.1)
733
+ def _sil_count(s):
734
+ fn_ = int(0.010 * SR)
735
+ db = np.array([float(20 * np.log10(np.sqrt(np.mean(s[i:i + fn_] ** 2)) + 1e-10))
736
+ for i in range(0, len(s) - fn_, fn_)])
737
+ med = float(np.median(db)); c = 0; in_s = False; sl = 0
738
+ for v in db:
739
+ if v < med - 18: in_s = True; sl += 1
740
+ else:
741
+ if in_s and 2 <= sl <= 8: c += 1
742
+ in_s = False; sl = 0
743
+ return c
744
+ sc_o = _sil_count(o); sc_p = _sil_count(p)
745
+ if sc_o > 0:
746
+ r = sc_p / sc_o
747
+ chk('G7-Izhar', r >= 0.70,
748
+ f'silences {sc_o}→{sc_p} ({r:.0%}) {"✓" if r>=0.70 else "⚠ words may run together"}')
749
+
750
+ total_g = len(st.guard_pass) + len(st.guard_warn)
751
+ if st.guard_warn:
752
+ _L(st, f' [S7] {len(st.guard_warn)}/{total_g} warnings — check output for Tajweed artifacts')
753
+ else:
754
+ _L(st, f' [S7] All {total_g} guards passed ✓')
755
+ return proc_wav
756
+
757
+
758
+ # ─── Main ─────────────────────────────────────────────────────────────────────
759
+ def process(input_path, output_path, source_tier='TIER_UNKNOWN',
760
+ mujawwad_conf=0.0, force_rt60=0.0, verbose=True):
761
+ """
762
+ الصفاء v3 — main entry point.
763
+ Returns SafaaState with full diagnostics.
764
+ All temp files are tracked and cleaned up on exit. [I8]
765
+ """
766
+ st = SafaaState(input_path=input_path, source_tier=source_tier,
767
+ mujawwad_conf=mujawwad_conf)
768
+ _L(st, f'\n{"═"*60}')
769
+ _L(st, f' الصفاء {__version__} — إزالة الصدى والريفيرب')
770
+ _L(st, f' Input : {Path(input_path).name}')
771
+ _L(st, f' Tier : {source_tier} Mujawwad: {mujawwad_conf:.2f}')
772
+ _L(st, f'{"═"*60}')
773
+
774
+ s_orig = _decode(input_path)
775
+ if s_orig is None:
776
+ _L(st, ' ERROR: decode failed'); return st
777
+
778
+ try:
779
+ cur = input_path
780
+
781
+ # S1: RT60 + DRR
782
+ rt60 = force_rt60 if force_rt60 > 0 else _rt60(s_orig)
783
+ st.rt60_initial = rt60; st.drr_before = _drr(s_orig)
784
+ _L(st, f' [S1] RT60={rt60:.2f}s DRR={st.drr_before:.1f}dB')
785
+
786
+ if rt60 < RT60_MIN:
787
+ _L(st, f' [S1] RT60 < {RT60_MIN}s — no processing needed')
788
+ _enc_stereo(input_path, output_path); return st
789
+
790
+ # S2 Sub-band LF EQ
791
+ cur = _lf_eq(cur, s_orig, rt60, st)
792
+ # S3 WPE
793
+ cur = _wpe(cur, rt60, st)
794
+ # S4 DF3 (parallel)
795
+ _s4 = _decode(cur); s4 = _s4 if _s4 is not None else s_orig
796
+ cur = _df3(cur, s4, rt60, st)
797
+ # S5 JALAA (DRR-weighted, corrected late window)
798
+ _s5 = _decode(cur); s5 = _s5 if _s5 is not None else s_orig
799
+ cur = _jalaa(cur, s5, rt60, st.drr_before, st)
800
+ # S6 Tail NR (JALAA-aware)
801
+ _s6 = _decode(cur); s6 = _s6 if _s6 is not None else s_orig
802
+ cur = _tailnr(cur, s6, rt60, st)
803
+ # S7 Arabic guards
804
+ cur = _arabic_guards(s_orig, cur, st)
805
+
806
+ # Final stereo encode [I2]
807
+ rc, _, err = _run(['ffmpeg', '-y', '-i', cur,
808
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '2',
809
+ '-loglevel', 'error', output_path])
810
+ if rc:
811
+ _L(st, f' ERROR: final encode: {err[:80]}'); return st
812
+
813
+ sf_ = _decode(output_path)
814
+ if sf_ is not None: st.drr_after = _drr(sf_)
815
+
816
+ _L(st, f'\n{"═"*60}')
817
+ _L(st, f' الصفاء {__version__} ✓')
818
+ _L(st, f' RT60 : {st.rt60_initial:.2f}s')
819
+ _L(st, f' DRR : {st.drr_before:.1f} → {st.drr_after:.1f} dB '
820
+ f'(Δ{st.drr_after - st.drr_before:+.1f})')
821
+ _L(st, f' LF={st.lf_eq} WPE={st.wpe} DF3={st.df3} JALAA={st.jalaa} tailNR={st.tail_nr}')
822
+ _L(st, f' Reverts:{st.guard_reverts} Warns:{len(st.guard_warn)}/{len(st.guard_pass)+len(st.guard_warn)}')
823
+ _L(st, f'{"═"*60}\n')
824
+ return st
825
+
826
+ finally:
827
+ _cleanup_all(st) # [I8] always runs
828
+
829
+
830
+ # ─── CLI ──────────────────────────────────────────────────────────────────────
831
+ if __name__ == '__main__':
832
+ import argparse, json
833
+ ap = argparse.ArgumentParser(description=f'الصفاء {__version__} — Dereverberation Engine')
834
+ # S156-B1: use -i/-o flags to match server calling convention
835
+ # server: python3 engine_safaa_v3_fixed.py -i input -o output --iterations 3 --ref ref
836
+ ap.add_argument('-i', '--input', required=True, dest='input')
837
+ ap.add_argument('-o', '--output', required=True, dest='output')
838
+ ap.add_argument('--iterations', type=int, default=3) # accepted, ignored (pipeline is fixed)
839
+ ap.add_argument('--ref', action='append', default=[], metavar='REF') # accepted, ignored
840
+ ap.add_argument('--tier', default='TIER_UNKNOWN')
841
+ ap.add_argument('--mujawwad', type=float, default=0.0)
842
+ ap.add_argument('--rt60', type=float, default=0.0, help='Force RT60 (0=auto)')
843
+ a = ap.parse_args()
844
+ st = process(a.input, a.output,
845
+ source_tier=a.tier, mujawwad_conf=a.mujawwad, force_rt60=a.rt60)
846
+ print('\n[REPORT]')
847
+ print(json.dumps({
848
+ 'version': __version__,
849
+ 'rt60': st.rt60_initial,
850
+ 'drr_before': round(st.drr_before, 2),
851
+ 'drr_after': round(st.drr_after, 2),
852
+ 'drr_gain': round(st.drr_after - st.drr_before, 2),
853
+ 'stages': {
854
+ 'lf_eq': st.lf_eq,
855
+ 'wpe': st.wpe,
856
+ 'df3': st.df3,
857
+ 'jalaa': st.jalaa,
858
+ 'tail_nr': st.tail_nr,
859
+ },
860
+ 'guard_reverts': st.guard_reverts,
861
+ 'guard_pass': st.guard_pass,
862
+ 'guard_warn': st.guard_warn,
863
+ }, indent=2, ensure_ascii=False))
engine_tajalli_v1.py DELETED
@@ -1,947 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- ╔══════════════════════════════════════════════════════════════════════════════╗
5
- ║ التجلي — محسن التلاوات Unified Processing Engine v1.0 ║
6
- ╠══════════════════════════════════════════════════════════════════════════════╣
7
- ║ ║
8
- ║ The name: التجلي (At-Tajalli) = The Manifestation / The Radiance. ║
9
- ║ In Islamic tradition, tajalli refers to the divine light becoming ║
10
- ║ perceptible — apt for an engine that reveals the true beauty of ║
11
- ║ Quranic recitation hidden beneath noise, compression, and time. ║
12
- ║ ║
13
- ║ Architecture: ║
14
- ║ ║
15
- ║ [T-0] Unified Tier Classifier ║
16
- ║ │ ║
17
- ║ ├─ PRISTINE / PRISTINE_NOISY / COMPRESSED ──► [E2] الإتقان v2 ║
18
- ║ │ Fingerprint perfection ║
19
- ║ │ ║
20
- ║ └─ DAMAGED / CRITICAL ────────────────────────► [E1] الاسترداد v12 ║
21
- ║ Damage recovery ║
22
- ║ (نقاء pre-pass for ║
23
- ║ MOSQUE/CASSETTE) ║
24
- ║ ║
25
- ║ Both paths → [B4] البيان v2 Voice quality diagnosis + correction ║
26
- ║ → [B5] النور v5 Harmonic gate + enrichment ║
27
- ║ → [T-6] Sidrah Score synthesis ║
28
- ║ → [T-7] Final encode TPDF + SHA-256 provenance ║
29
- ║ ║
30
- ║ Engine routing table: ║
31
- ║ TIER_PRISTINE → الإتقان (fingerprint match, صدي, LUFS) ║
32
- ║ TIER_PRISTINE_NOISY → الإتقان (+ DF3 if SNR 10-18dB & ≥256kbps) ║
33
- ║ TIER_COMPRESSED → الإتقان (codec-aware EQ, conservative NR) ║
34
- ║ TIER_DAMAGED → الاسترداد (TYPE_A/B/C, wow/flutter, BWE) ║
35
- ║ TIER_CRITICAL → الاسترداد (maximum recovery, low ceiling) ║
36
- ║ ║
37
- ║ Specialist modules (loaded on-demand): ║
38
- ║ true_engine_itiqan_v2_fixed.py — الإتقان v2 ║
39
- ║ engine_isteidad_v12.py — الاسترداد v12 ║
40
- ║ naqaa_v1_tested.py — النقاء v1 (mosque/cassette pre-pass) ║
41
- ║ bayan_ve_v2fix.py — البيان v2 (voice quality) ║
42
- ║ noor_v5.py — النور v5 (harmonic enrichment) ║
43
- ║ ihyaa_ve.py — الإحياء (already wired in isteidad) ║
44
- ║ ║
45
- ║ v6 baseline improvement summary: ║
46
- ║ • Tier awareness: none → 5 routing tiers ║
47
- ║ • NR: spectral subtraction → Wiener + DF3 (SNR-gated) ║
48
- ║ • Tajweed guards: 0 → 6 (Qalqalah, Ghunnah, Emphatic, Alif, Hams, Sib) ║
49
- ║ • Recovery paths: 1 (declip) → 7 (TYPE_A/B/C + R-1/R-2/R-3 + mosque) ║
50
- ║ • BWE: none → LR4 + PCHIP F0-tracked harmonic synthesis ║
51
- ║ • Scoring: RMS distance → Sidrah (maqam + cadence + TRSB) ║
52
- ║ • Provenance: none → SHA-256 input/output hash ║
53
- ╚══════════════════════════════════════════════════════════════════════════════╝
54
-
55
- Usage (CLI):
56
- python engine_tajalli_v1.py -i input.mp3 -o output.wav --ref ref_1425.mp3
57
-
58
- Usage (API):
59
- from engine_tajalli_v1 import process
60
- result = process(input_path, output_path, ref_files=[ref_mp3])
61
- """
62
-
63
- from __future__ import annotations
64
-
65
- import argparse
66
- import hashlib
67
- import json
68
- import os
69
- import subprocess
70
- import sys
71
- import tempfile
72
- import time
73
- import shutil
74
- import warnings
75
- from dataclasses import dataclass, field
76
- from pathlib import Path
77
- from typing import Dict, List, Optional, Tuple
78
-
79
- warnings.filterwarnings('ignore')
80
-
81
- # ── Version ──────────────────────────────────────────────────────────────────
82
- TAJALLI_VERSION = 'v1.0.0-التجلي'
83
- TARGET_LUFS = -6.29
84
- TARGET_CREST = 10.25
85
- TARGET_LRA = 4.19
86
-
87
- # ── Tier constants ────────────────────────────────────────────────────────────
88
- TIER_PRISTINE = 'TIER_PRISTINE'
89
- TIER_PRISTINE_NOISY = 'TIER_PRISTINE_NOISY'
90
- TIER_COMPRESSED = 'TIER_COMPRESSED'
91
- TIER_DAMAGED = 'TIER_DAMAGED'
92
- TIER_CRITICAL = 'TIER_CRITICAL'
93
-
94
- ITIQAN_TIERS = {TIER_PRISTINE, TIER_PRISTINE_NOISY, TIER_COMPRESSED}
95
- ISTEIDAD_TIERS = {TIER_DAMAGED, TIER_CRITICAL}
96
-
97
- # ── Module discovery ──────────────────────────────────────────────────────────
98
- _HERE = Path(__file__).parent
99
-
100
- def _find_module(name: str, candidates: List[str]) -> Optional[Path]:
101
- """Find a module file by trying candidate paths in order."""
102
- for c in candidates:
103
- p = Path(c)
104
- if p.exists():
105
- return p
106
- p2 = _HERE / c
107
- if p2.exists():
108
- return p2
109
- return None
110
-
111
- _ITIQAN_PATH = _find_module('itiqan', [
112
- 'true_engine_itiqan_v2_fixed.py',
113
- 'true_engine_itiqan_v2.py',
114
- 'true_engine_itiqan_v2_patched.py',
115
- ])
116
- _ISTEIDAD_PATH = _find_module('isteidad', [
117
- 'engine_isteidad_v12.py',
118
- 'engine_isteidad_v11.py',
119
- ])
120
- _NAQAA_PATH = _find_module('naqaa', ['naqaa_v1_tested.py', 'naqaa_v1.py'])
121
- _BAYAN_PATH = _find_module('bayan', ['bayan_ve_v2fix.py', 'bayan_ve.py'])
122
- _NOOR_PATH = _find_module('noor', ['noor_v5.py', 'noor_v4.py'])
123
-
124
- # ── Numpy / scipy guard ───────────────────────────────────────────────────────
125
- try:
126
- import numpy as np
127
- NUMPY_OK = True
128
- except ImportError:
129
- NUMPY_OK = False
130
- np = None # type: ignore
131
-
132
- # ══════════════════════════════════════════════════════════════════════════════
133
- # RESULT PACKET
134
- # ══════════════════════════════════════════════════════════════════════════════
135
- @dataclass
136
- class TajalliResult:
137
- """Unified output packet from التجلي pipeline."""
138
- # Routing
139
- tier: str = ''
140
- engine_used: str = ''
141
- naqaa_pre_pass: bool = False
142
-
143
- # Scores
144
- score_before: float = 0.0
145
- score_after: float = 0.0
146
- score_ceiling: float = 100.0
147
-
148
- # Loudness
149
- lufs_in: float = 0.0
150
- lufs_out: float = 0.0
151
- lra_in: float = 0.0
152
- lra_out: float = 0.0
153
- crest_in: float = 0.0
154
- crest_out: float = 0.0
155
-
156
- # Post-processing
157
- bayan_applied: bool = False
158
- noor_applied: bool = False
159
-
160
- # Provenance
161
- input_sha256: str = ''
162
- output_sha256: str = ''
163
- engine_version: str = TAJALLI_VERSION
164
- elapsed_s: float = 0.0
165
-
166
- # Raw sub-engine result (for diagnostics)
167
- sub_result: dict = field(default_factory=dict)
168
- log: List[str] = field(default_factory=list)
169
-
170
-
171
- # ══════════════════════════════════════════════════════════════════════════════
172
- # UTILITY
173
- # ══════════════════════════════════════════════════════════════════════════════
174
- def _sha256(path: str) -> str:
175
- h = hashlib.sha256()
176
- try:
177
- with open(path, 'rb') as f:
178
- for chunk in iter(lambda: f.read(65536), b''):
179
- h.update(chunk)
180
- return h.hexdigest()[:16]
181
- except Exception:
182
- return ''
183
-
184
-
185
- def _ffprobe(path: str) -> Tuple[float, int]:
186
- """Returns (duration_s, bitrate_kbps)."""
187
- try:
188
- r = subprocess.run(
189
- ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration,bit_rate',
190
- '-of', 'csv=p=0', path],
191
- capture_output=True, text=True, timeout=15
192
- )
193
- vals = r.stdout.strip().split(',')
194
- dur = float(vals[0]) if vals else 0.0
195
- br = int(vals[1]) // 1000 if len(vals) > 1 else 0
196
- return dur, br
197
- except Exception:
198
- return 0.0, 0
199
-
200
-
201
- def _decode_to_wav(input_path: str, tmp_dir: str) -> Optional[str]:
202
- """Decode any audio to pcm_s16le 48kHz mono WAV for analysis."""
203
- out = os.path.join(tmp_dir, 'tajalli_analysis.wav')
204
- r = subprocess.run(
205
- ['ffmpeg', '-y', '-i', input_path,
206
- '-acodec', 'pcm_s16le', '-ar', '48000', '-ac', '1', out],
207
- capture_output=True, timeout=120
208
- )
209
- return out if r.returncode == 0 and os.path.exists(out) else None
210
-
211
-
212
- def _load_wav_samples(wav_path: str):
213
- """Load WAV as float32 numpy array. Returns None if numpy unavailable."""
214
- if not NUMPY_OK:
215
- return None
216
- try:
217
- import wave as _wave
218
- with _wave.open(wav_path, 'rb') as wf:
219
- raw = wf.readframes(wf.getnframes())
220
- return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
221
- except Exception:
222
- return None
223
-
224
-
225
- def _measure_lufs(wav_path: str) -> Tuple[float, float]:
226
- """Measure integrated LUFS and LRA via ffmpeg ebur128."""
227
- try:
228
- r = subprocess.run(
229
- ['ffmpeg', '-i', wav_path, '-af', 'ebur128=peak=true', '-f', 'null', '-'],
230
- capture_output=True, text=True, timeout=60
231
- )
232
- txt = r.stderr
233
- lufs = lra = 0.0
234
- for line in txt.splitlines():
235
- if 'I:' in line and 'LUFS' in line:
236
- try:
237
- lufs = float(line.split('I:')[1].split('LUFS')[0].strip())
238
- except Exception:
239
- pass
240
- if 'LRA:' in line and 'LU' in line:
241
- try:
242
- lra = float(line.split('LRA:')[1].split('LU')[0].strip())
243
- except Exception:
244
- pass
245
- return lufs, lra
246
- except Exception:
247
- return 0.0, 0.0
248
-
249
-
250
- # ══════════════════════════════════════════════════════════════════════════════
251
- # TIER CLASSIFIER (extracted + calibrated from الإتقان v2)
252
- # ══════════════════════════════════════════════════════════════════════════════
253
- def _classify_background_noise(samples, sr: int = 48000):
254
- """
255
- Returns (noise_floor_db, snr_proxy_db, is_noisy).
256
-
257
- Three-guard clean detection — calibrated on 7 real recordings:
258
- Guard A: True phrase silence (yt5s: SFM≈0, p5 < -36dBFS)
259
- Guard B: Room character (الذاريات: bg_slope < -1.6, snr > 9dB)
260
- Guard C: SNR threshold (< 16dB = genuinely noisy)
261
-
262
- All three guards must fail for is_noisy=True.
263
- Known results:
264
- الأحزاب (noisy): slope=-1.22, snr=12.6 → NOISY ✓
265
- الأعراف (noisy): slope=-2.36, snr=8.8 → NOISY ✓ (snr<9 override)
266
- الذاريات (room air): slope=-1.90, snr=14.8 → CLEAN ✓
267
- يا أيها (clean): slope=-2.09, snr=9.4 → CLEAN ✓
268
- yt5s (silence): SFM=0.0005, p5=-39.0 → CLEAN ✓
269
- """
270
- if not NUMPY_OK or len(samples) < sr:
271
- return -60.0, 25.0, False
272
-
273
- SR = sr
274
- frame_n = int(0.02 * SR)
275
- frames_db = [
276
- float(20.0 * np.log10(float(np.sqrt(np.mean(samples[i:i+frame_n]**2))) + 1e-10))
277
- for i in range(0, len(samples) - frame_n, frame_n)
278
- ]
279
- if len(frames_db) < 20:
280
- return -60.0, 25.0, False
281
-
282
- arr = np.array(frames_db)
283
- p5 = float(np.percentile(arr, 5))
284
- p10 = float(np.percentile(arr, 10))
285
- p20 = float(np.percentile(arr, 20))
286
- p75 = float(np.percentile(arr, 75))
287
- snr_proxy = p75 - p10
288
-
289
- noise_idxs = [i for i, r in enumerate(frames_db) if p5 < r < p20][:80]
290
- if not noise_idxs:
291
- return p10, snr_proxy, snr_proxy < 18.0
292
-
293
- bg = np.concatenate([samples[i*frame_n:(i+1)*frame_n] for i in noise_idxs])
294
- bg_rms = float(20.0 * np.log10(np.sqrt(np.mean(bg**2)) + 1e-10))
295
-
296
- # Has_noise: LF or mid band energy in background
297
- N = min(len(bg), 4096)
298
- bg_spec = np.abs(np.fft.rfft(bg[:N], n=N))**2
299
- bg_freqs = np.fft.rfftfreq(N, 1.0/SR)
300
- lf_mask = (bg_freqs >= 100) & (bg_freqs < 500)
301
- mid_mask = (bg_freqs >= 500) & (bg_freqs < 2000)
302
- has_noise = (
303
- (lf_mask.any() and float(np.mean(bg_spec[lf_mask])) > 1e-8) or
304
- (mid_mask.any() and float(np.mean(bg_spec[mid_mask])) > 1e-8)
305
- )
306
-
307
- # Full-resolution spectral analysis for guards
308
- N_full = min(len(bg), 8192)
309
- bg_full = np.abs(np.fft.rfft(bg[:N_full], n=N_full))**2 + 1e-10
310
- bg_fq = np.fft.rfftfreq(N_full, 1.0/SR)
311
-
312
- # Guard A: near-digital silence (phrase gaps, not noise floor)
313
- sfm = float(np.exp(np.mean(np.log(bg_full))) / np.mean(bg_full))
314
- p5_depth = float(np.percentile(arr, 5))
315
- is_true_silence = sfm < 0.003 and p5_depth < -36.0
316
-
317
- # Guard B: tonal/room-character background (صدي التمييز, acoustic air)
318
- slope_band = (bg_fq >= 200) & (bg_fq < 4000)
319
- if slope_band.sum() > 4:
320
- _lf = np.log10(bg_fq[slope_band] + 1)
321
- _lp = np.log10(bg_full[slope_band])
322
- bg_slope = float(np.polyfit(_lf, _lp, 1)[0])
323
- else:
324
- bg_slope = -2.0
325
- is_room_character = bg_slope < -1.6 and snr_proxy > 9.0
326
-
327
- is_noisy = (
328
- snr_proxy < 16.0
329
- and has_noise
330
- and bg_rms > -40.0
331
- and not is_true_silence
332
- and not is_room_character
333
- )
334
- return bg_rms, snr_proxy, is_noisy
335
-
336
-
337
- def classify_tier(wav_path: str, samples, bitrate_kbps: int,
338
- duration_s: float) -> Tuple[str, dict]:
339
- """
340
- Unified tier classifier for التجلي.
341
-
342
- Returns (tier_string, info_dict).
343
-
344
- Routing:
345
- bitrate ≥ 192 kbps + good quality → TIER_PRISTINE / TIER_PRISTINE_NOISY
346
- bitrate 64–191 kbps → TIER_COMPRESSED
347
- bitrate < 64 kbps OR THD/damage → TIER_DAMAGED
348
- unrecoverable → TIER_CRITICAL
349
- """
350
- info = {
351
- 'bitrate_kbps': bitrate_kbps,
352
- 'duration_s': duration_s,
353
- 'noise_floor': -60.0,
354
- 'snr_proxy': 25.0,
355
- 'is_noisy': False,
356
- 'lufs': 0.0,
357
- 'lra': 0.0,
358
- }
359
-
360
- # Measure LUFS/LRA
361
- try:
362
- lufs, lra = _measure_lufs(wav_path)
363
- info['lufs'] = lufs
364
- info['lra'] = lra
365
- except Exception:
366
- pass
367
-
368
- # Measure noise profile
369
- if NUMPY_OK and samples is not None:
370
- noise_floor, snr_proxy, is_noisy = _classify_background_noise(samples)
371
- info['noise_floor'] = noise_floor
372
- info['snr_proxy'] = snr_proxy
373
- info['is_noisy'] = is_noisy
374
- else:
375
- is_noisy = False
376
-
377
- # TIER_CRITICAL: completely unprocessable
378
- # Heuristic: LUFS > -3 dBFS (digital overload) or duration < 5s
379
- if duration_s < 5.0 or (info['lufs'] > -3.0 and info['lufs'] != 0.0):
380
- return TIER_CRITICAL, info
381
-
382
- # TIER_DAMAGED: low bitrate or bitrate-class indicates severe compression
383
- # الاسترداد handles 8–63 kbps (cassette, telephone, corrupted internet)
384
- if bitrate_kbps > 0 and bitrate_kbps < 64:
385
- return TIER_DAMAGED, info
386
-
387
- # TIER_COMPRESSED: 64–191 kbps — الإتقان handles with reduced EQ confidence
388
- if 64 <= bitrate_kbps < 192:
389
- tier = TIER_COMPRESSED
390
- # Even within COMPRESSED, very low bitrate (64-95) with severe damage
391
- # should route to الاسترداد. Proxy: LRA < 1.0 + lufs deviation > 12 dB
392
- if bitrate_kbps < 96 and info['lra'] < 1.0 and abs(info['lufs'] - TARGET_LUFS) > 12:
393
- return TIER_DAMAGED, info
394
- return tier, info
395
-
396
- # ≥ 192 kbps: PRISTINE family
397
- if is_noisy:
398
- return TIER_PRISTINE_NOISY, info
399
- return TIER_PRISTINE, info
400
-
401
-
402
- # ══════════════════════════════════════════════════════════════════════════════
403
- # MODULE LOADERS (lazy import — only load what the routing requires)
404
- # ══════════════════════════════════════════════════════════════════════════════
405
- def _load_itiqan():
406
- """Dynamically import الإتقان v2. Returns module or None."""
407
- if _ITIQAN_PATH is None:
408
- return None
409
- abs_path = str(_ITIQAN_PATH.resolve())
410
- mod_name = f'_tajalli_itiqan_{id(abs_path)}'
411
- try:
412
- import importlib.util, sys as _sys
413
- _sys.modules.pop(mod_name, None)
414
- spec = importlib.util.spec_from_file_location(mod_name, abs_path)
415
- mod = importlib.util.module_from_spec(spec)
416
- _sys.modules[mod_name] = mod
417
- spec.loader.exec_module(mod)
418
- return mod
419
- except Exception as e:
420
- print(f' [التجلي] الإتقان load error: {e}')
421
- return None
422
-
423
-
424
- def _load_isteidad():
425
- """Dynamically import الاسترداد v12. Returns module or None."""
426
- if _ISTEIDAD_PATH is None:
427
- return None
428
- abs_path = str(_ISTEIDAD_PATH.resolve())
429
- mod_name = f'_tajalli_isteidad_{id(abs_path)}'
430
- try:
431
- import importlib.util, sys as _sys
432
- _sys.modules.pop(mod_name, None)
433
- spec = importlib.util.spec_from_file_location(mod_name, abs_path)
434
- mod = importlib.util.module_from_spec(spec)
435
- _sys.modules[mod_name] = mod
436
- spec.loader.exec_module(mod)
437
- return mod
438
- except Exception as e:
439
- print(f' [التجلي] الاسترداد load error: {e}')
440
- return None
441
-
442
-
443
- def _load_naqaa():
444
- if _NAQAA_PATH is None:
445
- return None
446
- try:
447
- import importlib.util
448
- spec = importlib.util.spec_from_file_location(f'_tajalli_naqaa_{id(abs_path)}', str(_NAQAA_PATH.resolve()))
449
- mod = importlib.util.module_from_spec(spec)
450
- spec.loader.exec_module(mod)
451
- return mod
452
- except Exception:
453
- return None
454
-
455
-
456
- def _load_bayan():
457
- if _BAYAN_PATH is None:
458
- return None
459
- try:
460
- import importlib.util
461
- spec = importlib.util.spec_from_file_location('_bayan', str(_BAYAN_PATH.resolve()))
462
- mod = importlib.util.module_from_spec(spec)
463
- spec.loader.exec_module(mod)
464
- return mod
465
- except Exception:
466
- return None
467
-
468
-
469
- def _load_noor():
470
- if _NOOR_PATH is None:
471
- return None
472
- try:
473
- import importlib.util
474
- spec = importlib.util.spec_from_file_location('_noor', str(_NOOR_PATH))
475
- mod = importlib.util.module_from_spec(spec)
476
- spec.loader.exec_module(mod)
477
- return mod
478
- except Exception:
479
- return None
480
-
481
-
482
- # ══════════════════════════════════════════════════════════════════════════════
483
- # POST-PROCESSING (البيان + النور — applied after both paths)
484
- # ══════════════════════════════════════════════════════════════════════════════
485
- def _run_bayan(wav_path: str, tier: str, codec_cutoff: float,
486
- ref_third_oct: dict, log) -> str:
487
- """
488
- Apply البيان v2 voice quality correction.
489
- Returns output wav path (same as input on failure/skip).
490
- """
491
- bayan = _load_bayan()
492
- if bayan is None:
493
- log(' [البيان] module not found — skipping')
494
- return wav_path
495
-
496
- # Build minimal state and ref objects compatible with apply_bayan_to_engine
497
- class _State:
498
- pass
499
- class _Ref:
500
- pass
501
-
502
- state = _State()
503
- state.source_tier = tier
504
- state.skip_s = 15.0
505
- state.dur_s = 45.0
506
- state.codec_cutoff = codec_cutoff
507
-
508
- ref = _Ref()
509
- ref.third_oct = ref_third_oct
510
-
511
- try:
512
- out_wav, bayan_result = bayan.apply_bayan_to_engine(
513
- wav_path, state, ref, log_fn=log
514
- )
515
- if bayan_result.status == 'OK' and out_wav != wav_path:
516
- log(f' [البيان] ✓ VQS {bayan_result.vqs_before:.1f}→{bayan_result.vqs_after:.1f}')
517
- return out_wav
518
- log(f' [البيان] {bayan_result.status}: {bayan_result.reason}')
519
- return wav_path
520
- except Exception as e:
521
- log(f' [البيان] failed: {e}')
522
- return wav_path
523
-
524
-
525
- def _run_noor(wav_path: str, output_path: str, ref_path: Optional[str],
526
- log) -> str:
527
- """
528
- Apply النور v5 harmonic gate + enrichment.
529
- Returns output path (same as wav_path on failure).
530
- """
531
- noor = _load_noor()
532
- if noor is None:
533
- log(' [النور] module not found — skipping')
534
- return wav_path
535
-
536
- tmp_out = wav_path.replace('.wav', '_noor.wav')
537
- try:
538
- noor.run(wav_path, tmp_out, ref=ref_path)
539
- if os.path.exists(tmp_out):
540
- log(' [النور] ✓ harmonic enrichment applied')
541
- return tmp_out
542
- return wav_path
543
- except Exception as e:
544
- log(f' [النور] failed: {e}')
545
- return wav_path
546
-
547
-
548
- def _build_ref_third_oct(ref_files: List[str]) -> dict:
549
- """
550
- Build reference third-octave spectrum from ref files for البيان.
551
- Returns empty dict if bayan unavailable.
552
- """
553
- bayan = _load_bayan()
554
- if bayan is None or not ref_files:
555
- return {}
556
- try:
557
- # البيان exposes _compute_third_oct internally; we use its band helper
558
- spectra = []
559
- for rf in ref_files:
560
- if not os.path.exists(rf):
561
- continue
562
- tmp = tempfile.mktemp(suffix='.wav')
563
- subprocess.run(
564
- ['ffmpeg', '-y', '-i', rf, '-acodec', 'pcm_s16le',
565
- '-ar', '48000', '-ac', '1', tmp],
566
- capture_output=True, timeout=60
567
- )
568
- if not os.path.exists(tmp):
569
- continue
570
- try:
571
- audio = bayan._load_mono(tmp, skip_s=10, dur_s=40)
572
- if len(audio) > 48000 * 5:
573
- spectra.append(bayan.bands(audio))
574
- except Exception:
575
- pass
576
- finally:
577
- if os.path.exists(tmp):
578
- os.unlink(tmp)
579
-
580
- if not spectra:
581
- return {}
582
- # Average across reference files
583
- keys = set()
584
- for s in spectra:
585
- keys.update(s.keys())
586
- averaged = {}
587
- for k in keys:
588
- vals = [s[k] for s in spectra if k in s]
589
- averaged[k] = float(sum(vals) / len(vals))
590
- return averaged
591
- except Exception:
592
- return {}
593
-
594
-
595
- # ══════════════════════════════════════════════════════════════════════════════
596
- # MAIN PROCESS FUNCTION
597
- # ══════════════════════════════════════════════════════════════════════════════
598
- def process(
599
- input_path: str,
600
- output_path: str,
601
- ref_files: Optional[List[str]] = None,
602
- iterations: int = 3,
603
- target_score: float = 96.0,
604
- force_tier: Optional[str] = None,
605
- aggressive: bool = False,
606
- run_bayan: bool = True,
607
- run_noor: bool = True,
608
- log_fn = None,
609
- ) -> TajalliResult:
610
- """
611
- التجلي unified processing pipeline.
612
-
613
- Args:
614
- input_path: Source audio (any format ffmpeg can read)
615
- output_path: Output path (.wav or .mp3)
616
- ref_files: List of 1425H reference MP3s for fingerprint targeting
617
- iterations: Processing iterations (passed to sub-engines)
618
- target_score: Target quality score (0–100)
619
- force_tier: Override automatic tier detection
620
- aggressive: Pass aggressive flag to sub-engines
621
- run_bayan: Apply البيان v2 post-processing
622
- run_noor: Apply النور v5 harmonic enrichment
623
- log_fn: Logging function (default: print)
624
-
625
- Returns:
626
- TajalliResult with full processing report
627
- """
628
- log = log_fn or print
629
- res = TajalliResult()
630
- t0 = time.time()
631
- _LOG: List[str] = []
632
-
633
- def L(msg: str):
634
- _LOG.append(msg)
635
- log(msg)
636
-
637
- ref_files = [r for r in (ref_files or []) if os.path.exists(r)]
638
-
639
- L(f'\n╔{"═"*62}╗')
640
- L(f'║ التجلي {TAJALLI_VERSION} — محسن التلاوات{"":26}║')
641
- L(f'╚{"═"*62}╝')
642
- L(f' Input : {os.path.basename(input_path)}')
643
- L(f' Output : {os.path.basename(output_path)}')
644
- L(f' Refs : {len(ref_files)} file(s)')
645
-
646
- if not os.path.exists(input_path):
647
- L(f' ERROR: Input not found'); res.log = _LOG; return res
648
-
649
- # ── Provenance: hash input ────────────────────────────────────────────────
650
- res.input_sha256 = _sha256(input_path)
651
- L(f' SHA256 : {res.input_sha256}')
652
-
653
- tmp_dir = tempfile.mkdtemp(prefix='tajalli_')
654
- try:
655
- # ── T-0: Analysis + Classification ───────────────────────────────────
656
- L('\n── T-0: Analysis ─────────────────────────────────────────────')
657
- wav_analysis = _decode_to_wav(input_path, tmp_dir)
658
- samples = _load_wav_samples(wav_analysis) if wav_analysis else None
659
- duration_s, bitrate_kbps = _ffprobe(input_path)
660
- lufs_in, lra_in = _measure_lufs(wav_analysis or input_path)
661
-
662
- res.lufs_in = lufs_in
663
- res.lra_in = lra_in
664
-
665
- if force_tier:
666
- tier, tier_info = force_tier, {'bitrate_kbps': bitrate_kbps,
667
- 'duration_s': duration_s}
668
- L(f' [T-0] tier forced → {tier}')
669
- else:
670
- tier, tier_info = classify_tier(
671
- wav_analysis or input_path, samples,
672
- bitrate_kbps, duration_s
673
- )
674
-
675
- res.tier = tier
676
- L(f' [T-0] Tier : {tier}')
677
- L(f' [T-0] Bitrate : {bitrate_kbps} kbps Duration: {duration_s:.1f}s')
678
- L(f' [T-0] LUFS : {lufs_in:.2f} LRA: {lra_in:.2f}')
679
- L(f' [T-0] Noise : {tier_info.get("noise_floor", -60):.1f} dBFS '
680
- f'SNR: {tier_info.get("snr_proxy", 25):.1f} dB')
681
-
682
- # Build reference third-octave once for البيان
683
- ref_third_oct = _build_ref_third_oct(ref_files) if run_bayan else {}
684
- codec_cutoff = float(tier_info.get('codec_cutoff', 20000))
685
-
686
- # ── Routing ───────────────────────────────────────────────────────────
687
- current_path = input_path
688
-
689
- if tier in ITIQAN_TIERS:
690
- # ──────────────────────────────────────────────────────────────────
691
- # PATH A: الإتقان v2
692
- # Good / near-good recordings: fingerprint perfection
693
- # ──────────────────────────────────────────────────────────────────
694
- L(f'\n── E2: الإتقان ─────────────────────────────────────────────')
695
- itiqan = _load_itiqan()
696
- if itiqan is None:
697
- L(' [E2] الإتقان module not found — check file path')
698
- L(f' Expected: {_ITIQAN_PATH or "true_engine_itiqan_v2_fixed.py"}')
699
- res.log = _LOG; return res
700
-
701
- # Set REF_FILES on the module if it exposes it
702
- if ref_files and hasattr(itiqan, 'REF_FILES'):
703
- itiqan.REF_FILES = ref_files
704
-
705
- try:
706
- sub = itiqan.enhance(
707
- input_path = current_path,
708
- output_path = os.path.join(tmp_dir, 'itiqan_out.wav'),
709
- iterations = iterations,
710
- target_score = target_score,
711
- aggressive = aggressive,
712
- )
713
- itiqan_out = os.path.join(tmp_dir, 'itiqan_out.wav')
714
- if os.path.exists(itiqan_out):
715
- current_path = itiqan_out
716
- res.engine_used = f'الإتقان ({_ITIQAN_PATH.name})'
717
- res.score_before = float(sub.get('score_before', 0))
718
- res.score_after = float(sub.get('score', 0))
719
- res.score_ceiling = float(sub.get('ceiling', 100))
720
- res.lufs_out = float(sub.get('lufs', 0))
721
- res.lra_out = float(sub.get('lra', 0))
722
- res.crest_out = float(sub.get('crest', 0))
723
- res.sub_result = sub
724
- L(f' [E2] Score: {res.score_before:.1f}→{res.score_after:.1f} '
725
- f'LUFS={res.lufs_out:.2f} Crest={res.crest_out:.2f}')
726
- else:
727
- L(' [E2] output file missing after enhance()')
728
- except Exception as e:
729
- L(f' [E2] الإتقان failed: {e}')
730
- import traceback; L(traceback.format_exc())
731
-
732
- else:
733
- # ──────────────────────────────────────────────────────────────────
734
- # PATH B: الاسترداد v12
735
- # Damaged / critical: full recovery pipeline
736
- # ──────────────────────────────────────────────────────────────────
737
-
738
- # Pre-pass: النقاء for MOSQUE / CASSETTE profiles
739
- # النقاء auto-detects profile; run it first for DAMAGED files
740
- # as it handles comb filtering, azimuth, and derev before NR.
741
- naqaa = _load_naqaa()
742
- if naqaa is not None:
743
- L(f'\n── E0: النقاء pre-pass ────────────────────────────────────')
744
- try:
745
- naqaa_out = os.path.join(tmp_dir, 'naqaa_out.wav')
746
- naqaa_ref = ref_files[0] if ref_files else None
747
- naqaa_result = naqaa.restore(
748
- input_path = current_path,
749
- output_path = naqaa_out,
750
- ref_path = naqaa_ref,
751
- force_profile= None, # auto-detect MOSQUE/CODEC/CASSETTE
752
- output_br_k = 320,
753
- log_fn = L,
754
- )
755
- profile = getattr(getattr(naqaa_result, 'triage', None),
756
- 'profile', 'UNKNOWN')
757
- if profile == 'CLEAN':
758
- # النقاء decided file is already clean — skip pre-pass
759
- L(f' [E0] profile=CLEAN — النقاء pre-pass skipped')
760
- elif os.path.exists(naqaa_out):
761
- current_path = naqaa_out
762
- res.naqaa_pre_pass = True
763
- L(f' [E0] النقاء ✓ profile={profile} '
764
- f'score {naqaa_result.score_before:.1f}→{naqaa_result.score_after:.1f}')
765
- except Exception as e:
766
- L(f' [E0] النقاء failed: {e} — continuing to الاسترداد')
767
- else:
768
- L(' [E0] النقاء module not found — skipping pre-pass')
769
-
770
- L(f'\n── E1: الاسترداد ───────────────────────────────────────────')
771
- isteidad = _load_isteidad()
772
- if isteidad is None:
773
- L(' [E1] الاسترداد module not found')
774
- L(f' Expected: {_ISTEIDAD_PATH or "engine_isteidad_v12.py"}')
775
- res.log = _LOG; return res
776
-
777
- # Set ref files if module exposes REF_FILES
778
- if ref_files and hasattr(isteidad, 'REF_FILES'):
779
- isteidad.REF_FILES = ref_files
780
-
781
- try:
782
- isteidad_out = os.path.join(tmp_dir, 'isteidad_out.wav')
783
- sub = isteidad.enhance(
784
- input_path = current_path,
785
- output_path = isteidad_out,
786
- iterations = iterations,
787
- target_score = target_score,
788
- force_tier = tier,
789
- )
790
- if os.path.exists(isteidad_out):
791
- current_path = isteidad_out
792
- res.engine_used = f'الاسترداد ({_ISTEIDAD_PATH.name})'
793
- res.score_before = float(sub.get('score_before', 0))
794
- res.score_after = float(sub.get('score', 0))
795
- res.score_ceiling = float(sub.get('ceiling', 100))
796
- res.lufs_out = float(sub.get('lufs', 0))
797
- res.lra_out = float(sub.get('lra', 0))
798
- res.crest_out = float(sub.get('crest', 0))
799
- res.sub_result = sub
800
- L(f' [E1] Score: {res.score_before:.1f}→{res.score_after:.1f} '
801
- f'LUFS={res.lufs_out:.2f} Crest={res.crest_out:.2f}')
802
- else:
803
- L(' [E1] output file missing after enhance()')
804
- except Exception as e:
805
- L(f' [E1] الاسترداد failed: {e}')
806
- import traceback; L(traceback.format_exc())
807
-
808
- # ── B4: البيان v2 ─────────────────────────────────────────────────────
809
- if run_bayan and os.path.exists(current_path):
810
- L('\n── B4: البيان ──────────────────────────────────────────────')
811
- bayan_out = _run_bayan(
812
- current_path, tier, codec_cutoff, ref_third_oct, L
813
- )
814
- if bayan_out != current_path and os.path.exists(bayan_out):
815
- current_path = bayan_out
816
- res.bayan_applied = True
817
-
818
- # ── B5: النور v5 ──────────────────────────────────────────────────────
819
- if run_noor and os.path.exists(current_path):
820
- L('\n── B5: النور ───────────────────────────────────────────────')
821
- noor_out_path = os.path.join(tmp_dir, 'noor_out.wav')
822
- noor_out = _run_noor(
823
- current_path, noor_out_path,
824
- ref_files[0] if ref_files else None, L
825
- )
826
- if noor_out != current_path and os.path.exists(noor_out):
827
- current_path = noor_out
828
- res.noor_applied = True
829
-
830
- # ── T-7: Final copy to output ─────────────────────────────────────────
831
- L('\n── T-7: Output ─────────────────────────────────────────────')
832
- if os.path.exists(current_path):
833
- os.makedirs(os.path.dirname(os.path.abspath(output_path)),
834
- exist_ok=True)
835
- shutil.copy2(current_path, output_path)
836
- res.output_sha256 = _sha256(output_path)
837
- L(f' ✓ Written → {output_path}')
838
- L(f' SHA256 : {res.output_sha256}')
839
- else:
840
- L(' ✗ No output produced — pipeline failed')
841
-
842
- finally:
843
- shutil.rmtree(tmp_dir, ignore_errors=True)
844
-
845
- res.elapsed_s = time.time() - t0
846
- res.log = _LOG
847
-
848
- L(f'\n{"═"*64}')
849
- L(f' التجلي complete: {res.score_before:.1f}→{res.score_after:.1f}/100')
850
- L(f' Engine: {res.engine_used}')
851
- L(f' Tier: {res.tier} | البيان: {"✓" if res.bayan_applied else "✗"} '
852
- f'| النور: {"✓" if res.noor_applied else "✗"}')
853
- L(f' LUFS={res.lufs_out:.2f} LRA={res.lra_out:.2f} '
854
- f'Crest={res.crest_out:.2f} [{res.elapsed_s:.1f}s]')
855
- L(f'{"═"*64}')
856
-
857
- return res
858
-
859
-
860
- # ══════════════════════════════════════════════════════════════════════════════
861
- # CLI
862
- # ══════════════════════════════════════════════════════════════════════════════
863
- def main() -> int:
864
- p = argparse.ArgumentParser(
865
- description=f'التجلي {TAJALLI_VERSION} — محسن التلاوات Processing Engine',
866
- formatter_class=argparse.RawDescriptionHelpFormatter,
867
- epilog="""
868
- examples:
869
- python engine_tajalli_v1.py -i surah.mp3 -o enhanced.wav --ref ref_1425.mp3
870
- python engine_tajalli_v1.py -i damaged.mp3 -o restored.wav --ref ref_1425.mp3 --tier TIER_DAMAGED
871
- python engine_tajalli_v1.py -i clean.mp3 -o out.wav --ref ref_1425.mp3 --no-noor
872
- """
873
- )
874
- p.add_argument('-i', '--input', required=False, help='Input audio file')
875
- p.add_argument('-o', '--output', required=False, help='Output file (.wav)')
876
- p.add_argument('--ref', action='append', default=[], metavar='REF_MP3',
877
- help='Reference 1425H audio (can repeat)')
878
- p.add_argument('--iterations', type=int, default=3,
879
- help='Processing iterations (default: 3)')
880
- p.add_argument('--target', type=float, default=96.0,
881
- help='Target quality score (default: 96)')
882
- p.add_argument('--tier', default=None,
883
- choices=[TIER_PRISTINE, TIER_PRISTINE_NOISY,
884
- TIER_COMPRESSED, TIER_DAMAGED, TIER_CRITICAL],
885
- help='Force tier (bypass auto-detection)')
886
- p.add_argument('--aggressive', action='store_true',
887
- help='Aggressive mode — all processing pushed harder')
888
- p.add_argument('--no-bayan', action='store_true', help='Skip البيان v2')
889
- p.add_argument('--no-noor', action='store_true', help='Skip النور v5')
890
- p.add_argument('--json', action='store_true', help='Output JSON result')
891
- p.add_argument('--engines', action='store_true', help='Show detected engine paths')
892
-
893
- args = p.parse_args()
894
-
895
- if args.engines:
896
- print(f'التجلي {TAJALLI_VERSION}')
897
- print(f' الإتقان : {_ITIQAN_PATH or "NOT FOUND"}')
898
- print(f' الاسترداد : {_ISTEIDAD_PATH or "NOT FOUND"}')
899
- print(f' النقاء : {_NAQAA_PATH or "NOT FOUND"}')
900
- print(f' البيان : {_BAYAN_PATH or "NOT FOUND"}')
901
- print(f' النور : {_NOOR_PATH or "NOT FOUND"}')
902
- return 0
903
-
904
- if not args.input or not args.output:
905
- if not args.engines:
906
- p.error('arguments -i/--input and -o/--output are required')
907
- ref_files = [r for r in args.ref if os.path.exists(r)]
908
- if args.ref and not ref_files:
909
- print(f'WARNING: No reference files found from: {args.ref}')
910
-
911
- result = process(
912
- input_path = args.input,
913
- output_path = args.output,
914
- ref_files = ref_files,
915
- iterations = args.iterations,
916
- target_score = args.target,
917
- force_tier = args.tier,
918
- aggressive = args.aggressive,
919
- run_bayan = not args.no_bayan,
920
- run_noor = not args.no_noor,
921
- )
922
-
923
- if args.json:
924
- print(json.dumps({
925
- 'version': result.engine_version,
926
- 'tier': result.tier,
927
- 'engine_used': result.engine_used,
928
- 'score_before': result.score_before,
929
- 'score_after': result.score_after,
930
- 'score_ceiling': result.score_ceiling,
931
- 'lufs_out': result.lufs_out,
932
- 'lra_out': result.lra_out,
933
- 'crest_out': result.crest_out,
934
- 'bayan_applied': result.bayan_applied,
935
- 'noor_applied': result.noor_applied,
936
- 'naqaa_pre_pass': result.naqaa_pre_pass,
937
- 'input_sha256': result.input_sha256,
938
- 'output_sha256': result.output_sha256,
939
- 'elapsed_s': round(result.elapsed_s, 2),
940
- }, ensure_ascii=False, indent=2))
941
-
942
- success = result.score_after > 0 and os.path.exists(args.output)
943
- return 0 if success else 1
944
-
945
-
946
- if __name__ == '__main__':
947
- sys.exit(main())