Tilawa Enhancer commited on
Commit
7fba2c5
Β·
1 Parent(s): e278256

S175: safaa v4 (DF3 path fix, nara_wpe, --tier/--mujawwad, rm v3_fixed)

Browse files
app.py CHANGED
@@ -33,7 +33,7 @@ for _d in [UPLOAD_DIR, CHUNK_DIR, OUTPUT_DIR]:
33
  BASE = Path(__file__).parent
34
 
35
  ENGINE_SCRIPTS = {
36
- "v11.0": BASE / "engine_safaa_v3_fixed.py", # S149
37
  "v11.1": BASE / "engine_itiqan_v6_official.py", # S163
38
  "v11.2": BASE / "engine_isteidad_v21.py", # S162-SV-B1
39
  "v10.0": BASE / "engine_safaa_v3_fixed.py", # S149
@@ -334,7 +334,9 @@ def _run_engine(job_id):
334
  )
335
  # S174-SV-C1: safaa uses positional args (input output), not -i/-o flags
336
  if _is_safaa:
337
- cmd = ["python3", str(script), job["in_path"], _o_path]
 
 
338
  else:
339
  cmd = ["python3", str(script),
340
  "-i", job["in_path"], "-o", _o_path,
 
33
  BASE = Path(__file__).parent
34
 
35
  ENGINE_SCRIPTS = {
36
+ "v11.0": BASE / "engine_safaa_v4.py", # S175 # S149
37
  "v11.1": BASE / "engine_itiqan_v6_official.py", # S163
38
  "v11.2": BASE / "engine_isteidad_v21.py", # S162-SV-B1
39
  "v10.0": BASE / "engine_safaa_v3_fixed.py", # S149
 
334
  )
335
  # S174-SV-C1: safaa uses positional args (input output), not -i/-o flags
336
  if _is_safaa:
337
+ cmd = ["python3", str(script), job["in_path"], _o_path,
338
+ "--tier", job.get("tier", "TIER_UNKNOWN"),
339
+ "--mujawwad", str(job.get("mujawwad_conf", 0.0))] # S175
340
  else:
341
  cmd = ["python3", str(script),
342
  "-i", job["in_path"], "-o", _o_path,
engine_safaa_v3_fixed.py β†’ engine_safaa_v4.py RENAMED
@@ -39,9 +39,9 @@ KB REFS: Β§3 Β§28 Β§35 Β§36 Β§52 Β§79 Β§109 Β§138 Β§140 Β§143 Β§145 Β§151 Β§152
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
@@ -70,9 +70,12 @@ except ImportError:
70
 
71
  # DF3 binary
72
  _DF3_CLI_BIN = ''
73
- for _c in ['deep-filter', 'deepfilter', 'deep_filter',
74
- '/usr/local/bin/deep-filter', '/app/deep-filter']:
75
- if shutil.which(_c):
 
 
 
76
  _DF3_CLI_BIN = _c; break
77
  DF3_OK = bool(_DF3_CLI_BIN)
78
 
@@ -94,12 +97,12 @@ RT60_TAILNR = 0.30
94
  RT60_AGGR = 1.50
95
  MUJ_RT60_FLOOR = 1.20 # Β§145.3
96
 
97
- # Β§79 per-style DF attenuation hard limits
98
- _DF3_LIM_MURATTAL = 18 # dB maximum
99
- _DF3_LIM_MUJAWWAD = 6 # dB maximum
100
- _DF3_SPEECH = 12
101
- _DF3_TRANS = 20
102
- _DF3_TAIL = 28
103
 
104
  _CHUNK_S = 0.100
105
  _XFADE_N = 960
@@ -107,7 +110,7 @@ _XFADE_N = 960
107
  _RMS_MAX_DELTA = 1.0
108
  _LRA_MAX_DELTA = 0.5 # Β§109.4
109
 
110
- DRR_ALREADY_DRY = 6.0 # dB β€” skip JALAA if DRR already above this [I10]
111
 
112
 
113
  # ─── State ────────────────────────────────────────────────────────────────────
@@ -154,8 +157,7 @@ def _run(cmd, timeout=600):
154
  return r.returncode, r.stdout, r.stderr
155
 
156
  def _tmp(tag, st=None):
157
- # S156-B2: add nanosecond suffix β€” avoids collision between concurrent server jobs
158
- p = os.path.join(_TMP, f'safaa3_{tag}_{os.getpid()}_{time.time_ns()}.wav')
159
  if st is not None:
160
  st._tmps.append(p)
161
  return p
@@ -180,7 +182,7 @@ def _decode(path, st=None):
180
  try:
181
  if SF_OK:
182
  # ffmpeg β†’ pcm_f32le temp, then soundfile reads it natively
183
- t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}_{time.time_ns()}.wav') # S156-B2
184
  rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
185
  '-acodec', 'pcm_f32le',
186
  '-ar', str(SR), '-ac', '1',
@@ -191,7 +193,7 @@ def _decode(path, st=None):
191
  return data
192
  elif SCIPY_WAV_OK:
193
  # scipy.io.wavfile can read pcm_f32le natively
194
- t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}_{time.time_ns()}.wav') # S156-B2
195
  rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
196
  '-acodec', 'pcm_f32le',
197
  '-ar', str(SR), '-ac', '1',
@@ -204,7 +206,7 @@ def _decode(path, st=None):
204
  return data.copy()
205
  else:
206
  # Final fallback: pcm_s16le β†’ int16 β†’ float32 normalised
207
- t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}_{time.time_ns()}.wav') # S156-B2
208
  rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
209
  '-acodec', 'pcm_s16le',
210
  '-ar', str(SR), '-ac', '1',
@@ -446,115 +448,227 @@ def _wpe(wav, rt60, st):
446
  shutil.rmtree(d, ignore_errors=True)
447
 
448
 
449
- # ─── Stage 4: DF3 reverb-adapted pass (parallel) [I1/I4] ─────────────────────
450
- def _df3_run_one(args):
451
- """Worker: run DF3 at one attenuation level. Returns (name, array|None)."""
452
- name, at, cli_bin, fi, od, SR_ = args
453
- import wave as _w
454
- r = subprocess.run([cli_bin, '--atten-lim-db', str(at), '-o', od, fi],
455
- capture_output=True, timeout=600)
456
- wp = os.path.join(od, os.path.basename(fi))
457
- if r.returncode or not os.path.exists(wp):
458
- return name, None
459
- with _w.open(wp, 'rb') as f:
460
- raw = f.readframes(f.getnframes())
461
- return name, np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
  def _df3(wav, samples, rt60, st):
464
  if not DF3_OK:
465
  _L(st, ' [S4-DF3] deep-filter not found β€” skip'); return wav
466
 
467
- # Attenuation levels
468
- ta = _DF3_TAIL + (4 if rt60 > RT60_AGGR else 0)
469
- ra = _DF3_TRANS + (2 if rt60 > RT60_AGGR else 0)
470
- sa = _DF3_SPEECH
471
-
472
- # Β§79 per-style hard limits [I4]
473
- if st.mujawwad_conf > 0.6:
474
- lim = _DF3_LIM_MUJAWWAD
475
- else:
476
- lim = _DF3_LIM_MURATTAL
477
- sa = min(sa, lim)
478
- ra = min(ra, lim + 8) # transition/tail may exceed speech limit but respect style spirit
479
- ta = min(ta, lim + 16)
480
  if st.mujawwad_conf > 0.6:
481
- ta = min(ta, int(ta * 0.70)); ra = min(ra, int(ra * 0.70)); sa = min(sa, int(sa * 0.80))
 
 
 
482
 
483
- import wave as _w
484
  d = tempfile.mkdtemp(prefix='safaa3_df3_')
485
  try:
 
486
  fi = os.path.join(d, 'in.wav')
487
  rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
488
  '-acodec', 'pcm_s16le', '-ar', str(SR), '-ac', '1',
489
  '-loglevel', 'error', fi])
490
  if rc or not os.path.exists(fi):
491
  return wav
492
- with _w.open(fi, 'rb') as f: raw = f.readframes(f.getnframes())
493
- s16 = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
494
-
495
- cn = int(_CHUNK_S * SR); nc = len(s16) // cn
496
- if nc < 1:
497
  return wav
498
-
499
- rms = np.array([float(np.sqrt(np.mean(s16[i*cn:(i+1)*cn]**2)) + 1e-10) for i in range(nc)])
500
- zcr = np.array([float(np.mean(np.abs(np.diff(np.sign(s16[i*cn:(i+1)*cn]))))) for i in range(nc)])
501
- rp30 = float(np.percentile(rms, 30)); rp70 = float(np.percentile(rms, 70))
502
- zm = float(np.median(zcr))
503
- labels = np.where((rms >= rp70) & (zcr >= zm * 0.7), 0,
504
- np.where((rms <= rp30) & (zcr < zm * 0.5), 2, 1))
505
-
506
- # Parallel DF3 passes [I1]
507
- jobs = []
508
- for nm, at in [('speech', sa), ('transition', ra), ('tail', ta)]:
509
- od = os.path.join(d, nm); os.makedirs(od, exist_ok=True)
510
- jobs.append((nm, at, _DF3_CLI_BIN, fi, od, SR))
511
-
512
- pas = {}
513
- with ThreadPoolExecutor(max_workers=3) as ex:
514
- futs = {ex.submit(_df3_run_one, j): j[0] for j in jobs}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  for fut in as_completed(futs):
516
- nm, arr = fut.result()
517
- if arr is None:
518
- _L(st, f' [S4-DF3] {nm} failed β€” abort'); return wav
519
- pas[nm] = arr
520
- atten = next(j[1] for j in jobs if j[0] == nm)
521
- _L(st, f' [S4-DF3] {nm:10s} {atten:2d}dB βœ“')
522
-
523
- pa = [pas['speech'], pas['transition'], pas['tail']]
524
- ml = min(len(s16), min(len(a) for a in pa))
525
- out_s = np.empty(ml, dtype=np.float32)
526
- t = np.linspace(0, 1, _XFADE_N, dtype=np.float32)
527
- ci_ = 0.5 * (1 - np.cos(np.pi * t)).astype(np.float32); co_ = 1 - ci_
528
- pl = int(labels[0])
529
- for ci in range(nc):
530
- s = ci * cn; e = min((ci + 1) * cn, ml)
531
- if e > ml: break
532
- lb = int(labels[ci])
533
- if lb != pl and ci > 0 and s + _XFADE_N <= ml:
534
- b = min(_XFADE_N, e - s)
535
- out_s[s:s+b] = pa[pl][s:s+b] * co_[:b] + pa[lb][s:s+b] * ci_[:b]
536
- if e > s + _XFADE_N: out_s[s+_XFADE_N:e] = pa[lb][s+_XFADE_N:e]
537
- else:
538
- out_s[s:e] = pa[lb][s:e]
539
- pl = lb
540
-
541
- bm = os.path.join(d, 'blend.wav')
542
- b16 = (np.clip(out_s, -1, 1) * 32767).astype(np.int16)
543
- with _w.open(bm, 'wb') as f:
544
- f.setnchannels(1); f.setsampwidth(2); f.setframerate(SR)
545
- f.writeframes(b16.tobytes())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  out = _tmp('s4', st)
547
- if not _enc_mono(bm, out):
 
 
 
 
 
548
  return wav
549
 
550
- delta = _rmsdb(b16.astype(np.float32) / 32768.0) - _rmsdb(s16)
551
- if abs(delta) > _RMS_MAX_DELTA * 2:
552
- _cleanup(out); st.guard_reverts += 1
553
- _L(st, f' [S4-DF3] RMS Ξ”={delta:+.2f}dB β€” REVERT'); return wav
554
  st.df3 = True
555
- _L(st, f' [S4-DF3] βœ“ sa={sa} ra={ra} ta={ta} RMS Ξ”={delta:+.2f}dB '
556
- f'speech={int(np.sum(labels==0))} trans={int(np.sum(labels==1))} '
557
- f'tail={int(np.sum(labels==2))}')
558
  return out
559
  except Exception as e:
560
  _L(st, f' [S4-DF3] exception: {e}'); return wav
@@ -595,18 +709,29 @@ def _jalaa(wav, samples, rt60, drr_before, st):
595
  if not nf_vals:
596
  _L(st, ' [S5-JALAA] no reverb frames β€” skip'); return wav
597
 
598
- nf = float(np.clip(float(np.median(nf_vals)) + 3, -72, -25))
599
- nr = 2 if rt60 > RT60_AGGR else 1
600
  if st.mujawwad_conf > 0.6: nr = max(1, nr - 1)
601
 
602
- out = _tmp('s5', st)
 
603
  rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
604
- '-af', f'afftdn=nr={nr}:nf={nf:.0f}:tn=1',
605
  '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1',
606
  '-loglevel', 'error', out])
607
  if rc or not os.path.exists(out):
608
  return wav
609
 
 
 
 
 
 
 
 
 
 
 
610
  post = _decode(out)
611
  if post is not None:
612
  d = _rmsdb(post) - _rmsdb(samples)
@@ -619,35 +744,96 @@ def _jalaa(wav, samples, rt60, drr_before, st):
619
  return out
620
 
621
 
622
- # ─── Stage 6: Tail floor NR ───────────────────────────────────────────────────
623
  def _tailnr(wav, samples, rt60, st):
624
  """
 
 
 
 
625
  [I3] If JALAA already ran, reduce nr by 1 to avoid double-attenuation.
 
626
  """
627
  if rt60 < RT60_TAILNR or samples is None or not NUMPY_OK:
628
  return wav
629
  fn = int(0.200 * SR)
630
  overall = _rmsdb(samples)
631
- fdb = np.array([float(20 * np.log10(np.sqrt(np.mean(samples[i:i + fn] ** 2)) + 1e-10))
632
  for i in range(0, len(samples) - fn, fn)])
633
  quiet = fdb[fdb < overall - 10]
634
  if len(quiet) == 0:
635
  return wav
636
- nf = float(np.clip(float(np.median(quiet)) + 4, -72, -30))
637
- nr = 3 if rt60 > RT60_AGGR else 2
 
 
638
  if st.mujawwad_conf > 0.6: nr = max(1, nr - 1)
639
- if st.jalaa: nr = max(1, nr - 1) # [I3] JALAA already ran
640
- out = _tmp('s6', st)
 
 
641
  rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
642
- '-af', f'afftdn=nr={nr}:nf={nf:.0f}:tn=1',
643
- '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1',
644
- '-loglevel', 'error', out])
645
- if rc or not os.path.exists(out):
646
  return wav
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  st.tail_nr = True
648
- jalaa_note = ' (JALAA-adjusted)' if st.jalaa else ''
649
- _L(st, f' [S6-tailNR] βœ“ nr={nr}{jalaa_note} nf={nf:.0f}dB')
650
- return out
 
651
 
652
 
653
  # ─── Stage 7: Arabic phoneme guards (Β§35/Β§52/Β§143/Β§152) ──────────────────────
@@ -804,14 +990,16 @@ def process(input_path, output_path, source_tier='TIER_UNKNOWN',
804
  # S7 Arabic guards
805
  cur = _arabic_guards(s_orig, cur, st)
806
 
807
- # Final stereo encode [I2]
808
  rc, _, err = _run(['ffmpeg', '-y', '-i', cur,
809
- '-acodec', 'libmp3lame', '-ar', str(SR), '-ac', '1',
810
- '-b:a', '320k',
 
 
 
811
  '-loglevel', 'error', output_path])
812
  if rc:
813
- _L(st, f' ERROR: final encode: {err[:80]}')
814
- sys.exit(1) # S169b: non-zero rc so app.py detects failure
815
 
816
  sf_ = _decode(output_path)
817
  if sf_ is not None: st.drr_after = _drr(sf_)
@@ -834,15 +1022,11 @@ def process(input_path, output_path, source_tier='TIER_UNKNOWN',
834
  if __name__ == '__main__':
835
  import argparse, json
836
  ap = argparse.ArgumentParser(description=f'الءفاؑ {__version__} β€” Dereverberation Engine')
837
- # S156-B1: use -i/-o flags to match server calling convention
838
- # server: python3 engine_safaa_v3_fixed.py -i input -o output --iterations 3 --ref ref
839
- ap.add_argument('-i', '--input', required=True, dest='input')
840
- ap.add_argument('-o', '--output', required=True, dest='output')
841
- ap.add_argument('--iterations', type=int, default=3) # accepted, ignored (pipeline is fixed)
842
- ap.add_argument('--ref', action='append', default=[], metavar='REF') # accepted, ignored
843
- ap.add_argument('--tier', default='TIER_UNKNOWN')
844
- ap.add_argument('--mujawwad', type=float, default=0.0)
845
- ap.add_argument('--rt60', type=float, default=0.0, help='Force RT60 (0=auto)')
846
  a = ap.parse_args()
847
  st = process(a.input, a.output,
848
  source_tier=a.tier, mujawwad_conf=a.mujawwad, force_rt60=a.rt60)
 
39
  """
40
  from __future__ import annotations
41
 
42
+ __version__ = 'v4'
43
 
44
+ import os, shutil, subprocess, tempfile, warnings
45
  from concurrent.futures import ThreadPoolExecutor, as_completed
46
  from dataclasses import dataclass, field
47
  from pathlib import Path
 
70
 
71
  # DF3 binary
72
  _DF3_CLI_BIN = ''
73
+ for _c in [
74
+ str(Path(__file__).parent / 'deep-filter'), # S175: /app/deep-filter on HF βœ…
75
+ '/home/claude/deep-filter', # local dev fallback
76
+ 'deep-filter', 'deepfilter', 'deep_filter',
77
+ ]:
78
+ if Path(_c).exists() or shutil.which(_c):
79
  _DF3_CLI_BIN = _c; break
80
  DF3_OK = bool(_DF3_CLI_BIN)
81
 
 
97
  RT60_AGGR = 1.50
98
  MUJ_RT60_FLOOR = 1.20 # Β§145.3
99
 
100
+ # Β§79 per-style DF attenuation hard limits β€” v4: raised for deeper cleaning
101
+ _DF3_LIM_MURATTAL = 24 # dB maximum (was 18)
102
+ _DF3_LIM_MUJAWWAD = 8 # dB maximum (was 6)
103
+ _DF3_SPEECH = 15 # was 12
104
+ _DF3_TRANS = 24 # was 20
105
+ _DF3_TAIL = 32 # was 28
106
 
107
  _CHUNK_S = 0.100
108
  _XFADE_N = 960
 
110
  _RMS_MAX_DELTA = 1.0
111
  _LRA_MAX_DELTA = 0.5 # Β§109.4
112
 
113
+ DRR_ALREADY_DRY = 3.0 # v4: lower threshold β†’ JALAA runs on more material (was 6.0)
114
 
115
 
116
  # ─── State ────────────────────────────────────────────────────────────────────
 
157
  return r.returncode, r.stdout, r.stderr
158
 
159
  def _tmp(tag, st=None):
160
+ p = os.path.join(_TMP, f'safaa3_{tag}_{os.getpid()}.wav')
 
161
  if st is not None:
162
  st._tmps.append(p)
163
  return p
 
182
  try:
183
  if SF_OK:
184
  # ffmpeg β†’ pcm_f32le temp, then soundfile reads it natively
185
+ t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}.wav')
186
  rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
187
  '-acodec', 'pcm_f32le',
188
  '-ar', str(SR), '-ac', '1',
 
193
  return data
194
  elif SCIPY_WAV_OK:
195
  # scipy.io.wavfile can read pcm_f32le natively
196
+ t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}.wav')
197
  rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
198
  '-acodec', 'pcm_f32le',
199
  '-ar', str(SR), '-ac', '1',
 
206
  return data.copy()
207
  else:
208
  # Final fallback: pcm_s16le β†’ int16 β†’ float32 normalised
209
+ t = os.path.join(_TMP, f'safaa3_dec_{os.getpid()}.wav')
210
  rc, _, _ = _run(['ffmpeg', '-y', '-i', path,
211
  '-acodec', 'pcm_s16le',
212
  '-ar', str(SR), '-ac', '1',
 
448
  shutil.rmtree(d, ignore_errors=True)
449
 
450
 
451
+ # ─── Stage 4: DF3 smooth adaptive VAD (Β§22.5 / Β§106 / RL-01 / RL-16) ─────────
452
+ _ADF_LOUD_T = -15.0 # dBFS β€” projecting voice β†’ protect
453
+ _ADF_QUIET_T = -25.0 # dBFS β€” soft/breath β†’ clean hard
454
+ _ADF_SNR_GATE = 10.0 # v4: lower gate β†’ more chunks get cleaned (was 12)
455
+ _ADF_CHUNK_S = 0.050 # 50ms chunks β€” finer granularity than isteidad 100ms
456
+ _ADF_XFADE = int(0.120 * 48000) # v4: 120ms crossfade (was 40ms) β€” much smoother seams
457
+ # RL-16: guard bands β€” restore these from original after blend
458
+ _RL16_BANDS = [(220, 290), (950, 1100)] # Hz: Ghunnah nasal pole + formant zone
459
+
460
+ def _df3_pipe_decode(path, sr):
461
+ """Decode WAV to float32 mono via ffmpeg pipe β€” avoids wave/soundfile dep."""
462
+ r = subprocess.run(
463
+ ['ffmpeg', '-nostdin', '-y', '-hide_banner', '-loglevel', 'error',
464
+ '-i', path, '-ar', str(sr), '-ac', '1', '-f', 'f32le', '-'],
465
+ capture_output=True, timeout=120)
466
+ if r.returncode or len(r.stdout) < 4:
467
+ return None
468
+ return np.frombuffer(r.stdout, dtype=np.float32).copy()
469
+
470
+ def _stft_restore_bands(processed, original, sr, bands):
471
+ """
472
+ RL-16: after DF3 blend, restore specified Hz bands from original.
473
+ Uses STFT with 50% overlap Hann window β€” preserves phase coherence.
474
+ """
475
+ from numpy.fft import rfft, irfft
476
+ N = 2048; HOP = N // 2
477
+ freqs = np.fft.rfftfreq(N, d=1.0/sr) # Hz per bin
478
+ restore_mask = np.zeros(len(freqs), dtype=bool)
479
+ for lo, hi in bands:
480
+ restore_mask |= (freqs >= lo) & (freqs <= hi)
481
+
482
+ win = np.hanning(N).astype(np.float32)
483
+ min_n = min(len(processed), len(original))
484
+ p = processed[:min_n].astype(np.float64)
485
+ o = original[:min_n].astype(np.float64)
486
+ out = np.zeros(min_n, dtype=np.float64)
487
+ norm = np.zeros(min_n, dtype=np.float64)
488
+
489
+ for s in range(0, min_n - N, HOP):
490
+ Pf = rfft(p[s:s+N] * win)
491
+ Of = rfft(o[s:s+N] * win)
492
+ Pf[restore_mask] = Of[restore_mask] # restore original in guard bands
493
+ frame = irfft(Pf) * win
494
+ out[s:s+N] += frame; norm[s:s+N] += win**2
495
+
496
+ valid = norm > 1e-8
497
+ out[valid] /= norm[valid]
498
+ out[~valid] = p[~valid]
499
+ return out.astype(np.float32)
500
 
501
  def _df3(wav, samples, rt60, st):
502
  if not DF3_OK:
503
  _L(st, ' [S4-DF3] deep-filter not found β€” skip'); return wav
504
 
505
+ # Β§106.3 + Β§79 atten limits β€” v4: stronger per-class
506
+ lim = _DF3_LIM_MUJAWWAD if st.mujawwad_conf > 0.6 else _DF3_LIM_MURATTAL
507
+ a_loud = min(10, lim) # was 8
508
+ a_mid = min(18, lim) # was 15
509
+ a_quiet = min(24, lim + 10) # was 20
510
+ a_trans = min(14, lim) # v4: new transition class between loud/mid
 
 
 
 
 
 
 
511
  if st.mujawwad_conf > 0.6:
512
+ a_loud = min(a_loud, 8)
513
+ a_mid = min(a_mid, 10)
514
+ a_quiet = min(a_quiet, 14)
515
+ a_trans = min(a_trans, 9)
516
 
 
517
  d = tempfile.mkdtemp(prefix='safaa3_df3_')
518
  try:
519
+ # ── Prepare pcm_s16le mono input ──────────────────────────────────
520
  fi = os.path.join(d, 'in.wav')
521
  rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
522
  '-acodec', 'pcm_s16le', '-ar', str(SR), '-ac', '1',
523
  '-loglevel', 'error', fi])
524
  if rc or not os.path.exists(fi):
525
  return wav
526
+ mono = _df3_pipe_decode(fi, SR)
527
+ if mono is None:
 
 
 
528
  return wav
529
+ total = len(mono)
530
+
531
+ # ── VAD β€” 50ms chunks with SNR gate (Β§22.5) ──────────────────────
532
+ CHUNK = int(_ADF_CHUNK_S * SR)
533
+ nc = max(1, total // CHUNK)
534
+ # Estimate noise floor from quietest 10% of chunks
535
+ rms_db = np.array([
536
+ 20.0 * np.log10(np.sqrt(np.mean(mono[i*CHUNK:(i+1)*CHUNK]**2)) + 1e-12)
537
+ for i in range(nc)])
538
+ noise_floor = float(np.percentile(rms_db, 10))
539
+
540
+ loud_m = rms_db > _ADF_LOUD_T
541
+ quiet_m = rms_db <= _ADF_QUIET_T
542
+ mid_m = ~loud_m & ~quiet_m
543
+ # Β§22.5 SNR gate: chunks already clean enough β†’ skip DF3, use original
544
+ chunk_snr = rms_db - noise_floor
545
+ clean_m = chunk_snr >= _ADF_SNR_GATE
546
+
547
+ # ── Label smoothing β€” mode filter over 7-chunk window (v4: was 3) ──────
548
+ # Wider window prevents micro-class flicker that causes audible texture changes
549
+ raw_cls = np.where(loud_m, 0, np.where(mid_m, 1, 2))
550
+ smoothed_cls = raw_cls.copy()
551
+ half = 3
552
+ for i in range(half, nc - half):
553
+ window = raw_cls[i-half:i+half+1]
554
+ counts = np.bincount(window, minlength=3)
555
+ smoothed_cls[i] = int(np.argmax(counts))
556
+ loud_m = smoothed_cls == 0
557
+ mid_m = smoothed_cls == 1
558
+ quiet_m = smoothed_cls == 2
559
+
560
+ n_loud = int(loud_m.sum()); n_mid = int(mid_m.sum())
561
+ n_quiet = int(quiet_m.sum()); n_clean = int(clean_m.sum())
562
+ _L(st, f' [S4-ADF] 50ms chunks: LOUD={n_loud} MID={n_mid} '
563
+ f'QUIET={n_quiet} CLEAN(skip)={n_clean} nf={noise_floor:.1f}dBFS')
564
+
565
+ # ── 3 parallel DF passes with per-class flags ─────────────────────
566
+ df_out = {}
567
+ def _run_pass(cls, atten, pf=False, pf_beta=0.02):
568
+ od = os.path.join(d, cls); os.makedirs(od, exist_ok=True)
569
+ cmd = [_DF3_CLI_BIN, '--atten-lim-db', str(atten)]
570
+ if pf:
571
+ cmd += ['--pf', '--pf-beta', str(pf_beta)]
572
+ cmd += ['-o', od, fi]
573
+ r2 = subprocess.run(cmd, capture_output=True, timeout=600)
574
+ wp = os.path.join(od, 'in.wav')
575
+ if r2.returncode or not os.path.exists(wp):
576
+ _L(st, f' [S4-ADF] {cls} pass failed β€” using source')
577
+ return cls, mono.copy()
578
+ arr = _df3_pipe_decode(wp, SR)
579
+ if arr is None:
580
+ _L(st, f' [S4-ADF] {cls} decode failed β€” using source')
581
+ return cls, mono.copy()
582
+ pf_tag = ' +PF' if pf else ''
583
+ _L(st, f' [S4-ADF] {cls:6s} {atten:2d}dB{pf_tag} βœ“ '
584
+ f'max={np.max(np.abs(arr)):.4f}')
585
+ return cls, arr
586
+
587
+ with ThreadPoolExecutor(max_workers=4) as ex:
588
+ futs = [
589
+ ex.submit(_run_pass, 'loud', a_loud, False),
590
+ ex.submit(_run_pass, 'mid', a_mid, False),
591
+ ex.submit(_run_pass, 'quiet', a_quiet, True, 0.04), # PF quiet only
592
+ ex.submit(_run_pass, 'trans', a_trans, False), # v4: transition class
593
+ ]
594
  for fut in as_completed(futs):
595
+ cls, arr = fut.result()
596
+ df_out[cls] = arr
597
+
598
+ # ── Expand masks to sample level ──────────────────────────────────
599
+ def _expand(mask):
600
+ full = np.zeros(total, dtype=bool)
601
+ for i, v in enumerate(mask):
602
+ if v:
603
+ s = i * CHUNK
604
+ full[s:min(s + CHUNK, total)] = True
605
+ return full
606
+
607
+ l_s = _expand(loud_m); m_s = _expand(mid_m)
608
+ q_s = _expand(quiet_m); c_s = _expand(clean_m)
609
+
610
+ min_n = min(total, *(len(df_out[c]) for c in ('loud', 'mid', 'quiet')))
611
+ blended = np.zeros(min_n, dtype=np.float64)
612
+ blended[l_s[:min_n]] = df_out['loud'][:min_n][l_s[:min_n]]
613
+ blended[m_s[:min_n]] = df_out['mid'][:min_n][m_s[:min_n]]
614
+ blended[q_s[:min_n]] = df_out['quiet'][:min_n][q_s[:min_n]]
615
+ # Β§22.5 clean gate: restore original on already-clean chunks
616
+ blended[c_s[:min_n]] = mono[:min_n][c_s[:min_n]]
617
+
618
+ # ── 120ms sigmoid crossfade at every class boundary (v4: was cosine 40ms) ──
619
+ # Sigmoid gives perceptually flatter transition β€” no "click" at midpoint
620
+ cls_map = (l_s[:min_n].astype(np.int8) * 1 +
621
+ m_s[:min_n].astype(np.int8) * 2 +
622
+ q_s[:min_n].astype(np.int8) * 3)
623
+ bounds = np.where(np.diff(cls_map) != 0)[0]
624
+ FADE = _ADF_XFADE
625
+ for b in bounds:
626
+ s_cf = max(0, b - FADE // 2)
627
+ e_cf = min(min_n, b + FADE // 2)
628
+ if e_cf <= s_cf: continue
629
+ ln_ = e_cf - s_cf
630
+ # Sigmoid: x from -6..+6 β†’ smooth S-curve
631
+ x = np.linspace(-6.0, 6.0, ln_)
632
+ fi_ = 1.0 / (1.0 + np.exp(-x)) # sigmoid fade-in
633
+ fo = 1.0 - fi_ # sigmoid fade-out
634
+ cb = int(cls_map[s_cf])
635
+ ca = int(cls_map[min(e_cf, min_n - 1)])
636
+ cb_n = 'loud' if cb==1 else ('mid' if cb==2 else 'quiet')
637
+ ca_n = 'loud' if ca==1 else ('mid' if ca==2 else 'quiet')
638
+ # v4: blend through 'trans' class at midpoint for 3-way smooth
639
+ mid_blend = (
640
+ df_out[cb_n][:min_n][s_cf:e_cf].astype(np.float64) * fo * 0.5 +
641
+ df_out['trans'][:min_n][s_cf:e_cf].astype(np.float64) * 0.5 +
642
+ df_out[ca_n][:min_n][s_cf:e_cf].astype(np.float64) * fi_ * 0.5
643
+ )
644
+ blended[s_cf:e_cf] = mid_blend
645
+ _L(st, f' [S4-ADF] {len(bounds)} sigmoid crossfades @ {FADE} samp (120ms)')
646
+
647
+ # ── RL-16: restore Ghunnah guard bands from original ──────────────
648
+ blended_f32 = np.where(np.isfinite(blended), blended, 0.0).astype(np.float32)
649
+ blended_f32 = _stft_restore_bands(blended_f32, mono[:min_n], SR, _RL16_BANDS)
650
+ _L(st, f' [S4-ADF] RL-16 bands restored: '
651
+ f'{", ".join(f"{lo}-{hi}Hz" for lo,hi in _RL16_BANDS)}')
652
+
653
+ # ── Silence guard ─────────────────────────────────────────────────
654
+ if float(np.max(np.abs(blended_f32))) < 1e-4:
655
+ _L(st, ' [S4-ADF] ⚠ silent result β€” fallback to mid pass')
656
+ blended_f32 = df_out['mid'][:min_n].astype(np.float32)
657
+
658
+ # ── Encode to pcm_s24le stereo ────────────────────────────────────
659
  out = _tmp('s4', st)
660
+ r3 = subprocess.run(
661
+ ['ffmpeg', '-nostdin', '-y', '-hide_banner', '-loglevel', 'error',
662
+ '-f', 'f32le', '-ar', str(SR), '-ac', '1', '-i', '-',
663
+ '-ar', str(SR), '-ac', '2', '-acodec', 'pcm_s24le', out],
664
+ input=blended_f32.tobytes(), capture_output=True, timeout=120)
665
+ if r3.returncode or not os.path.exists(out):
666
  return wav
667
 
668
+ delta = _rmsdb(blended_f32) - _rmsdb(mono)
 
 
 
669
  st.df3 = True
670
+ _L(st, f' [S4-ADF] βœ“ loud={a_loud}dB mid={a_mid}dB quiet={a_quiet}dB '
671
+ f'RMS Ξ”={delta:+.2f}dB')
 
672
  return out
673
  except Exception as e:
674
  _L(st, f' [S4-DF3] exception: {e}'); return wav
 
709
  if not nf_vals:
710
  _L(st, ' [S5-JALAA] no reverb frames β€” skip'); return wav
711
 
712
+ nf = float(np.clip(float(np.median(nf_vals)) + 3, -72, -20)) # v4: floor raised -25β†’-20
713
+ nr = 3 if rt60 > RT60_AGGR else 2 # v4: base raised (was 2/1)
714
  if st.mujawwad_conf > 0.6: nr = max(1, nr - 1)
715
 
716
+ # v4: two-pass JALAA β€” first pass stationary, second adaptive tracking
717
+ out = _tmp('s5a', st)
718
  rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
719
+ '-af', f'afftdn=nr={nr}:nf={nf:.0f}:nt=w:tn=1',
720
  '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1',
721
  '-loglevel', 'error', out])
722
  if rc or not os.path.exists(out):
723
  return wav
724
 
725
+ out2 = _tmp('s5b', st)
726
+ rc2, _, _ = _run(['ffmpeg', '-y', '-i', out,
727
+ '-af', f'afftdn=nr={max(1,nr-1)}:nf={nf+3:.0f}:nt=w:tn=1',
728
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '1',
729
+ '-loglevel', 'error', out2])
730
+ if rc2 or not os.path.exists(out2):
731
+ pass # keep single-pass result
732
+ else:
733
+ _cleanup(out); out = out2
734
+
735
  post = _decode(out)
736
  if post is not None:
737
  d = _rmsdb(post) - _rmsdb(samples)
 
744
  return out
745
 
746
 
747
+ # ─── Stage 6: Tail floor NR β€” 2-stage (afftdn + anlmdn) ─────────────────────
748
  def _tailnr(wav, samples, rt60, st):
749
  """
750
+ 2-stage NR pipeline (Β§83.3 order: stationary β†’ non-stationary):
751
+ Stage A: afftdn with nt=w (adaptive tracking) for stationary noise floor.
752
+ Stage B: anlmdn (s=7:p=3:r=15) for transient/non-stationary residuals.
753
+ RT60-scaled nr: base=2, +1 per 1.5s RT60 above threshold, cap=5 (Β§5.3: 20dB/nr max).
754
  [I3] If JALAA already ran, reduce nr by 1 to avoid double-attenuation.
755
+ RL-16: sibilant SNR guard β€” revert if Safir/Tafasshi band drops > 2dB.
756
  """
757
  if rt60 < RT60_TAILNR or samples is None or not NUMPY_OK:
758
  return wav
759
  fn = int(0.200 * SR)
760
  overall = _rmsdb(samples)
761
+ fdb = np.array([float(20 * np.log10(np.sqrt(np.mean(samples[i:i+fn]**2)) + 1e-10))
762
  for i in range(0, len(samples) - fn, fn)])
763
  quiet = fdb[fdb < overall - 10]
764
  if len(quiet) == 0:
765
  return wav
766
+
767
+ nf = float(np.clip(float(np.median(quiet)) + 4, -72, -25))
768
+ # v4: RT60-scaled nr β€” higher cap (8 was 5), stronger base
769
+ nr = min(8, 3 + int((rt60 - RT60_TAILNR) / 1.2)) # was cap=5, base=2, step=1.5
770
  if st.mujawwad_conf > 0.6: nr = max(1, nr - 1)
771
+ if st.jalaa: nr = max(1, nr - 1) # [I3]
772
+
773
+ # ── Stage A: afftdn with adaptive noise tracking ──────────────────────
774
+ out_a = _tmp('s6a', st)
775
  rc, _, _ = _run(['ffmpeg', '-y', '-i', wav,
776
+ '-af', f'afftdn=nr={nr}:nf={nf:.0f}:nt=w:om=o',
777
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '2',
778
+ '-loglevel', 'error', out_a])
779
+ if rc or not os.path.exists(out_a):
780
  return wav
781
+
782
+ # ── Stage B: anlmdn for non-stationary residuals ──────────────────────
783
+ # anlmdn: s=patch size, p=context, r=search radius (Β§95 KB)
784
+ # Scale strength with nr: gentle (s=5) at nr<=2, standard (s=7) at nr>2
785
+ patch = 7 if nr > 2 else 5
786
+ out_b = _tmp('s6b', st)
787
+ rc2, _, _ = _run(['ffmpeg', '-y', '-i', out_a,
788
+ '-af', f'anlmdn=s={patch}:p=3:r=15:m=1',
789
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '2',
790
+ '-loglevel', 'error', out_b])
791
+ if rc2 or not os.path.exists(out_b):
792
+ # Stage B failed β€” commit Stage A only
793
+ st.tail_nr = True
794
+ jalaa_note = ' (JALAA-adj)' if st.jalaa else ''
795
+ _L(st, f' [S6-tailNR] βœ“ 1-stage nr={nr}{jalaa_note} nf={nf:.0f}dB '
796
+ f'[anlmdn failed β€” Stage A only]')
797
+ return out_a
798
+
799
+ # ── Sibilant SNR guard (RL-16 spirit) ─────────────────────────────────
800
+ post_s = _decode(out_b)
801
+ if post_s is not None:
802
+ sib_orig = _band_energy(samples, 3500, 8000)
803
+ sib_post = _band_energy(post_s, 3500, 8000)
804
+ sib_drop = sib_post - sib_orig
805
+ if sib_drop < -2.0:
806
+ _L(st, f' [S6-tailNR] sibilant drop {sib_drop:+.1f}dB β€” '
807
+ f'revert Stage B, keep Stage A')
808
+ _cleanup(out_b)
809
+ st.tail_nr = True
810
+ _L(st, f' [S6-tailNR] βœ“ nr={nr} nf={nf:.0f}dB [Stage A only]')
811
+ return out_a
812
+
813
+ # ── Stage C: v4 β€” gentle anlmdn second pass for reverb tail residual ────
814
+ out_c = _tmp('s6c', st)
815
+ rc3, _, _ = _run(['ffmpeg', '-y', '-i', out_b,
816
+ '-af', f'anlmdn=s=5:p=3:r=10:m=1',
817
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '2',
818
+ '-loglevel', 'error', out_c])
819
+ if rc3 or not os.path.exists(out_c):
820
+ pass # keep Stage B
821
+ else:
822
+ post_c = _decode(out_c)
823
+ if post_c is not None:
824
+ sib_c = _band_energy(post_c, 3500, 8000)
825
+ sib_drop_c = sib_c - _band_energy(samples, 3500, 8000)
826
+ if sib_drop_c >= -2.5:
827
+ _cleanup(out_b); out_b = out_c
828
+ else:
829
+ _cleanup(out_c)
830
+
831
+ _cleanup(out_a)
832
  st.tail_nr = True
833
+ jalaa_note = ' (JALAA-adj)' if st.jalaa else ''
834
+ _L(st, f' [S6-tailNR] βœ“ 3-stage nr={nr}{jalaa_note} nf={nf:.0f}dB '
835
+ f'patch={patch} rt60={rt60:.1f}s')
836
+ return out_b
837
 
838
 
839
  # ─── Stage 7: Arabic phoneme guards (Β§35/Β§52/Β§143/Β§152) ──────────────────────
 
990
  # S7 Arabic guards
991
  cur = _arabic_guards(s_orig, cur, st)
992
 
993
+ # Final stereo encode + S8 volume boost + v4: presence lift 2kHz +1.5dB
994
  rc, _, err = _run(['ffmpeg', '-y', '-i', cur,
995
+ '-af', ('volume=1.85,'
996
+ 'equalizer=f=2000:width_type=o:width=1.0:g=1.5,'
997
+ 'equalizer=f=300:width_type=o:width=1.0:g=-1.0,'
998
+ 'alimiter=level_in=1:level_out=1:limit=0.89:attack=5:release=50'),
999
+ '-acodec', WAV_CODEC, '-ar', str(SR), '-ac', '2',
1000
  '-loglevel', 'error', output_path])
1001
  if rc:
1002
+ _L(st, f' ERROR: final encode: {err[:80]}'); return st
 
1003
 
1004
  sf_ = _decode(output_path)
1005
  if sf_ is not None: st.drr_after = _drr(sf_)
 
1022
  if __name__ == '__main__':
1023
  import argparse, json
1024
  ap = argparse.ArgumentParser(description=f'الءفاؑ {__version__} β€” Dereverberation Engine')
1025
+ ap.add_argument('input')
1026
+ ap.add_argument('output')
1027
+ ap.add_argument('--tier', default='TIER_UNKNOWN')
1028
+ ap.add_argument('--mujawwad', type=float, default=0.0)
1029
+ ap.add_argument('--rt60', type=float, default=0.0, help='Force RT60 (0=auto)')
 
 
 
 
1030
  a = ap.parse_args()
1031
  st = process(a.input, a.output,
1032
  source_tier=a.tier, mujawwad_conf=a.mujawwad, force_rt60=a.rt60)
requirements.txt CHANGED
@@ -5,3 +5,4 @@ scipy==1.13.1
5
  gunicorn==22.0.0
6
  librosa
7
  soundfile
 
 
5
  gunicorn==22.0.0
6
  librosa
7
  soundfile
8
+ nara_wpe