carm5333 commited on
Commit
8a8cdcb
ยท
verified ยท
1 Parent(s): 754805d

add engine_v70.py

Browse files
Files changed (1) hide show
  1. engine_v70.py +1515 -0
engine_v70.py ADDED
@@ -0,0 +1,1515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
4
+ โ•‘ Audio Enhancement Engine v7.0 โ€” "Convergence" โ•‘
5
+ โ•‘ Reference: Sheikh Yasser Al-Dossari โ€” Al-A'raf โ€” 1425H โ•‘
6
+ โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ
7
+ โ•‘ v7 improvements over v6.6: โ•‘
8
+ โ•‘ โ•‘
9
+ โ•‘ 1. THREE-PASS PIPELINE โ€” Pass3 ูŠูุตุญุญ LRA+RMS ุจุนุฏ spectral correction โ•‘
10
+ โ•‘ 2. STATISTICAL PRE-EQ โ€” ุชุตุญูŠุญ ุงู„ูุฌูˆุงุช ุงู„ู…ู†ุชุธู…ุฉ ู…ู† 5+ ู…ู„ูุงุช ู…ุนุงู„ุฌุฉ โ•‘
11
+ โ•‘ 3. ITERATIVE CONVERGENCE โ€” ูŠูƒุฑุฑ ุญุชู‰ score โ‰ฅ 97 ุฃูˆ max 3 ู…ุญุงูˆู„ุงุช โ•‘
12
+ โ•‘ 4. LRA FEEDBACK โ€” ูŠู‚ูŠุณ LRA ุจุนุฏ Pass2 ูˆูŠุถุบุทู‡ ููŠ Pass3 ุจุฏู‚ุฉ โ•‘
13
+ โ•‘ 5. RMS FEEDBACK โ€” ูŠูุนุฏู‘ู„ ุงู„ู€ gain ููŠ Pass3 ู„ุถุจุท RMS ุนู„ู‰ -10.01 โ•‘
14
+ โ•‘ 6. ADAPTIVE CORRECTION SCALE โ€” ูŠุฑูุน/ูŠุฎูุถ scale ุจู†ุงุกู‹ ุนู„ู‰ error โ•‘
15
+ โ•‘ 7. SPECTRAL BIAS CORRECTION โ€” ูŠูุฒูŠู„ ุงู„ุงู†ุญูŠุงุฒ ุงู„ู…ู†ุชุธู… ููŠ ุงู„ู€ EQ โ•‘
16
+ โ•‘ ุงู„ู‡ุฏู: โ‰ฅ 97/100 ู„ู„ู€ GOOD/FAIRุŒ โ‰ฅ 94/100 ู„ู„ู€ POOR/EXTREME โ•‘
17
+ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
18
+ """
19
+
20
+ import subprocess, sys, json, os, shutil, warnings
21
+ import numpy as np
22
+ from scipy.fft import rfft, rfftfreq
23
+ from scipy.optimize import minimize
24
+ from scipy.interpolate import CubicSpline
25
+ from dataclasses import dataclass, field
26
+ from typing import Dict, List, Tuple, Optional
27
+ warnings.filterwarnings('ignore')
28
+
29
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
30
+ # CONSTANTS
31
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
32
+ REF_PATH = '/mnt/user-data/uploads/ุงู„ู…ุฑุฌุน1425.mp3'
33
+ REF_CACHE = '/tmp/enhance_ref_fp.v7.json'
34
+ _CLI_REF_FILES = [] # S22: set by --ref CLI args; overrides REF_FILES in get_reference_fingerprint()
35
+ SR = 48000
36
+
37
+ TARGET = {
38
+ 'lufs': -6.29,
39
+ 'rms': -9.44,
40
+ 'crest': 9.45,
41
+ 'lra': 4.00, # fallback only โ€” engine uses ref_fp.lra
42
+ 'peak_tp': 1.22,
43
+ 'snr': 35.5,
44
+ 'warmth_ratio': 0.60,
45
+ 'sr': 48000,
46
+ 'bitrate': '320k',
47
+ }
48
+
49
+ # v7: ุงู†ุญูŠุงุฒ ุทูŠููŠ ู…ู‚ุงุณ ุญู‚ูŠู‚ูŠุงู‹ ู…ู† ุฒูˆุฌ (ุฃุตู„ูŠ / v6.6) ู„ุณูˆุฑุฉ ู‚ 1425
50
+ # ุงู„ู‚ูŠู…ุฉ = ู…ุชูˆุณุท (ref - v6.6_output) ุจุนุฏ level normalization
51
+ # ูŠูุทุจูŽู‘ู‚ ููŠ Pass2 ูƒู€ pre-correction ู‚ุจู„ ุงู„ู€ spectral_correction_eq
52
+ SPECTRAL_BIAS = {
53
+ 80: -2.84, # v6.6 ูŠูุฒูŠุฏ bass ุฒูŠุงุฏุฉ โ†’ cut
54
+ 100: -5.08, # cut ู‚ูˆูŠ
55
+ 125: +4.16, # v6.6 ูŠู†ู‚ุต 125Hz โ†’ boost
56
+ 200: -7.77, # ุฃูƒุจุฑ ุงู†ุญูŠุงุฒ โ€” cut ุญุงุฏ ู…ู†ุชุธู…
57
+ 250: +7.95, # v6.6 ูŠู†ู‚ุต 250Hz ูƒุซูŠุฑุงู‹ โ†’ boost ู‚ูˆูŠ
58
+ 315: +3.85, # boost
59
+ 400: -3.01, # cut
60
+ 500: +1.95, # boost ุฎููŠู
61
+ 630: -3.69, # cut
62
+ 800: +1.76, # boost ุฎููŠู
63
+ 1250: +0.54,
64
+ 2500: +2.32,
65
+ 3150: +1.55,
66
+ 5000: -1.03,
67
+ 6300: -1.12,
68
+ 8000: +1.10,
69
+ }
70
+ BIAS_SCALE = 0.15 # v7 ุชุฌุฑูŠุจูŠ: 15% ูู‚ุท โ€” bias ู…ู† ู…ู„ู ูˆุงุญุฏ ุบูŠุฑ ูƒุงูู ู„ู„ู€ scale ุงู„ุฃุนู„ู‰
71
+
72
+ CENTERS_31 = [
73
+ 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
74
+ 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
75
+ 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000
76
+ ]
77
+
78
+ BARK_BANDS = [
79
+ (20,100),(100,200),(200,300),(300,400),(400,510),(510,630),(630,770),
80
+ (770,920),(920,1080),(1080,1270),(1270,1480),(1480,1720),(1720,2000),
81
+ (2000,2320),(2320,2700),(2700,3150),(3150,3700),(3700,4400),(4400,5300),
82
+ (5300,6400),(6400,7700),(7700,9500),(9500,12000),(12000,20000)
83
+ ]
84
+
85
+ A_WEIGHT = {
86
+ 20:-50.5, 25:-44.7, 31.5:-39.4, 40:-34.6, 50:-30.2,
87
+ 63:-26.2, 80:-22.5, 100:-19.1, 125:-16.1, 160:-13.4,
88
+ 200:-10.9, 250:-8.6, 315:-6.6, 400:-4.8, 500:-3.2,
89
+ 630:-1.9, 800:-0.8, 1000:0.0, 1250:0.6, 1600:1.0,
90
+ 2000:1.2, 2500:1.3, 3150:1.2, 4000:1.0, 5000:0.5,
91
+ 6300:-0.1, 8000:-1.1, 10000:-2.5,12500:-4.3,16000:-6.6, 20000:-9.3
92
+ }
93
+
94
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•๏ฟฝ๏ฟฝโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
95
+ # AUDIO I/O
96
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
97
+ def load(path: str, sr: int = SR, mono: bool = True,
98
+ skip: int = 0, duration: int = None) -> np.ndarray:
99
+ channels = '1' if mono else '2'
100
+ cmd = ['ffmpeg', '-i', path]
101
+ if skip > 0: cmd += ['-ss', str(skip)]
102
+ if duration: cmd += ['-t', str(duration)]
103
+ cmd += ['-f', 's16le', '-ac', channels, '-ar', str(sr), '-loglevel', 'error', '-']
104
+ r = subprocess.run(cmd, capture_output=True)
105
+ if not r.stdout:
106
+ raise RuntimeError(f"Failed to load: {path}")
107
+ return np.frombuffer(r.stdout, dtype=np.int16).astype(np.float32) / 32768.0
108
+
109
+ def get_probe(path: str) -> Dict:
110
+ r = subprocess.run(
111
+ ['ffprobe','-v','quiet','-print_format','json',
112
+ '-show_streams','-show_format', path],
113
+ capture_output=True, text=True)
114
+ return json.loads(r.stdout)
115
+
116
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
117
+ # SIGNAL METRICS
118
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
119
+ def rms_db(a: np.ndarray) -> float:
120
+ return float(20*np.log10(np.sqrt(np.mean(a**2))+1e-10))
121
+
122
+ def peak_db(a: np.ndarray) -> float:
123
+ return float(20*np.log10(np.max(np.abs(a))+1e-10))
124
+
125
+ def crest_factor(a: np.ndarray) -> float:
126
+ return float(peak_db(a) - rms_db(a))
127
+
128
+ def lra_estimate(a: np.ndarray, sr: int = SR) -> float:
129
+ n = int(0.4*sr); step = n//2
130
+ lvls = np.array([
131
+ 20*np.log10(np.sqrt(np.mean(a[i:i+n]**2))+1e-10)
132
+ for i in range(0, len(a)-n, step)
133
+ ])
134
+ if len(lvls) < 2: return 0.0
135
+ active = lvls[lvls > np.max(lvls)-30]
136
+ return float(np.percentile(active,95)-np.percentile(active,10)) if len(active)>=2 else 0.0
137
+
138
+ def snr_estimate(a: np.ndarray, sr: int = SR) -> float:
139
+ n = int(0.1*sr)
140
+ blocks = np.array([np.sqrt(np.mean(a[i:i+n]**2)) for i in range(0,len(a)-n,n)])
141
+ return float(20*np.log10(np.percentile(blocks,85)/(np.percentile(blocks,3)+1e-10)))
142
+
143
+ def count_clips(a: np.ndarray, threshold: float = 0.99) -> int:
144
+ return int(np.sum(np.abs(a) >= threshold))
145
+
146
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
147
+ # DE-CLIPPING
148
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
149
+ def declip(audio: np.ndarray, threshold: float = 0.98) -> Tuple[np.ndarray, int]:
150
+ clipped = np.abs(audio) >= threshold
151
+ n_clipped = int(np.sum(clipped))
152
+ if n_clipped == 0: return audio, 0
153
+ out = audio.copy(); n = len(audio)
154
+ diff = np.diff(clipped.astype(int))
155
+ starts = np.where(diff == 1)[0] + 1
156
+ ends = np.where(diff == -1)[0] + 1
157
+ if clipped[0]: starts = np.insert(starts, 0, 0)
158
+ if clipped[-1]: ends = np.append(ends, n)
159
+ for s, e in zip(starts, ends):
160
+ ctx = 40
161
+ pre_idx = np.arange(max(0,s-ctx), s)
162
+ post_idx = np.arange(e, min(n,e+ctx))
163
+ good = np.concatenate([pre_idx, post_idx])
164
+ if len(good) < 4: continue
165
+ try:
166
+ cs = CubicSpline(good, audio[good], extrapolate=True)
167
+ out[np.arange(s,e)] = cs(np.arange(s,e))
168
+ except: pass
169
+ return out, n_clipped
170
+
171
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
172
+ # SPECTRAL ANALYSIS
173
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
174
+ def third_octave(audio: np.ndarray, sr: int = SR,
175
+ chunk_sec: int = 40, a_weighted: bool = False) -> Dict[float,float]:
176
+ chunk = audio[:sr*chunk_sec] if len(audio) > sr*chunk_sec else audio
177
+ N = len(chunk)
178
+ spec = np.abs(rfft(chunk))
179
+ freqs = rfftfreq(N, 1.0/sr)
180
+ out = {}
181
+ for fc in CENTERS_31:
182
+ if fc >= sr/2: continue
183
+ fl = fc/(2**(1/6)); fh = fc*(2**(1/6))
184
+ mask = (freqs>=fl) & (freqs<fh)
185
+ if mask.sum() > 0:
186
+ v = float(20*np.log10(np.mean(spec[mask])+1e-10))
187
+ if a_weighted and fc in A_WEIGHT: v += A_WEIGHT[fc]
188
+ out[fc] = v
189
+ return out
190
+
191
+ def bark_spectrum(audio: np.ndarray, sr: int = SR) -> List[Tuple[float,float]]:
192
+ chunk = audio[:sr*30] if len(audio) > sr*30 else audio
193
+ N = len(chunk)
194
+ spec = np.abs(rfft(chunk))**2
195
+ freqs = rfftfreq(N, 1.0/sr)
196
+ result = []
197
+ for fl, fh in BARK_BANDS:
198
+ fh = min(fh, sr//2)
199
+ mask = (freqs>=fl) & (freqs<fh)
200
+ if mask.sum() > 0:
201
+ result.append((float(np.sqrt(fl*fh)),
202
+ float(10*np.log10(np.mean(spec[mask])+1e-15))))
203
+ return result
204
+
205
+ def hf_status(bands: Dict[float,float]) -> str:
206
+ vals = [bands.get(fc,-99) for fc in [6300,8000,10000] if fc in bands]
207
+ if not vals: return 'absent'
208
+ avg = np.mean(vals)
209
+ if avg > 10: return 'good'
210
+ if avg > -5: return 'weak'
211
+ return 'absent'
212
+
213
+ def detect_hf_rolloff(bands: Dict[float,float],
214
+ drop_threshold: float = 12.0) -> float:
215
+ """v6.4: drop_threshold=12dB (was 15 in v6.3)"""
216
+ fs = sorted([f for f in bands if 1600 <= f <= 20000])
217
+ if not fs: return 20000.0
218
+ prev = bands[fs[0]]
219
+ for fc in fs[1:]:
220
+ curr = bands[fc]
221
+ if prev - curr > drop_threshold:
222
+ return float(fc)
223
+ prev = curr
224
+ return 20000.0
225
+
226
+ def merge_eq_nodes(nodes: List[Tuple], min_hz_gap: float = 50.0) -> List[Tuple]:
227
+ if not nodes: return nodes
228
+ nodes = sorted(nodes, key=lambda x: x[0])
229
+ merged = [list(nodes[0])]
230
+ for f0, g, Q in nodes[1:]:
231
+ pf, pg, pq = merged[-1]
232
+ if abs(f0-pf) < min_hz_gap:
233
+ total = float(np.clip(pg+g, -16, 16))
234
+ avg_f = (pf*abs(pg)+f0*abs(g)) / (abs(pg)+abs(g)+1e-6)
235
+ merged[-1] = [round(avg_f,0), round(total,2), round((pq+Q)/2,2)]
236
+ else:
237
+ merged.append([f0, g, Q])
238
+ return [tuple(x) for x in merged]
239
+
240
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
241
+ # REFERENCE FINGERPRINT
242
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
243
+ @dataclass
244
+ class ReferenceFingerprint:
245
+ third_oct: Dict[float,float] = field(default_factory=dict)
246
+ bark: List[Tuple] = field(default_factory=list)
247
+ a_weighted: Dict[float,float] = field(default_factory=dict)
248
+ rms: float = -9.44
249
+ peak: float = 0.99
250
+ crest: float = 9.45
251
+ lra: float = 3.50 # measured from real reference
252
+ lufs: float = -6.29
253
+ warmth_ratio: float = 0.0
254
+ clarity_ratio: float = 0.0
255
+ tilt_slope: float = 0.0
256
+
257
+ def build_reference_fingerprint(audio: np.ndarray, sr: int = SR) -> ReferenceFingerprint:
258
+ fp = ReferenceFingerprint()
259
+
260
+ # v6.5: ุจู†ุงุก ุงู„ุทูŠู ู…ู† median ูƒู„ ุงู„ู…ู‚ุงุทุน (ุฃูƒุซุฑ ุงุณุชู‚ุฑุงุฑุงู‹)
261
+ chunk_n = sr * 20
262
+ n_chunks = max(1, len(audio) // chunk_n)
263
+ all_bands = []
264
+ crests_all, lras_all = [], []
265
+
266
+ for ci in range(n_chunks):
267
+ seg = audio[ci * chunk_n : (ci+1) * chunk_n]
268
+ if len(seg) < sr * 3: continue
269
+ b_seg = third_octave(seg, sr, a_weighted=False)
270
+ all_bands.append(b_seg)
271
+ crests_all.append(crest_factor(seg))
272
+ lras_all.append(lra_estimate(seg, sr))
273
+
274
+ if not all_bands:
275
+ all_bands = [third_octave(audio, sr, a_weighted=False)]
276
+
277
+ # Median spectrum โ€” ู…ู‚ุงูˆู… ู„ู„ู€ outliers
278
+ fp.third_oct = {}
279
+ for fc in CENTERS_31:
280
+ vals = [b.get(fc) for b in all_bands if b.get(fc) is not None]
281
+ if vals:
282
+ fp.third_oct[fc] = float(np.median(vals))
283
+
284
+ fp.a_weighted = third_octave(audio[:sr*30] if len(audio)>sr*30 else audio,
285
+ sr, a_weighted=True)
286
+ fp.bark = bark_spectrum(audio[:sr*30] if len(audio)>sr*30 else audio, sr)
287
+ fp.rms = rms_db(audio)
288
+ fp.peak = peak_db(audio)
289
+ fp.crest = float(np.median(crests_all)) if crests_all else crest_factor(audio)
290
+ fp.lra = float(np.median(lras_all)) if lras_all else lra_estimate(audio, sr)
291
+
292
+ fc_arr = np.array([fc for fc in CENTERS_31 if 100<=fc<=10000 and fc in fp.third_oct])
293
+ db_arr = np.array([fp.third_oct[fc] for fc in fc_arr])
294
+ if len(fc_arr) >= 3:
295
+ fp.tilt_slope = float(np.polyfit(np.log2(fc_arr/1000.0), db_arr, 1)[0])
296
+
297
+ # v6.5: warmth = spectral tilt 200โ†’2000Hz (ุฃูƒุซุฑ ุงุณุชู‚ุฑุงุฑุงู‹ ู…ู† bass/mid ratio)
298
+ # ุงู„ู€ tilt_slope ูŠุนูƒุณ ุงู„ุชูˆุงุฒู† ุงู„ุฌูˆู‡ุฑูŠ ู„ู„ุทูŠู ุจุฏูˆู† ุชุฃุซูŠุฑ ุงู„ู€ resonances
299
+ tilt_fc = np.array([fc for fc in CENTERS_31 if 200<=fc<=2000 and fc in fp.third_oct])
300
+ tilt_db = np.array([fp.third_oct[fc] for fc in tilt_fc])
301
+ if len(tilt_fc) >= 3:
302
+ fp.warmth_ratio = float(np.polyfit(np.log2(tilt_fc/1000.0), tilt_db, 1)[0])
303
+ else:
304
+ fp.warmth_ratio = fp.tilt_slope
305
+
306
+ high_e = np.mean([fp.third_oct.get(fc,-60) for fc in [4000,5000,6300,8000]])
307
+ mid_e2 = np.mean([fp.third_oct.get(fc,-60) for fc in [500,630,800,1000]])
308
+ fp.clarity_ratio = float(mid_e2 - high_e)
309
+ return fp
310
+
311
+ def get_reference_fingerprint() -> ReferenceFingerprint:
312
+ """
313
+ v7: Multi-file fingerprint ู…ู† 3 ุณูˆุฑ 1425H (ุงู„ุฃุนุฑุงู + ุงู„ูุชุญ + ูุงุทุฑ)
314
+ - ูŠุชุฌู†ุจ ู…ู‚ุฏู…ุฉ ูƒู„ ู…ู„ู (ูŠุจุฏุฃ ู…ู† 15%) ู„ุชูุงุฏูŠ bass artifacts
315
+ - median ุนุจุฑ ุงู„ู…ู„ูุงุช ุงู„ุซู„ุงุซุฉ ุจุนุฏ level normalization
316
+ - ุฃูƒุซุฑ ุฏู‚ุฉ ู…ู† ู…ู„ู ูˆุงุญุฏ (ฯƒ ุฃู‚ู„ ููŠ RMS/Crest/LRA)
317
+ """
318
+ import json
319
+ cache_file = '/tmp/enhance_ref_fp.v7.json'
320
+
321
+ # S22: use CLI-provided server paths if set; fall back to Termux dev paths
322
+ REF_FILES = (_CLI_REF_FILES if _CLI_REF_FILES else [
323
+ '/mnt/user-data/uploads/ุงู„ู…ุฑุฌุน1425.mp3',
324
+ '/mnt/user-data/uploads/ุณูˆุฑู‡_ุงู„ูุชุญ_174232307.mp3',
325
+ '/mnt/user-data/uploads/ูŠุงุณุฑ_ุงู„ุฏูˆุณุฑูŠ_ู…ุง_ุชุณูŠุฑ_ู…ู†_ุณูˆุฑุฉ_ูุงุทุฑ_1425__ุงูˆู„_ู…ุฑุฉ_ุชู†_173856242_99.mp3',
326
+ ])
327
+ # ู†ุณุชุฎุฏู… ุงู„ู…ู„ู ุงู„ุฃูˆู„ ู„ู„ุชุญู‚ู‚ ู…ู† ุชุบูŠูŠุฑ cache
328
+ primary = REF_FILES[0]
329
+
330
+ if os.path.exists(cache_file):
331
+ try:
332
+ if os.path.getmtime(cache_file) >= os.path.getmtime(primary):
333
+ with open(cache_file, 'r') as f:
334
+ d = json.load(f)
335
+ fp = ReferenceFingerprint()
336
+ fp.third_oct = {float(k): v for k,v in d['third_oct'].items()}
337
+ fp.a_weighted = {float(k): v for k,v in d.get('a_weighted',{}).items()}
338
+ fp.bark = [(float(a), float(b)) for a,b in d.get('bark',[])]
339
+ fp.rms = d['rms']; fp.peak = d['peak']
340
+ fp.crest = d['crest']; fp.lra = d['lra']
341
+ fp.tilt_slope = d['tilt_slope']
342
+ fp.warmth_ratio = d['warmth_ratio']
343
+ fp.clarity_ratio = d.get('clarity_ratio', 0.0)
344
+ return fp
345
+ except Exception:
346
+ pass
347
+
348
+ # ุจู†ุงุก fingerprint ู…ู† ูƒู„ ู…ู„ู
349
+ # percentages ุชุชุฌู†ุจ ุงู„ุจุฏุงูŠุฉ: 15% โ†’ 88%
350
+ SAFE_PCT = [0.15, 0.28, 0.42, 0.56, 0.70, 0.84]
351
+ all_fp_data = []
352
+
353
+ for path in REF_FILES:
354
+ if not os.path.exists(path):
355
+ continue
356
+ try:
357
+ probe = get_probe(path)
358
+ total_s = int(float(probe.get('format',{}).get('duration',300)))
359
+ skips = [max(15, int(total_s * r)) for r in SAFE_PCT]
360
+
361
+ clips = [f'/tmp/ref_v7_f{REF_FILES.index(path)}_s{i}.wav' for i in range(len(skips))]
362
+ procs = [
363
+ subprocess.Popen(['ffmpeg','-y','-i',path,
364
+ '-ss',str(sk),'-t','30',
365
+ '-f','s16le','-ac','1','-ar',str(SR),
366
+ cl,'-loglevel','error'])
367
+ for sk,cl in zip(skips,clips)
368
+ ]
369
+ for p in procs: p.wait()
370
+
371
+ segs_spec, segs_rms, segs_crest, segs_lra = [], [], [], []
372
+ for cl in clips:
373
+ try:
374
+ if not os.path.exists(cl) or os.path.getsize(cl) < SR*2: continue
375
+ raw = open(cl, 'rb').read()
376
+ a = np.frombuffer(raw, np.int16).astype(np.float32) / 32768.0
377
+ if len(a) < SR*3: continue
378
+ segs_spec.append(third_octave(a, a_weighted=False))
379
+ segs_rms.append(rms_db(a))
380
+ segs_crest.append(crest_factor(a))
381
+ segs_lra.append(lra_estimate(a))
382
+ except: pass
383
+
384
+ if len(segs_spec) >= 3:
385
+ common = [fc for fc in CENTERS_31 if all(fc in s for s in segs_spec)]
386
+ med_spec = {fc: float(np.median([s[fc] for s in segs_spec])) for fc in common}
387
+ all_fp_data.append({
388
+ 'spec': med_spec,
389
+ 'rms': float(np.median(segs_rms)),
390
+ 'crest': float(np.median(segs_crest)),
391
+ 'lra': float(np.median(segs_lra)),
392
+ })
393
+ except Exception:
394
+ continue
395
+
396
+ # fallback ุนู„ู‰ ุงู„ุฃุนุฑุงู ูˆุญุฏู‡ ุฅุฐุง ูุดู„ ุงู„ุชุญู…ูŠู„
397
+ if len(all_fp_data) < 2:
398
+ fp_single = _build_single_ref(primary)
399
+ return fp_single
400
+
401
+ # level-normalize ุซู… median ุนุจุฑ ุงู„ู…ู„ูุงุช
402
+ ref_level = float(np.mean([f['rms'] for f in all_fp_data]))
403
+ common_all = [fc for fc in CENTERS_31 if all(fc in f['spec'] for f in all_fp_data)]
404
+ normalized = [{fc: f['spec'][fc] + (ref_level - f['rms']) for fc in common_all}
405
+ for f in all_fp_data]
406
+ multi_spec = {fc: float(np.median([s[fc] for s in normalized])) for fc in common_all}
407
+
408
+ fp = ReferenceFingerprint()
409
+ fp.third_oct = multi_spec
410
+ fp.rms = float(np.median([f['rms'] for f in all_fp_data]))
411
+ fp.peak = -1.22
412
+ fp.crest = float(np.median([f['crest'] for f in all_fp_data]))
413
+ fp.lra = float(np.median([f['lra'] for f in all_fp_data]))
414
+
415
+ fc_arr = np.array([fc for fc in CENTERS_31 if 100<=fc<=10000 and fc in fp.third_oct])
416
+ db_arr = np.array([fp.third_oct[fc] for fc in fc_arr])
417
+ if len(fc_arr) >= 3:
418
+ fp.tilt_slope = float(np.polyfit(np.log2(fc_arr/1000.0), db_arr, 1)[0])
419
+
420
+ tilt_fc = np.array([fc for fc in CENTERS_31 if 200<=fc<=2000 and fc in fp.third_oct], dtype=float)
421
+ tilt_db = np.array([fp.third_oct[fc] for fc in tilt_fc])
422
+ if len(tilt_fc) >= 3:
423
+ fp.warmth_ratio = float(np.polyfit(np.log2(tilt_fc/1000.0), tilt_db, 1)[0])
424
+
425
+ # a_weighted ูˆ bark ู…ู† ุงู„ุฃุนุฑุงู
426
+ try:
427
+ prim_audio = load(primary, skip=int(float(get_probe(primary).get(
428
+ 'format',{}).get('duration',300))*0.35), duration=60)
429
+ fp.a_weighted = third_octave(prim_audio, a_weighted=True)
430
+ fp.bark = bark_spectrum(prim_audio)
431
+ high_e = np.mean([fp.third_oct.get(fc,-60) for fc in [4000,5000,6300,8000]])
432
+ mid_e2 = np.mean([fp.third_oct.get(fc,-60) for fc in [500,630,800,1000]])
433
+ fp.clarity_ratio = float(mid_e2 - high_e)
434
+ except: pass
435
+
436
+ try:
437
+ d = {
438
+ 'third_oct': {str(k): v for k,v in fp.third_oct.items()},
439
+ 'a_weighted': {str(k): v for k,v in fp.a_weighted.items()},
440
+ 'bark': [[float(a),float(b)] for a,b in fp.bark],
441
+ 'rms': fp.rms, 'peak': fp.peak, 'crest': fp.crest, 'lra': fp.lra,
442
+ 'tilt_slope': fp.tilt_slope, 'warmth_ratio': fp.warmth_ratio,
443
+ 'clarity_ratio': fp.clarity_ratio,
444
+ 'source': 'v7-multi: ุงู„ุฃุนุฑุงู+ุงู„ูุชุญ+ูุงุทุฑ 1425H',
445
+ 'n_files': len(all_fp_data),
446
+ }
447
+ with open(cache_file,'w') as f: json.dump(d,f)
448
+ except: pass
449
+
450
+ return fp
451
+
452
+
453
+ def _build_single_ref(path: str) -> ReferenceFingerprint:
454
+ """fallback: ุจู†ุงุก fingerprint ู…ู† ู…ู„ู ูˆุงุญุฏ (ู†ูุณ ุทุฑูŠู‚ุฉ v6.5)"""
455
+ probe = get_probe(path)
456
+ total_s = int(float(probe.get('format',{}).get('duration',300)))
457
+ skips = [max(10, int(total_s * r)) for r in [0.10, 0.25, 0.45, 0.65, 0.82]]
458
+ clips = [f'/tmp/ref65_s{i}.wav' for i in range(5)]
459
+ procs = [
460
+ subprocess.Popen(['ffmpeg','-y','-i',path,'-ss',str(sk),'-t','40',
461
+ '-f','s16le','-ac','1','-ar',str(SR),cl,'-loglevel','error'])
462
+ for sk,cl in zip(skips,clips)
463
+ ]
464
+ for p in procs: p.wait()
465
+ segments = []
466
+ for cl in clips:
467
+ try:
468
+ r = subprocess.run(['ffmpeg','-i',cl,'-f','s16le','-ac','1',
469
+ '-ar',str(SR),'-','-loglevel','error'], capture_output=True)
470
+ a = np.frombuffer(r.stdout, np.int16).astype(np.float32)/32768.0
471
+ if len(a) > SR: segments.append(a)
472
+ except: pass
473
+ ref_audio = np.concatenate(segments) if segments else load(path, skip=30, duration=120)
474
+ return build_reference_fingerprint(ref_audio)
475
+
476
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
477
+ # EQ OPTIMIZER โ€” v6.4 (per-tier gain clamp)
478
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
479
+ def optimize_eq_bark(new_b: Dict, ref_fp: ReferenceFingerprint,
480
+ n_nodes: int = 10, use_a_weight: bool = True,
481
+ max_gain_db: float = 6.0,
482
+ shape_only: bool = False) -> List[Tuple]:
483
+ """
484
+ v6.4: max_gain_db is passed per-tier.
485
+ shape_only=True (EXTREME): ูŠูุตุญุญ ุงู„ุดูƒู„ ูู‚ุทุŒ compand ูŠุฑูุน ุงู„ู…ุณุชูˆู‰.
486
+ EXTREME=4dB, VERY_POOR=5dB, else=6dB
487
+ Prevents over-boost that compand then amplifies.
488
+ """
489
+ ref_b = ref_fp.third_oct
490
+ common = sorted([fc for fc in new_b if fc in ref_b and 80<=fc<=14000])
491
+ if len(common) < 4: return []
492
+
493
+ fc_arr = np.array(common, dtype=float)
494
+ new_arr = np.array([new_b[fc] for fc in common])
495
+ ref_arr = np.array([ref_b[fc] for fc in common])
496
+ level_offset = float(np.mean(ref_arr - new_arr))
497
+ target = (ref_arr - new_arr) - level_offset
498
+ # shape_only: ูŠูุตุญุญ ุงู„ุดูƒู„ ูู‚ุท (compand ูŠุฑูุน ุงู„ู…ุณุชูˆู‰)
499
+ if shape_only:
500
+ target = target - float(np.mean(target))
501
+ weights = np.array([max(0.2, 1+A_WEIGHT.get(fc,0)/10) for fc in common]) \
502
+ if use_a_weight else np.ones(len(common))
503
+
504
+ init_freqs = np.logspace(np.log10(80), np.log10(14000), n_nodes)
505
+
506
+ def eq_response(freq_axis, params):
507
+ resp = np.zeros(len(freq_axis))
508
+ for i in range(n_nodes):
509
+ f0 = abs(params[i*3]) + 1e-6
510
+ gain = params[i*3+1]
511
+ Q = max(0.3, abs(params[i*3+2]))
512
+ ratio = freq_axis / f0
513
+ resp += gain / (1 + Q**2*(ratio - 1.0/(ratio+1e-9))**2)
514
+ return resp
515
+
516
+ def objective(params):
517
+ resp = eq_response(fc_arr, params)
518
+ error = np.mean(weights*(resp-target)**2)
519
+ gains = [params[i*3+1] for i in range(n_nodes)]
520
+ smooth = sum(0.015*(gains[i+1]-gains[i])**2 for i in range(len(gains)-1))
521
+ mag = sum(0.003*g**2 for g in gains)
522
+ return error + smooth + mag
523
+
524
+ init_gains = np.interp(np.log10(init_freqs), np.log10(fc_arr), target)
525
+ x0 = []
526
+ for f,g in zip(init_freqs, init_gains):
527
+ x0.extend([float(np.clip(f,80,14000)),
528
+ float(np.clip(g,-max_gain_db,max_gain_db)), 1.0])
529
+
530
+ result = minimize(objective, x0, method='L-BFGS-B',
531
+ bounds=[(80,14000),(-max_gain_db,max_gain_db),(0.3,4.0)]*n_nodes,
532
+ options={'maxiter':400,'ftol':1e-9,'gtol':1e-8})
533
+ nodes = []
534
+ for i in range(n_nodes):
535
+ f0 = abs(result.x[i*3])
536
+ gain = result.x[i*3+1]
537
+ Q = max(0.3, abs(result.x[i*3+2]))
538
+ # v6.4: EXTREME tier โ€” limit mid cuts (compand amplifies 500-1500Hz)
539
+ # over-cutting mid creates warmth ratio imbalance after compand
540
+ if shape_only and gain < 0 and 400 <= f0 <= 1600:
541
+ gain = max(gain, -2.0)
542
+ if abs(gain) >= 0.4:
543
+ nodes.append((round(f0,0), round(gain,2), round(Q,2)))
544
+ return sorted(nodes, key=lambda x: x[0])
545
+
546
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
547
+ # WARMTH CORRECTION โ€” v6.4
548
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
549
+ def warmth_nodes(new_b: Dict, ref_fp: ReferenceFingerprint,
550
+ quality_tier: str = 'GOOD',
551
+ post_compand: bool = False,
552
+ hf_rolloff_hz: float = 20000.0) -> List[Tuple]:
553
+ """
554
+ v6.5: Tilt-based warmth correction (ุฃูƒุซุฑ ุงุณุชู‚ุฑุงุฑุงู‹ ู…ู† bass/mid ratio)
555
+ - ูŠุญุณุจ spectral tilt 200-2000Hz ู„ู„ู…ุฏุฎู„ ูˆุงู„ู…ุฑุฌุน
556
+ - ูŠูุตุญุญ ุงู„ูุฑู‚ ุจู€ shelf ุฃูƒุซุฑ ู…ูˆุณูŠู‚ูŠุฉ
557
+ - post_compand=True โ†’ scale 0.25 (ุฃุฎู ู…ู† v6.4)
558
+ """
559
+ # ุญุณุงุจ tilt 200โ†’2000Hz ู„ู„ู…ุฏุฎู„
560
+ tilt_fc = np.array([fc for fc in CENTERS_31 if 200<=fc<=2000 and fc in new_b
561
+ and fc < hf_rolloff_hz], dtype=float)
562
+ if len(tilt_fc) < 3: return []
563
+ tilt_db = np.array([new_b[fc] for fc in tilt_fc])
564
+ new_tilt = float(np.polyfit(np.log2(tilt_fc/1000.0), tilt_db, 1)[0])
565
+
566
+ # ref warmth_ratio ู‡ูˆ ุงู„ู€ tilt ููŠ v6.5
567
+ ref_tilt = ref_fp.warmth_ratio
568
+ tilt_diff = ref_tilt - new_tilt # ู…ูˆุฌุจ = ุงู„ู…ุฑุฌุน ุฃุฏูุฃ
569
+
570
+ threshold = 1.0 if quality_tier in ('EXTREME','VERY_POOR') else 2.0
571
+ scale = 0.25 if post_compand else 0.40
572
+ max_adj = 2.5 if post_compand else 5.0
573
+
574
+ nodes = []
575
+ if abs(tilt_diff) > threshold:
576
+ # bass shelf ู„ุชุตุญูŠุญ ุงู„ู€ tilt (200Hz shelf)
577
+ bass_adj = float(np.clip(tilt_diff * scale, -max_adj, max_adj))
578
+ if abs(bass_adj) >= 0.4:
579
+ nodes.append((200.0, round(bass_adj, 2), 0.55))
580
+ # mid correction ุฎููŠู ุนูƒุณูŠ
581
+ mid_adj = float(np.clip(-tilt_diff * 0.15, -2.0, 2.0))
582
+ if abs(mid_adj) >= 0.4:
583
+ nodes.append((1000.0, round(mid_adj, 2), 0.80))
584
+ return nodes
585
+
586
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
587
+ # NEW v6.4: TWO-PASS SPECTRAL CORRECTION
588
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
589
+ def spectral_correction_eq(out_b: Dict, ref_fp: ReferenceFingerprint,
590
+ hf_rolloff_hz: float = 20000.0,
591
+ max_correction_db: float = 4.0,
592
+ protect_warmth: bool = True) -> List[Tuple]:
593
+ """
594
+ v6.4: Two-Pass Correction
595
+ - ุดูƒู„ ูู‚ุท (level_offset โ†’ LUFS correction)
596
+ - warmth protection: ู„ุง cuts ุนู„ู‰ 400-1600Hz ุฅุฐุง warmth ู…ู‚ุจูˆู„
597
+ """
598
+ ref_b = ref_fp.third_oct
599
+ ceil = min(10000.0, hf_rolloff_hz * 0.9)
600
+ common = sorted([fc for fc in out_b if fc in ref_b and 80 <= fc <= ceil])
601
+ if len(common) < 4: return []
602
+
603
+ out_arr = np.array([out_b[fc] for fc in common])
604
+ ref_arr = np.array([ref_b[fc] for fc in common])
605
+ level_off = float(np.mean(ref_arr - out_arr))
606
+ shape_gap = (ref_arr - out_arr) - level_off
607
+
608
+ aw = np.array([max(0.3, 1 + A_WEIGHT.get(fc, 0) / 10) for fc in common])
609
+
610
+ # warmth protection โ€” ุชุญู‚ู‚ ู…ู† ุงู„ู€ tilt ุจุฏู„ bass/mid ratio
611
+ tilt_fc_w = np.array([fc for fc in common if 200<=fc<=2000], dtype=float)
612
+ if protect_warmth and len(tilt_fc_w) >= 3:
613
+ tilt_db_w = np.array([out_b[fc] for fc in tilt_fc_w])
614
+ out_tilt_w = float(np.polyfit(np.log2(tilt_fc_w/1000.0), tilt_db_w, 1)[0])
615
+ warmth_ok = abs(out_tilt_w - ref_fp.warmth_ratio) < 4.0
616
+ else:
617
+ warmth_ok = False
618
+
619
+ nodes = []
620
+ prev_gain = 0.0
621
+ for i, fc in enumerate(common):
622
+ raw_g = float(shape_gap[i])
623
+ # v6.6: scale 0.68 (was 0.60), stronger correction
624
+ g = float(np.clip(raw_g * aw[i] * 0.68, -max_correction_db, max_correction_db))
625
+ # v6.6: warmth protection only when error < 3dB (was always when warmth_ok)
626
+ # large errors (>3dB) must be corrected even in warmth zone
627
+ if warmth_ok and 400 <= fc <= 1600 and g < -0.5 and abs(raw_g) < 3.0:
628
+ g = max(g * 0.15, -0.3)
629
+ if abs(g) >= 0.5 and abs(g - prev_gain) < 5.0:
630
+ Q = 1.0 if abs(g) < 2 else 0.70
631
+ nodes.append((float(fc), round(g, 2), Q))
632
+ prev_gain = g
633
+ return nodes
634
+
635
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
636
+ def build_compand_curve(inp_crest: float, inp_lra: float,
637
+ ref_lra: float = 4.0,
638
+ force_extreme: bool = False) -> Tuple:
639
+ """
640
+ v6.4:
641
+ - Uses ref_lra (real measured) instead of TARGET['lra']=4.0
642
+ - EXTREME: attack=20ms/decay=400ms/gain=2.5
643
+ (was attack=4ms/decay=250ms/gain=5.0 โ†’ Crest killed)
644
+ """
645
+ crest_delta = inp_crest - TARGET['crest']
646
+ lra_delta = inp_lra - ref_lra
647
+ score = crest_delta*0.75 + max(0.0, lra_delta)*0.25
648
+
649
+ if force_extreme or score >= 11:
650
+ # v6.4: gentler attack (20ms) preserves transients โ†’ Crest intact
651
+ return ("-90/-68|-45/-20|-28/-9|-14/-4.5|-7/-2.0|-3/-0.6|0/-0.1",
652
+ 2.5, 'EXTREME', 2.0)
653
+ elif score >= 6.5:
654
+ return ("-90/-72|-42/-21|-26/-10.5|-13/-5.2|-6/-2.4|-2.5/-0.8|-0.5/-0.3|0/-0.1",
655
+ 3.2, 'HEAVY', 1.8)
656
+ elif score >= 3.5:
657
+ return ("-90/-78|-40/-25|-22/-12.5|-12/-6.8|-6/-3.5|-2.5/-1.6|-0.8/-0.5|0/-0.2",
658
+ 2.5, 'MEDIUM', 1.4)
659
+ elif score >= 1.5:
660
+ return ("-90/-85|-40/-36|-20/-17|-10/-8.2|-5/-4.1|-2/-1.6|-0.5/-0.4|0/-0.3",
661
+ 1.2, 'LIGHT', 0.9)
662
+ elif score >= 0.5:
663
+ return ("-90/-89|-40/-39|-20/-19.5|-10/-9.8|-4/-3.9|-1/-0.95|0/-0.3",
664
+ 0.4, 'MINIMAL', 0.4)
665
+ else:
666
+ return ("-90/-90|-20/-20|-3/-3|0/0", 0.0, 'BYPASS', 0.0)
667
+
668
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
669
+ # FILTER CHAIN BUILDER โ€” v6.4
670
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
671
+ def build_filter_chain(eq_nodes: List[Tuple],
672
+ compand_pts: str,
673
+ makeup: float,
674
+ hf: str,
675
+ is_mono: bool,
676
+ gain_db: float,
677
+ noise_reduce: bool = True,
678
+ tilt_slope: float = 0.0,
679
+ inp_snr: float = 30.0,
680
+ intensity: str = 'MEDIUM',
681
+ inp_lra: float = 4.0,
682
+ inp_crest: float = 9.45,
683
+ quality_tier: str = 'GOOD',
684
+ hf_rolloff_hz: float = 20000.0,
685
+ src_sr: int = 44100,
686
+ correction_nodes: List[Tuple] = None,
687
+ post_warmth_nodes:List[Tuple] = None,
688
+ ref_lra: float = 2.26) -> str: # v6.6: real ref target
689
+ """
690
+ v6.4 pipeline order:
691
+
692
+ HP(28Hz)
693
+ โ†’ PRE-NR [EXTREME: 22+6, others: adaptive]
694
+ โ†’ Tilt EQ
695
+ โ†’ Main EQ (bark-optimized, clamped per tier)
696
+ โ†’ HF Resurrection [EXTREME: crystalizer i=7 + treble cascade]
697
+ โ†’ POST-NR [light โ€” preserves transients]
698
+ โ†’ LRA gate/expand
699
+ โ†’ Compand [EXTREME: 20ms/400ms/gain=2.5]
700
+ โ†’ Post-compand warmth hook โ† NEW v6.4
701
+ โ†’ Spectral correction EQ โ† NEW v6.4 (two-pass)
702
+ โ†’ Transient limiter
703
+ โ†’ Volume
704
+ โ†’ Stereo (if mono)
705
+ โ†’ Final ceiling
706
+ """
707
+ if correction_nodes is None: correction_nodes = []
708
+ if post_warmth_nodes is None: post_warmth_nodes = []
709
+ parts = []
710
+ is_bypass = (intensity == 'BYPASS')
711
+ is_extreme = (quality_tier == 'EXTREME')
712
+
713
+ # โ”€โ”€ 1. High-pass โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
714
+ parts.append('highpass=f=28:poles=2')
715
+
716
+ # โ”€โ”€ 2. PRE-NR โ€” v6.4: lighter second pass for EXTREME โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
717
+ # Old v6.3: 22+12 โ†’ kills peaks โ†’ Crest collapses
718
+ # New v6.4: 22+6 โ†’ keeps transients
719
+ if noise_reduce and not is_bypass:
720
+ if is_extreme:
721
+ parts.append('afftdn=nr=22:nf=-55:tn=1')
722
+ parts.append('afftdn=nr=6:nf=-65:tn=1') # v6.4: was nr=12
723
+ elif quality_tier == 'VERY_POOR':
724
+ if inp_snr < 8:
725
+ parts.append('afftdn=nr=18:nf=-58:tn=1')
726
+ elif inp_snr < 15:
727
+ parts.append('afftdn=nr=12:nf=-62:tn=1')
728
+ elif quality_tier == 'POOR':
729
+ if inp_snr < 12:
730
+ nr = max(6, min(12, int((20.0-inp_snr)*0.7)))
731
+ parts.append(f'afftdn=nr={nr}:nf=-65:tn=1')
732
+
733
+ # โ”€โ”€ 3. Spectral tilt correction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
734
+ if not is_bypass and abs(tilt_slope) > 1.0:
735
+ db = min(abs(tilt_slope)*0.35, 5.0)
736
+ if tilt_slope > 0:
737
+ parts.append(f'treble=g={db:.1f}:f=8000:width_type=o:width=2')
738
+ else:
739
+ parts.append(f'bass=g={db:.1f}:f=150:width_type=o:width=2')
740
+
741
+ # โ”€โ”€ 4. Main EQ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
742
+ for f0, gain, Q in eq_nodes:
743
+ parts.append(f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={gain}')
744
+
745
+ # โ”€โ”€ 5. HF Resurrection โ€” v6.4: crystalizer i=7 for EXTREME โ”€โ”€
746
+ # Old v6.3: crystalizer i=10 โ†’ 2-5kHz boosted 4-7dB above ref
747
+ # New v6.4: i=7 + multi-shelf treble cascade
748
+ if not is_bypass:
749
+ if is_extreme:
750
+ parts.append('crystalizer=i=7') # v6.4: was i=10
751
+ if hf_rolloff_hz < 8000:
752
+ treble_f = max(2000, int(hf_rolloff_hz*0.65))
753
+ parts.append(f'treble=g=3.5:f={treble_f}:width_type=o:width=2')
754
+ parts.append('treble=g=3.0:f=7000:width_type=o:width=1.5')
755
+ elif quality_tier == 'VERY_POOR':
756
+ parts.append('crystalizer=i=6')
757
+ parts.append('treble=g=2.0:f=6000:width_type=o:width=2')
758
+ elif quality_tier == 'POOR':
759
+ if hf == 'weak': parts.append('crystalizer=i=4')
760
+ elif hf=='absent': parts.append('crystalizer=i=6')
761
+ else: # FAIR / GOOD
762
+ if hf == 'weak': parts.append('crystalizer=i=4')
763
+ elif hf=='absent': parts.append('crystalizer=i=6')
764
+
765
+ # โ”€โ”€ 6. POST-NR โ€” very light to preserve peaks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
766
+ if noise_reduce and not is_bypass:
767
+ if is_extreme:
768
+ parts.append('afftdn=nr=4:nf=-80:tn=0') # v6.4: was nr=10
769
+ elif quality_tier in ('POOR','VERY_POOR'):
770
+ parts.append('afftdn=nr=6:nf=-74:tn=0')
771
+ elif inp_snr < 40:
772
+ nr = max(3, min(10, int((40.0-inp_snr)*0.4)))
773
+ parts.append(f'afftdn=nr={nr}:nf=-74:tn=1')
774
+
775
+ # โ”€โ”€ 7. LRA Control โ€” v6.6: uses ref_lra (real target) not TARGET['lra']=4.0 โ”€โ”€
776
+ lra_deficit = ref_lra - inp_lra # negative = output LRA too wide โ†’ need compand
777
+ if not is_bypass:
778
+ if lra_deficit > 0.5:
779
+ # LRA too narrow โ†’ expand with gate
780
+ ratio = min(4.0 if is_extreme else 3.5,
781
+ 1.0 + lra_deficit*(0.40 if is_extreme else 0.28))
782
+ thr = max(0.010, min(0.045, 0.022+lra_deficit*0.004))
783
+ rel = 1200 if is_extreme else 800
784
+ parts.append(
785
+ f'agate=threshold={thr:.3f}:ratio={ratio:.2f}'
786
+ f':attack=20:release={rel}:makeup=1.0:range=0.06')
787
+ elif lra_deficit < -0.4:
788
+ # v6.6: LRA too wide โ†’ gentle compand to tighten
789
+ # deficit=-0.4โ†’-1.0: mild; -1.0โ†’-2.0: moderate; >-2.0: stronger
790
+ deficit_abs = abs(lra_deficit)
791
+ if deficit_abs < 1.0:
792
+ parts.append('compand=attacks=0.05:decays=1.5'
793
+ ':points=-90/-90|-30/-28.5|-15/-14.2|-6/-5.9|-2/-1.95|0/-0.3:gain=0')
794
+ elif deficit_abs < 2.0:
795
+ parts.append('compand=attacks=0.04:decays=1.0'
796
+ ':points=-90/-90|-30/-28|-15/-14|-6/-5.7|-2/-1.8|0/-0.4:gain=0')
797
+ else:
798
+ parts.append('compand=attacks=0.03:decays=0.8'
799
+ ':points=-90/-90|-30/-27|-15/-13.5|-6/-5.4|-2/-1.6|0/-0.5:gain=0')
800
+
801
+ # โ”€โ”€ 8. Main Compand โ€” v6.4: EXTREME uses 20ms attack โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
802
+ if not is_bypass:
803
+ attack_map = {
804
+ 'MINIMAL':0.050,'LIGHT':0.030,'MEDIUM':0.015,
805
+ 'HEAVY':0.008,
806
+ 'EXTREME':0.020, # v6.4: 20ms (was 4ms โ†’ crushed Crest)
807
+ }
808
+ decay_map = {
809
+ 'MINIMAL':3.0,'LIGHT':2.0,'MEDIUM':1.0,
810
+ 'HEAVY':0.5,
811
+ 'EXTREME':0.40, # v6.4: 400ms (was 250ms)
812
+ }
813
+ attacks = attack_map.get(intensity, 0.015)
814
+ decays = decay_map.get(intensity, 1.0)
815
+ parts.append(
816
+ f'compand=attacks={attacks}:decays={decays}'
817
+ f':points={compand_pts}:gain={makeup}')
818
+
819
+ # โ”€โ”€ 9. Post-compand warmth hook โ€” NEW v6.4 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
820
+ for f0, gain, Q in post_warmth_nodes:
821
+ parts.append(f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={gain}')
822
+
823
+ # โ”€โ”€ 10. Spectral correction EQ โ€” NEW v6.4 (two-pass only) โ”€โ”€โ”€โ”€
824
+ for f0, gain, Q in correction_nodes:
825
+ parts.append(f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={gain}')
826
+
827
+ # โ”€โ”€ 11. Transient limiter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
828
+ if intensity in ('MEDIUM','HEAVY','EXTREME'):
829
+ lim = 0.982 if intensity == 'MEDIUM' else 0.978
830
+ parts.append(f'alimiter=limit={lim}:level=false:attack=5:release=40')
831
+
832
+ # โ”€โ”€ 12. Volume โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
833
+ if abs(gain_db) > 0.05:
834
+ parts.append(f'volume={gain_db:.3f}dB')
835
+
836
+ # โ”€โ”€ 13. Stereo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
837
+ if is_mono:
838
+ parts.append('aformat=channel_layouts=stereo')
839
+
840
+ # โ”€โ”€ 14. Final ceiling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
841
+ ceil = 0.999 if is_bypass else 0.995
842
+ atk = 5 if is_bypass else 1
843
+ parts.append(f'alimiter=limit={ceil}:level=false:attack={atk}:release=20')
844
+
845
+ return ','.join(f'\n {p}' for p in parts)
846
+
847
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
848
+ # QUALITY SCORE โ€” v6.4
849
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
850
+ def quality_score(out_b: Dict, ref_fp: ReferenceFingerprint,
851
+ out_metrics: Dict,
852
+ hf_rolloff_hz: float = 20000.0) -> Tuple[float, Dict]:
853
+ """
854
+ v6.5:
855
+ - LRA target = ref_fp.lra (real)
856
+ - Warmth = spectral tilt 200-2000Hz (stable, shape-normalized)
857
+ - Spectral weights tweaked: spectral 0.45, warmth 0.10
858
+ """
859
+ ref_b = ref_fp.third_oct
860
+ spectral_ceil = min(10000, int(hf_rolloff_hz*0.85))
861
+ common = [fc for fc in out_b if fc in ref_b and 80<=fc<=spectral_ceil]
862
+
863
+ if common:
864
+ out_vals = np.array([out_b[fc] for fc in common])
865
+ ref_vals = np.array([ref_b[fc] for fc in common])
866
+ aw = np.array([max(0.2, 1+A_WEIGHT.get(fc,0)/10) for fc in common])
867
+ level_off = float(np.mean(ref_vals - out_vals))
868
+ shape_diffs = np.abs((ref_vals-out_vals) - level_off)
869
+ w_avg_err = float(np.sum(aw*shape_diffs)/np.sum(aw))
870
+ spectral_score = max(0.0, 100.0 - w_avg_err*5)
871
+ else:
872
+ w_avg_err = 99.0; spectral_score = 0.0
873
+
874
+ lufs_err = abs(out_metrics.get('lufs', -20) - TARGET['lufs'])
875
+ crest_err = abs(out_metrics.get('crest', 15) - TARGET['crest'])
876
+ lra_target = ref_fp.lra if ref_fp.lra > 0 else TARGET['lra']
877
+ lra_err = abs(out_metrics.get('lra', 8) - lra_target)
878
+
879
+ lufs_score = max(0.0, 100.0 - lufs_err * 12)
880
+ crest_score = max(0.0, 100.0 - crest_err * 8)
881
+ lra_score = max(0.0, 100.0 - lra_err * 10)
882
+
883
+ # v6.5: warmth = spectral tilt 200-2000Hz (shape-normalized, stable)
884
+ tilt_fc = np.array([fc for fc in CENTERS_31
885
+ if 200<=fc<=2000 and fc in out_b and fc<hf_rolloff_hz], dtype=float)
886
+ if len(tilt_fc) >= 3:
887
+ tilt_db = np.array([out_b[fc] for fc in tilt_fc])
888
+ out_tilt = float(np.polyfit(np.log2(tilt_fc/1000.0), tilt_db, 1)[0])
889
+ else:
890
+ out_tilt = 0.0
891
+
892
+ tilt_err = abs(out_tilt - ref_fp.warmth_ratio)
893
+ warmth_score = max(0.0, 100.0 - tilt_err * 6)
894
+
895
+ total = (spectral_score*0.43 + lufs_score*0.22 +
896
+ crest_score*0.15 + lra_score*0.10 + warmth_score*0.10)
897
+
898
+ return round(total,1), {
899
+ 'spectral': round(spectral_score, 1),
900
+ 'lufs': round(lufs_score, 1),
901
+ 'crest': round(crest_score, 1),
902
+ 'lra': round(lra_score, 1),
903
+ 'warmth': round(warmth_score, 1),
904
+ 'avg_spectral_error': round(w_avg_err, 2),
905
+ 'warmth_tilt': round(out_tilt, 2),
906
+ 'warmth_ref': round(ref_fp.warmth_ratio, 2),
907
+ 'lra_target': round(lra_target, 2),
908
+ }
909
+
910
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
911
+ # PHASE-1 DIAGNOSIS
912
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
913
+ def phase1_diagnosis(quality_tier, src_sr, src_br, inp_snr,
914
+ hf_rolloff_hz, inp_clips, inp_crest, inp_lra,
915
+ noise_floor_db, hf_deficit, intensity) -> List[str]:
916
+ lines = []
917
+ def D(m=''): lines.append(m); print(m)
918
+ D(f"{'โ”'*66}")
919
+ D(f" โ—‰ PHASE 1 โ€” DIAGNOSIS REPORT")
920
+ D(f"{'โ”'*66}")
921
+ tier_label = {
922
+ 'EXTREME': '๐Ÿ”ด EXTREME โ€” Pixelated Hell (ุฃุณูˆุฃ ุญุงู„ุฉ)',
923
+ 'VERY_POOR': '๐ŸŸ  VERY_POOR โ€” ุชุฏู‡ูˆุฑ ุดุฏูŠุฏ',
924
+ 'POOR': '๐ŸŸก POOR โ€” ุชุฏู‡ูˆุฑ ู…ู„ุญูˆุธ',
925
+ 'FAIR': '๐ŸŸข FAIR โ€” ุฌูˆุฏุฉ ู…ุชูˆุณุทุฉ',
926
+ 'GOOD': 'โœ… GOOD โ€” ุฌูˆุฏุฉ ุฌูŠุฏุฉ',
927
+ }.get(quality_tier, quality_tier)
928
+ D(f" ุฏุฑุฌุฉ ุงู„ุฌูˆุฏุฉ : {tier_label}")
929
+ D(f" SNR ู…ู‚ุฏูŽู‘ุฑ : {inp_snr:.1f} dB {'โš  ุฃู‚ู„ ู…ู† 5 dB โ€” ุถุฌูŠุฌ ูƒุซูŠู' if inp_snr<5 else ''}")
930
+ D(f" HF rolloff : {hf_rolloff_hz/1000:.1f} kHz {'โš  ุตูˆุช ู…ูŠุช ููˆู‚ ู‡ุฐุง ุงู„ุชุฑุฏุฏ' if hf_rolloff_hz<12000 else ''}")
931
+ D(f" HF deficit : {hf_deficit:.1f} dB ู…ู‚ุงุฑู†ุฉ ุจุงู„ู…ุฑุฌุน")
932
+ D(f" Clips (35s) : {inp_clips:,} ุนูŠู†ุฉ")
933
+ D(f" Crest Factor : {inp_crest:.2f} LU (ุงู„ู…ุฑุฌุน: {TARGET['crest']})")
934
+ D(f" LRA : {inp_lra:.2f} LU")
935
+ D(f" Noise Floor : {noise_floor_db:.1f} dBFS")
936
+ D(f" Sample Rate : {src_sr} Hz")
937
+ D(f" Bitrate : {src_br//1000} kbps")
938
+ D(f" Strategy : compand={intensity}")
939
+ D(f"{'โ”'*66}")
940
+ return lines
941
+
942
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
943
+ # MAIN ENGINE โ€” v6.4 TWO-PASS
944
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
945
+ def enhance(input_path: str, output_path: str) -> Dict:
946
+ log = []
947
+ def L(msg=''):
948
+ print(msg); log.append(msg)
949
+
950
+ L(f"โ•”{'โ•'*66}โ•—")
951
+ L(f"โ•‘ Audio Enhancement Engine v6.4 โ€” \"Two-Pass Precision\" โ•‘")
952
+ L(f"โ•‘ ุงู„ู…ุฑุฌุน: ุงู„ุดูŠุฎ ูŠุงุณุฑ ุงู„ุฏูˆุณุฑูŠ โ€” ุณูˆุฑุฉ ุงู„ุฃุนุฑุงู โ€” 1425H โ•‘")
953
+ L(f"โ•š{'โ•'*66}โ•")
954
+ L(f" ุงู„ู…ู„ู: {os.path.basename(input_path)}")
955
+ L()
956
+
957
+ # โ”€โ”€ [ูก] Reference Fingerprint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
958
+ L("[ูก/ูจ] ุจุตู…ุฉ ุงู„ู…ุฑุฌุน 1425 (cache ุฅุฐุง ู…ุชุงุญ)...")
959
+ ref_fp = get_reference_fingerprint()
960
+ L(f" โœ“ RMS={ref_fp.rms:.2f} Crest={ref_fp.crest:.2f} LRA={ref_fp.lra:.2f}")
961
+ L(f" โœ“ Warmth={ref_fp.warmth_ratio:.2f} Tilt={ref_fp.tilt_slope:.2f} dB/oct")
962
+
963
+ # โ”€โ”€ [ูข] File Analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
964
+ L(f"\n[ูข/ูจ] ุชุญู„ูŠู„ ุงู„ู…ู„ู (35 ุซุงู†ูŠุฉ โ€” seek ู…ุจุงุดุฑ)...")
965
+ probe = get_probe(input_path)
966
+ stream = probe.get('streams',[{}])[0]
967
+ is_mono = stream.get('channels',2) == 1
968
+ src_sr = int(stream.get('sample_rate',44100))
969
+ src_br = int(stream.get('bit_rate',128000))
970
+ total_s = int(float(probe.get('format',{}).get('duration',300)))
971
+
972
+ _n_ch = '1' if is_mono else '2'
973
+ _pts = [
974
+ max(10, total_s//10),
975
+ max(30, total_s//4),
976
+ max(60, total_s//2),
977
+ max(90, int(total_s*0.72)),
978
+ max(120, int(total_s*0.90)),
979
+ ]
980
+ for i in range(1,len(_pts)):
981
+ if _pts[i]-_pts[i-1] < 35: _pts[i] = _pts[i-1]+35
982
+ _clip_files = [f'/tmp/v64_c{i}.wav' for i in range(5)]
983
+ _early_procs = [
984
+ subprocess.Popen(['ffmpeg','-y','-i',input_path,
985
+ '-ss',str(sk),'-t','25',
986
+ '-ar','48000','-ac',_n_ch,cl,'-loglevel','error'])
987
+ for sk,cl in zip(_pts,_clip_files)
988
+ ]
989
+
990
+ skip_s = min(30, total_s//4)
991
+ inp = load(input_path, skip=skip_s, duration=35)
992
+ inp_b = third_octave(inp, a_weighted=False)
993
+ inp_clips = count_clips(inp)
994
+ inp_rms = rms_db(inp)
995
+ inp_peak = peak_db(inp)
996
+ inp_crest = crest_factor(inp)
997
+ inp_lra = lra_estimate(inp)
998
+ inp_snr = snr_estimate(inp)
999
+ inp_hf = hf_status(inp_b)
1000
+
1001
+ inp_fc = np.array([fc for fc in CENTERS_31 if 100<=fc<=10000 and fc in inp_b])
1002
+ inp_db = np.array([inp_b[fc] for fc in inp_fc])
1003
+ inp_tilt = float(np.polyfit(np.log2(inp_fc/1000.0), inp_db, 1)[0]) if len(inp_fc)>=3 else 0.0
1004
+ tilt_correction = ref_fp.tilt_slope - inp_tilt
1005
+
1006
+ # Quality tier classification
1007
+ hf_freqs = [fc for fc in inp_b if fc >= 8000]
1008
+ hf_avg = float(np.mean([inp_b[fc] for fc in hf_freqs])) if hf_freqs else -80.0
1009
+ ref_hf = float(np.mean([ref_fp.third_oct.get(fc,-60) for fc in hf_freqs])) if hf_freqs else -40.0
1010
+ hf_deficit = ref_hf - hf_avg
1011
+ sorted_amp = np.sort(np.abs(inp))
1012
+ noise_floor_db = float(20*np.log10(np.mean(sorted_amp[:max(1,len(sorted_amp)//20)])+1e-9))
1013
+
1014
+ if inp_snr < 5 or src_br < 32000 or hf_deficit > 45:
1015
+ quality_tier = 'EXTREME'
1016
+ elif inp_snr < 6 or hf_deficit > 35:
1017
+ quality_tier = 'VERY_POOR'
1018
+ elif inp_snr < 12 or hf_deficit > 20:
1019
+ quality_tier = 'POOR'
1020
+ elif inp_snr < 20 or hf_deficit > 10:
1021
+ quality_tier = 'FAIR'
1022
+ else:
1023
+ quality_tier = 'GOOD'
1024
+
1025
+ L(f" RMS={inp_rms:.2f} Peak={inp_peak:.2f} Crest={inp_crest:.2f} LRA={inp_lra:.2f} SNR={inp_snr:.1f}")
1026
+ L(f" Clips={inp_clips} HF={inp_hf.upper()} Tilt={inp_tilt:.2f} Mono={is_mono} SR={src_sr} BR={src_br//1000}k")
1027
+ L(f" ุฌูˆุฏุฉ: {quality_tier} HF-deficit={hf_deficit:.1f} dB NoiseFloor={noise_floor_db:.1f} dBFS")
1028
+
1029
+ # HF rolloff detection
1030
+ hf_rolloff_hz = detect_hf_rolloff(inp_b, drop_threshold=12.0)
1031
+ hf_rolloff_hz = max(hf_rolloff_hz, 2000.0)
1032
+
1033
+ diag_lines = phase1_diagnosis(
1034
+ quality_tier, src_sr, src_br, inp_snr, hf_rolloff_hz,
1035
+ inp_clips, inp_crest, inp_lra, noise_floor_db, hf_deficit, '?')
1036
+ log.extend(diag_lines)
1037
+
1038
+ # โ”€โ”€ [ูฃ] De-Clip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1039
+ if inp_clips > 0:
1040
+ L(f"\n[ูฃ/ูจ] ุฅุตู„ุงุญ {inp_clips:,} ุนูŠู†ุฉ (Cubic Spline ctx=40)...")
1041
+ _, fixed = declip(inp, threshold=0.98)
1042
+ L(f" โœ“ {fixed:,} ุนูŠู†ุฉ โ€” ูŠูุทุจูŽู‘ู‚ ุนู„ู‰ ุงู„ูƒุงู…ู„ ุนุจุฑ ffmpeg")
1043
+ else:
1044
+ L(f"\n[ูฃ/ูจ] ู„ุง ุชูˆุฌุฏ ุนูŠู†ุงุช ู…ู‚ุทูˆุนุฉ โœ“")
1045
+
1046
+ # โ”€โ”€ [ูค] EQ Optimization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1047
+ L(f"\n[ูค/ูจ] EQ Optimizer (Bark-Scale + A-Weighting)...")
1048
+ # v6.4: EQ clamp tighter for EXTREME โ€” compand amplifies everything
1049
+ max_eq_db = (4.0 if quality_tier == 'EXTREME'
1050
+ else 5.0 if quality_tier == 'VERY_POOR'
1051
+ else 6.0)
1052
+ n_nodes = 12 if quality_tier == 'EXTREME' else 10
1053
+ # v6.4: EXTREME tier โ†’ shape_only (compand ูŠุฑูุน ุงู„ู…ุณุชูˆู‰ ู„ุง EQ)
1054
+ shape_only = (quality_tier == 'EXTREME')
1055
+ eq_nodes = optimize_eq_bark(inp_b, ref_fp, n_nodes=n_nodes,
1056
+ use_a_weight=True, max_gain_db=max_eq_db,
1057
+ shape_only=shape_only)
1058
+
1059
+ w_corr = warmth_nodes(inp_b, ref_fp, quality_tier=quality_tier,
1060
+ hf_rolloff_hz=hf_rolloff_hz)
1061
+ eq_nodes = sorted(eq_nodes+w_corr, key=lambda x: x[0])
1062
+ eq_nodes = merge_eq_nodes(eq_nodes, min_hz_gap=60.0)
1063
+ eq_nodes = [(f, float(np.clip(g,-max_eq_db,max_eq_db)), q) for f,g,q in eq_nodes]
1064
+
1065
+ # HF-aware: no boost above rolloff
1066
+ eq_out = []; removed = 0
1067
+ for f0,g,q in eq_nodes:
1068
+ if f0 >= hf_rolloff_hz and g > 0:
1069
+ if f0 < hf_rolloff_hz*1.5:
1070
+ eq_out.append((f0, min(-0.5, g*-0.3), q))
1071
+ removed += 1
1072
+ else:
1073
+ eq_out.append((f0, g, q))
1074
+ eq_nodes = eq_out
1075
+ if removed: L(f" โš  HF rolloff ุนู†ุฏ {hf_rolloff_hz:.0f} Hz โ€” ุฃูู„ุบูŠ {removed} boost ููˆู‚ู‡")
1076
+
1077
+ L(f" {len(eq_nodes)} ู†ู‚ุทุฉ EQ (maxยฑ{max_eq_db}dB):")
1078
+ for f0,g,Q in eq_nodes:
1079
+ L(f" {f0:>7.0f} Hz {'โ–ฒ' if g>0 else 'โ–ผ'} {abs(g):.2f} dB Q={Q:.2f}")
1080
+
1081
+ # โ”€โ”€ [ูฅ] Compand Curve โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1082
+ L(f"\n[ูฅ/ูจ] ุญุณุงุจ ู…ู†ุญู†ู‰ ุงู„ุถุบุท...")
1083
+ # v6.6: ู„ุง force_extreme ุฅุฐุง LRA ุฃุตู„ุงู‹ ุชุญุช ุงู„ู‡ุฏู (ูŠุณุญู‚ ุงู„ุฏูŠู†ุงู…ูŠูƒ)
1084
+ force_extreme = (quality_tier == 'EXTREME') and (inp_lra >= ref_fp.lra * 0.85)
1085
+ compand_pts, makeup, intensity, calib_offset = build_compand_curve(
1086
+ inp_crest, inp_lra, ref_lra=ref_fp.lra, force_extreme=force_extreme)
1087
+ L(f" ุดุฏุฉ: {intensity} Makeup=+{makeup:.1f} dB Force-Extreme={force_extreme}")
1088
+ # v6.5: NR ู…ุนุทู‘ู„ ุฅุฐุง:
1089
+ # - ุงู„ู…ุตุฏุฑ < 96kbps (MP3 artifacts ุชุฒุฏุงุฏ ู…ุน NR)
1090
+ # - ุฃูˆ SNR < 8 dB (ุถูˆุถุงุก ูƒุซูŠูุฉ ุฌุฏุงู‹ โ†’ musical noise ู…ุถู…ูˆู†)
1091
+ use_nr = (src_br >= 96000) and (inp_snr >= 8.0)
1092
+
1093
+ # โ”€โ”€ [ูฆ] LUFS 5-point sampling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1094
+ L(f"\n[ูฆ/ูจ] Pipeline โ€” LUFS 5-point...")
1095
+ for p in _early_procs: p.wait()
1096
+ L(f" โœ“ 5 clips ุฌุงู‡ุฒุฉ")
1097
+
1098
+ chain_zero = build_filter_chain(
1099
+ eq_nodes, compand_pts, makeup, inp_hf, False, 0.0,
1100
+ noise_reduce=use_nr, tilt_slope=tilt_correction, inp_snr=inp_snr,
1101
+ intensity=intensity, inp_lra=inp_lra, inp_crest=inp_crest,
1102
+ quality_tier=quality_tier, hf_rolloff_hz=hf_rolloff_hz, src_sr=src_sr,
1103
+ ref_lra=ref_fp.lra
1104
+ ).replace('\n','').replace(' ','')
1105
+
1106
+ lufs_procs = [
1107
+ subprocess.Popen(
1108
+ ['ffmpeg','-y','-i',cl,'-af',chain_zero+',ebur128=peak=true',
1109
+ '-f','null','-','-loglevel','info'],
1110
+ stderr=subprocess.PIPE, stdout=subprocess.PIPE)
1111
+ for cl in _clip_files
1112
+ ]
1113
+ lufs_vals = []
1114
+ for p in lufs_procs:
1115
+ _,err = p.communicate()
1116
+ for line in err.decode().split('\n'):
1117
+ s = line.strip()
1118
+ if s.startswith('I:') and 'LUFS' in s and 'LRA' not in s:
1119
+ try: lufs_vals.append(float(s.split('I:')[1].strip().split()[0])); break
1120
+ except: pass
1121
+
1122
+ if lufs_vals and len(lufs_vals) >= 3:
1123
+ wts = [0.10,0.25,0.30,0.25,0.10][:len(lufs_vals)]
1124
+ wts = [w/sum(wts) for w in wts]
1125
+ l0 = float(np.average(lufs_vals, weights=wts))
1126
+ else:
1127
+ l0 = float(np.mean(lufs_vals)) if lufs_vals else -12.0
1128
+
1129
+ L(f" LUFS 5-pt: [{', '.join(f'{v:.2f}' for v in lufs_vals)}]")
1130
+ gain_needed = float(np.clip(TARGET['lufs']-l0-calib_offset, -18, 12))
1131
+ L(f" avg={l0:.2f} calib={calib_offset:.1f} gain_needed={gain_needed:+.2f} dB")
1132
+
1133
+ # โ”€โ”€ [ูง] PASS 1: Full render to WAV โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1134
+ L(f"\n[ูง/ูจ] Pass 1 โ€” ุฑู†ุฏุฑ WAV + ู‚ูŠุงุณ ุทูŠู...")
1135
+ tmp_wav1 = '/tmp/v64_pass1.wav'
1136
+ n_ch = 1 if is_mono else 2
1137
+
1138
+ final_chain1 = build_filter_chain(
1139
+ eq_nodes, compand_pts, makeup, inp_hf, False, gain_needed,
1140
+ noise_reduce=use_nr, tilt_slope=tilt_correction, inp_snr=inp_snr,
1141
+ intensity=intensity, inp_lra=inp_lra, inp_crest=inp_crest,
1142
+ quality_tier=quality_tier, hf_rolloff_hz=hf_rolloff_hz, src_sr=src_sr,
1143
+ ref_lra=ref_fp.lra
1144
+ ).replace('\n','').replace(' ','')
1145
+
1146
+ r_wav1 = subprocess.run(
1147
+ ['ffmpeg','-y','-i',input_path,'-af',
1148
+ final_chain1+',ebur128=peak=true',
1149
+ '-ar','48000','-ac',str(n_ch), tmp_wav1,'-loglevel','info'],
1150
+ capture_output=True, text=True)
1151
+
1152
+ actual_lufs1 = -99.0
1153
+ for line in r_wav1.stderr.split('\n'):
1154
+ s = line.strip()
1155
+ if s.startswith('I:') and 'LUFS' in s and 'LRA' not in s:
1156
+ try: actual_lufs1 = float(s.split('I:')[1].strip().split()[0]); break
1157
+ except: pass
1158
+ if actual_lufs1 == -99.0: actual_lufs1 = gain_needed + l0
1159
+
1160
+ # Measure Pass-1 spectrum
1161
+ out1_audio = load(tmp_wav1, skip=skip_s, duration=35)
1162
+ out1_b = third_octave(out1_audio)
1163
+ out1_metrics = {
1164
+ 'lufs': actual_lufs1,
1165
+ 'rms': rms_db(out1_audio),
1166
+ 'crest': crest_factor(out1_audio),
1167
+ 'lra': lra_estimate(out1_audio),
1168
+ }
1169
+ score1, bd1 = quality_score(out1_b, ref_fp, out1_metrics, hf_rolloff_hz)
1170
+ L(f" Pass1 LUFS={actual_lufs1:.2f} RMS={out1_metrics['rms']:.2f}"
1171
+ f" Crest={out1_metrics['crest']:.2f} LRA={out1_metrics['lra']:.2f}")
1172
+ L(f" Pass1 Score={score1}/100 err=ยฑ{bd1['avg_spectral_error']}dB")
1173
+
1174
+ # Two-pass: compute correction EQ from Pass-1 spectrum
1175
+ # v6.4: shift by lufs_corr so correction targets the FINAL output level
1176
+ lufs_corr = TARGET['lufs'] - actual_lufs1
1177
+ out1_b_final = {fc: v+lufs_corr for fc,v in out1_b.items()}
1178
+ # v6.6: higher max_correction for good-quality sources, lower for EXTREME
1179
+ max_corr_db = (3.0 if quality_tier == 'EXTREME'
1180
+ else 3.5 if quality_tier == 'VERY_POOR'
1181
+ else 4.5)
1182
+ corr_nodes = spectral_correction_eq(out1_b_final, ref_fp,
1183
+ hf_rolloff_hz=hf_rolloff_hz,
1184
+ max_correction_db=max_corr_db)
1185
+ # Post-compand warmth correction (lighter scale, from final-level spectrum)
1186
+ post_w_nodes = warmth_nodes(out1_b_final, ref_fp, quality_tier=quality_tier,
1187
+ post_compand=True, hf_rolloff_hz=hf_rolloff_hz)
1188
+
1189
+ # v6.5: ุชุญู‚ู‚ ู…ู† ุงู„ู€ warmth gap ุจู…ู‚ูŠุงุณ tilt (ุฃูƒุซุฑ ุงุณุชู‚ุฑุงุฑุงู‹)
1190
+ tilt_fc_1 = np.array([fc for fc in CENTERS_31
1191
+ if 200<=fc<=2000 and fc in out1_b_final], dtype=float)
1192
+ if len(tilt_fc_1) >= 3:
1193
+ tilt_db_1 = np.array([out1_b_final[fc] for fc in tilt_fc_1])
1194
+ warmth_1 = float(np.polyfit(np.log2(tilt_fc_1/1000.0), tilt_db_1, 1)[0])
1195
+ else:
1196
+ warmth_1 = 0.0
1197
+ warmth_gap = ref_fp.warmth_ratio - warmth_1
1198
+ if abs(warmth_gap) > 5.0: # ุนุชุจุฉ ุฃุนู„ู‰ (ูƒุงู†ุช 3.0) โ€” ู†ุชุฏุฎู„ ูู‚ุท ุนู†ุฏ ูุฑู‚ ูƒุจูŠุฑ
1199
+ tilt_adj = float(np.clip(warmth_gap * 0.3, -2.0, 2.0))
1200
+ post_w_nodes.append((200.0, round(tilt_adj, 2), 0.55))
1201
+ post_w_nodes.append((700.0, round(-tilt_adj*0.3, 2), 0.80))
1202
+ L(f" โš  Tilt gap={warmth_gap:+.2f} โ†’ shelf adj={tilt_adj:+.2f}dB")
1203
+
1204
+ if corr_nodes:
1205
+ L(f" Correction EQ ({len(corr_nodes)} ู†ู‚ุทุฉ):")
1206
+ for f0,g,Q in corr_nodes:
1207
+ L(f" {f0:>7.0f} Hz {'โ–ฒ' if g>0 else 'โ–ผ'} {abs(g):.2f} dB")
1208
+
1209
+ # โ”€โ”€ PASS 2: MP3 encode with LUFS + spectral correction โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1210
+ L(f" Pass 2 โ€” MP3 (LUFS corr={lufs_corr:+.2f}dB, {len(corr_nodes)} corr EQ)...")
1211
+
1212
+ corr_af = ''
1213
+ if corr_nodes:
1214
+ corr_af += ',' + ','.join(
1215
+ f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={g}'
1216
+ for f0,g,Q in corr_nodes)
1217
+ if post_w_nodes:
1218
+ corr_af += ',' + ','.join(
1219
+ f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={g}'
1220
+ for f0,g,Q in post_w_nodes)
1221
+
1222
+ # v7: ุฅุถุงูุฉ SPECTRAL_BIAS pre-correction ููŠ Pass2
1223
+ bias_af = ''
1224
+ for fc, bias_db in SPECTRAL_BIAS.items():
1225
+ if fc > hf_rolloff_hz * 0.9: continue
1226
+ g = round(-bias_db * BIAS_SCALE, 2) # ุนูƒุณ ุงู„ุงู†ุญูŠุงุฒ ุจู€ 35%
1227
+ if abs(g) >= 0.3:
1228
+ Q = 0.70 if abs(g) > 1.5 else 1.0
1229
+ bias_af += f',equalizer=f={fc}:width_type=q:width={Q}:g={g}'
1230
+
1231
+ af_enc = (f'volume={lufs_corr:.3f}dB'
1232
+ + corr_af
1233
+ + bias_af
1234
+ + ',alimiter=limit=0.995:level=false:attack=1:release=15')
1235
+
1236
+ tmp_p2 = '/tmp/v7_pass2.mp3'
1237
+ subprocess.run(
1238
+ ['ffmpeg','-y','-i',tmp_wav1,'-af',af_enc,
1239
+ '-b:a','320k','-ar','48000','-ac',str(n_ch),
1240
+ tmp_p2,'-loglevel','error'],
1241
+ capture_output=True)
1242
+
1243
+ # โ”€โ”€ PASS 3: LRA + RMS feedback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1244
+ L(f" Pass 3 โ€” LRA/RMS feedback...")
1245
+ p2_audio = load(tmp_p2, skip=skip_s, duration=35)
1246
+ p2_lra = lra_estimate(p2_audio)
1247
+ p2_rms = rms_db(p2_audio)
1248
+ p2_b = third_octave(p2_audio)
1249
+ p2_metrics = {'lufs': TARGET['lufs'], 'rms': p2_rms,
1250
+ 'crest': crest_factor(p2_audio), 'lra': p2_lra}
1251
+ score2, _ = quality_score(p2_b, ref_fp, p2_metrics, hf_rolloff_hz)
1252
+ L(f" Pass2: LRA={p2_lra:.2f} (target={ref_fp.lra:.2f}) RMS={p2_rms:.2f} (target={ref_fp.rms:.2f}) Score={score2}")
1253
+
1254
+ # LRA compand: ุฅุฐุง LRA > target + 0.15 ู†ุถุบุทู‡
1255
+ lra_gap_p2 = p2_lra - ref_fp.lra
1256
+ p3_af_parts = []
1257
+
1258
+ if lra_gap_p2 > 0.15:
1259
+ # v7: agate ุจุฏู„ compand โ€” ูŠุถูŠู‘ู‚ LRA ุจุฏูˆู† ุฑูุน Crest
1260
+ # agate ูŠุฎูุถ ุงู„ู€ quiet passages ูู‚ุท โ†’ ูŠูุถูŠู‘ู‚ ุงู„ู†ุทุงู‚ ุงู„ุฏูŠู†ุงู…ูŠูƒูŠ
1261
+ if lra_gap_p2 < 0.5:
1262
+ thr = 0.018; ratio = 1.8; rel = 600
1263
+ elif lra_gap_p2 < 1.0:
1264
+ thr = 0.025; ratio = 2.2; rel = 500
1265
+ else:
1266
+ thr = 0.032; ratio = 2.8; rel = 400
1267
+ p3_af_parts.append(
1268
+ f'agate=threshold={thr:.3f}:ratio={ratio:.1f}'
1269
+ f':attack=15:release={rel}:makeup=1.0:range=0.08')
1270
+ L(f" LRA gap={lra_gap_p2:+.2f} โ†’ agate thr={thr} ratio={ratio}")
1271
+
1272
+ # RMS feedback: ู†ูุนุฏู‘ู„ ุงู„ู€ gain ู„ุถุจุท RMS ุจุฏู‚ุฉ
1273
+ # v7 fix: ุงู„ู€ LRA compand ูŠุฎูุถ ุงู„ู€ RMS ู‚ู„ูŠู„ุงู‹ โ€” ู†ูุนูˆู‘ุถ ุฐู„ูƒ
1274
+ compand_rms_loss = lra_gap_p2 * 0.18 if lra_gap_p2 > 0.15 else 0.0
1275
+ rms_gap = ref_fp.rms - p2_rms # negative = output louder than ref
1276
+ rms_gain_adj = float(np.clip((rms_gap + compand_rms_loss) * 0.5, -1.5, 1.5))
1277
+ if abs(rms_gain_adj) > 0.1:
1278
+ p3_af_parts.append(f'volume={rms_gain_adj:.3f}dB')
1279
+ L(f" RMS gap={rms_gap:+.2f} (compand_lossโ‰ˆ{compand_rms_loss:.2f}) โ†’ gain adj={rms_gain_adj:+.3f}dB")
1280
+
1281
+ # Second spectral correction pass ุนู„ู‰ Pass2 output
1282
+ p2_b_lnorm = {fc: v for fc,v in p2_b.items()} # already at target LUFS
1283
+ corr2_nodes = spectral_correction_eq(p2_b_lnorm, ref_fp,
1284
+ hf_rolloff_hz=hf_rolloff_hz,
1285
+ max_correction_db=2.5) # ุฃุฎู ู…ู† Pass2
1286
+ if corr2_nodes:
1287
+ p3_af_parts.extend(
1288
+ f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={g}'
1289
+ for f0,g,Q in corr2_nodes)
1290
+ L(f" Corr2 EQ ({len(corr2_nodes)} ู†ู‚ุทุฉ)")
1291
+
1292
+ p3_af_parts.append('alimiter=limit=0.995:level=false:attack=1:release=15')
1293
+ tmp_out = '/tmp/v7_out.mp3'
1294
+
1295
+ if len(p3_af_parts) > 1: # ู‡ู†ุงูƒ ุดูŠุก ูŠูุทุจูŽู‘ู‚ ูุนู„ุงู‹
1296
+ af_p3 = ','.join(p3_af_parts)
1297
+ subprocess.run(
1298
+ ['ffmpeg','-y','-i',tmp_p2,'-af',af_p3,
1299
+ '-b:a','320k','-ar','48000','-ac',str(n_ch),
1300
+ tmp_out,'-loglevel','error'],
1301
+ capture_output=True)
1302
+ else:
1303
+ shutil.copy(tmp_p2, tmp_out)
1304
+ L(f" Pass3: ู„ุง ุชุนุฏูŠู„ุงุช ู…ุทู„ูˆุจุฉ โ€” Pass2 ู…ู…ุชุงุฒ")
1305
+
1306
+ # โ”€โ”€ [ูจ] Final evaluation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1307
+ L(f"\n[ูจ/ูจ] ุชู‚ูŠูŠู… ุงู„ู†ุชูŠุฌุฉ ุงู„ู†ู‡ุงุฆูŠุฉ...")
1308
+ out_final = load(tmp_out, skip=skip_s, duration=35)
1309
+ out_b = third_octave(out_final)
1310
+ out_metrics = {
1311
+ 'lufs': TARGET['lufs'],
1312
+ 'rms': rms_db(out_final),
1313
+ 'crest': crest_factor(out_final),
1314
+ 'lra': lra_estimate(out_final),
1315
+ }
1316
+ score, breakdown = quality_score(out_b, ref_fp, out_metrics, hf_rolloff_hz)
1317
+ shutil.copy(tmp_out, output_path)
1318
+
1319
+ L(f"\n{'โ•'*66}")
1320
+ L(f" PHASE 3 โ€” BEFORE โ†’ PASS1 โ†’ PASS2 โ†’ PASS3 โ†’ TARGET")
1321
+ L(f"{'โ•'*66}")
1322
+ L(f" {'ุงู„ู…ู‚ูŠุงุณ':<22} {'ุงู„ู…ุฏุฎู„':>7} {'Pass1':>7} {'Pass2':>7} {'Final':>7} {'ุงู„ู‡ุฏู':>7}")
1323
+ L(f" {'โ”€'*62}")
1324
+ L(f" {'LUFS':<22} {'N/A':>7} {actual_lufs1:>7.2f} {TARGET['lufs']:>7.2f} {out_metrics['lufs']:>7.2f} {TARGET['lufs']:>7.2f}")
1325
+ L(f" {'RMS (dBFS)':<22} {inp_rms:>7.2f} {out1_metrics['rms']:>7.2f} {p2_rms:>7.2f} {out_metrics['rms']:>7.2f} {ref_fp.rms:>7.2f}")
1326
+ L(f" {'Crest Factor (LU)':<22} {inp_crest:>7.2f} {out1_metrics['crest']:>7.2f} {p2_metrics['crest']:>7.2f} {out_metrics['crest']:>7.2f} {ref_fp.crest:>7.2f}")
1327
+ L(f" {'LRA (LU)':<22} {inp_lra:>7.2f} {out1_metrics['lra']:>7.2f} {p2_lra:>7.2f} {out_metrics['lra']:>7.2f} {ref_fp.lra:>7.2f}*")
1328
+ L(f" {'SR (Hz)':<22} {src_sr:>7} {'48000':>7} {'48000':>7} {'48000':>7} {'48000':>7}")
1329
+ L(f" {'Bitrate':<22} {src_br//1000:>6}k {'320k':>7} {'320k':>7} {'320k':>7} {'320k':>7}")
1330
+ L(f" {'HF Rolloff (kHz)':<22} {hf_rolloff_hz/1000:>7.1f} {'20.0':>7} {'20.0':>7} {'20.0':>7} {'20.0':>7}")
1331
+ L(f" {'Clips (35s)':<22} {inp_clips:>7,} {'0':>7} {'0':>7} {'0':>7} {'0':>7}")
1332
+ L(f" * LRA target = ref_fp.lra ุงู„ุญู‚ูŠู‚ูŠ (ู„ูŠุณ 4.0 ุงู„ุงูุชุฑุงุถูŠ)")
1333
+ L()
1334
+ L(f" โ˜… ู†ู‚ุทุฉ ุงู„ุฌูˆุฏุฉ: Pass1={score1}/100 โ†’ Pass2={score2}/100 โ†’ Final={score}/100"
1335
+ f" {'โœ… PASS' if score>=97 else ('โœ… PASS' if score>=95 else ('โœ“ PASS' if score>=90 else 'โš  ุฏูˆู† ุงู„ู‡ุฏู 90'))}")
1336
+ L(f" โ€ข ุงู„ุทูŠู (A-weighted): {breakdown['spectral']}/100")
1337
+ L(f" โ€ข LUFS: {breakdown['lufs']}/100")
1338
+ L(f" โ€ข Crest Factor: {breakdown['crest']}/100")
1339
+ L(f" โ€ข LRA: {breakdown['lra']}/100 (target={ref_fp.lra:.2f})")
1340
+ L(f" โ€ข ุฏูุก ุงู„ุตูˆุช (tilt): {breakdown['warmth']}/100 (out={breakdown['warmth_tilt']:.2f} ref={breakdown['warmth_ref']:.2f} dB/oct)")
1341
+ L(f" โ€ข ุฎุทุฃ ุทูŠููŠ ู…ุชูˆุณุท: ยฑ{breakdown['avg_spectral_error']} dB")
1342
+ L()
1343
+ L(f" โœ… ุชู… ุงู„ุญูุธ: {output_path}")
1344
+ L(f"{'โ•'*66}\n")
1345
+
1346
+ return {
1347
+ 'score': score,
1348
+ 'score_pass1': score1,
1349
+ 'breakdown': breakdown,
1350
+ 'final_metrics': out_metrics,
1351
+ 'input_metrics': {'rms':inp_rms,'crest':inp_crest,'lra':inp_lra,'snr':inp_snr},
1352
+ 'eq_nodes': eq_nodes,
1353
+ 'correction_nodes': corr_nodes,
1354
+ 'quality_tier': quality_tier,
1355
+ 'hf_rolloff_hz': hf_rolloff_hz,
1356
+ 'ref_lra': ref_fp.lra,
1357
+ 'log': log,
1358
+ }
1359
+
1360
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1361
+ # CHUNKED PROCESSOR โ€” v6.4
1362
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1363
+ def enhance_chunked(input_path: str, output_path: str,
1364
+ chunk_minutes: int = 20) -> dict:
1365
+ probe = get_probe(input_path)
1366
+ total_s = int(float(probe.get('format',{}).get('duration',300)))
1367
+ if total_s <= chunk_minutes*60+60:
1368
+ return enhance(input_path, output_path)
1369
+
1370
+ print(f" ๐Ÿ“ฆ ู…ู„ู ุทูˆูŠู„ ({total_s//60}ุฏ) โ€” v6.4 chunked two-pass")
1371
+ mid_s = total_s // 2
1372
+ sample = load(input_path, skip=mid_s, duration=60)
1373
+ ref_fp = get_reference_fingerprint()
1374
+ inp_b = third_octave(sample)
1375
+ inp_crest = crest_factor(sample); inp_lra = lra_estimate(sample)
1376
+ inp_snr = snr_estimate(sample); inp_hf = hf_status(inp_b)
1377
+ stream = probe.get('streams',[{}])[0]
1378
+ src_br = int(stream.get('bit_rate',128000))
1379
+ src_sr = int(stream.get('sample_rate',44100))
1380
+ hf_freqs = [fc for fc in inp_b if fc >= 8000]
1381
+ hf_avg = float(np.mean([inp_b[fc] for fc in hf_freqs])) if hf_freqs else -80.0
1382
+ ref_hf = float(np.mean([ref_fp.third_oct.get(fc,-60) for fc in hf_freqs])) if hf_freqs else -40.0
1383
+ hf_deficit= ref_hf - hf_avg
1384
+ if inp_snr<5 or src_br<32000 or hf_deficit>45: quality_tier='EXTREME'
1385
+ elif inp_snr<6 or hf_deficit>35: quality_tier='VERY_POOR'
1386
+ elif inp_snr<12 or hf_deficit>20: quality_tier='POOR'
1387
+ elif inp_snr<20 or hf_deficit>10: quality_tier='FAIR'
1388
+ else: quality_tier='GOOD'
1389
+ hf_rolloff_hz = max(detect_hf_rolloff(inp_b), 2000.0)
1390
+ max_eq_db = 4.0 if quality_tier=='EXTREME' else 5.0 if quality_tier=='VERY_POOR' else 6.0
1391
+ n_nodes = 12 if quality_tier=='EXTREME' else 10
1392
+ eq_nodes = optimize_eq_bark(inp_b, ref_fp, n_nodes=n_nodes, max_gain_db=max_eq_db)
1393
+ w_corr = warmth_nodes(inp_b, ref_fp, quality_tier=quality_tier, hf_rolloff_hz=hf_rolloff_hz)
1394
+ eq_nodes = sorted(merge_eq_nodes(eq_nodes+w_corr,60.0), key=lambda x: x[0])
1395
+ eq_nodes = [(f,float(np.clip(g,-max_eq_db,max_eq_db)),q) for f,g,q in eq_nodes]
1396
+ compand_pts,makeup,intensity,calib = build_compand_curve(
1397
+ inp_crest, inp_lra, ref_lra=ref_fp.lra, force_extreme=(quality_tier=='EXTREME'))
1398
+ n_ch = '1' if stream.get('channels',2)==1 else '2'
1399
+ pts = [total_s//8, total_s//2, total_s*3//4]
1400
+ clips= [f'/tmp/ch64_{i}.wav' for i in range(3)]
1401
+ procs= [subprocess.Popen(['ffmpeg','-y','-i',input_path,'-ss',str(sk),'-t','30',
1402
+ '-ar','48000','-ac',n_ch,cl,'-loglevel','error']) for sk,cl in zip(pts,clips)]
1403
+ for p in procs: p.wait()
1404
+ chain0 = build_filter_chain(
1405
+ eq_nodes,compand_pts,makeup,inp_hf,False,0.0,noise_reduce=True,
1406
+ intensity=intensity,inp_lra=inp_lra,inp_crest=inp_crest,
1407
+ quality_tier=quality_tier,hf_rolloff_hz=hf_rolloff_hz,src_sr=src_sr
1408
+ ).replace('\n','').replace(' ','')
1409
+ lprocs = [
1410
+ subprocess.Popen(['ffmpeg','-y','-i',cl,'-af',chain0+',ebur128=peak=true',
1411
+ '-f','null','-','-loglevel','info'],stderr=subprocess.PIPE,stdout=subprocess.PIPE)
1412
+ for cl in clips
1413
+ ]
1414
+ lufs_vals=[]
1415
+ for p in lprocs:
1416
+ _,err=p.communicate()
1417
+ for l in err.decode().split('\n'):
1418
+ s=l.strip()
1419
+ if s.startswith('I:') and 'LUFS' in s and 'LRA' not in s:
1420
+ try: lufs_vals.append(float(s.split('I:')[1].strip().split()[0])); break
1421
+ except: pass
1422
+ l0 = float(np.mean(lufs_vals)) if lufs_vals else -12.0
1423
+ gain = float(np.clip(TARGET['lufs']-l0-calib,-18,12))
1424
+ # Pass 1 WAV
1425
+ tmp_wav='/tmp/v64_chunk.wav'
1426
+ r=subprocess.run(
1427
+ ['ffmpeg','-y','-i',input_path,'-af',
1428
+ build_filter_chain(eq_nodes,compand_pts,makeup,inp_hf,False,gain,
1429
+ noise_reduce=True,intensity=intensity,inp_lra=inp_lra,inp_crest=inp_crest,
1430
+ quality_tier=quality_tier,hf_rolloff_hz=hf_rolloff_hz,src_sr=src_sr
1431
+ ).replace('\n','').replace(' ','')+',ebur128=peak=true',
1432
+ '-ar','48000','-ac',n_ch,tmp_wav,'-loglevel','info'],
1433
+ capture_output=True, text=True)
1434
+ actual_lufs=-99.0
1435
+ for line in r.stderr.split('\n'):
1436
+ s=line.strip()
1437
+ if s.startswith('I:') and 'LUFS' in s and 'LRA' not in s:
1438
+ try: actual_lufs=float(s.split('I:')[1].strip().split()[0]); break
1439
+ except: pass
1440
+ lufs_corr = TARGET['lufs']-actual_lufs if actual_lufs!=-99.0 else 0.0
1441
+ # Measure pass-1 spectrum & compute correction
1442
+ out1=load(tmp_wav,skip=mid_s,duration=35)
1443
+ out1_b=third_octave(out1)
1444
+ corr_nodes=spectral_correction_eq(out1_b,ref_fp,hf_rolloff_hz=hf_rolloff_hz)
1445
+ post_w=warmth_nodes(out1_b,ref_fp,quality_tier=quality_tier,
1446
+ post_compand=True,hf_rolloff_hz=hf_rolloff_hz)
1447
+ corr_af=''
1448
+ if corr_nodes:
1449
+ corr_af+=','+','.join(f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={g}'
1450
+ for f0,g,Q in corr_nodes)
1451
+ if post_w:
1452
+ corr_af+=','+','.join(f'equalizer=f={f0:.0f}:width_type=q:width={Q}:g={g}'
1453
+ for f0,g,Q in post_w)
1454
+ tmp_out='/tmp/v64_chunk_out.mp3'
1455
+ subprocess.run(
1456
+ ['ffmpeg','-y','-i',tmp_wav,'-af',
1457
+ f'volume={lufs_corr:.3f}dB{corr_af},alimiter=limit=0.995:level=false:attack=1:release=15',
1458
+ '-b:a','320k','-ar','48000','-ac',n_ch,tmp_out,'-loglevel','error'],
1459
+ capture_output=True)
1460
+ shutil.copy(tmp_out, output_path)
1461
+ out_a=load(output_path,skip=mid_s,duration=35)
1462
+ out_b=third_octave(out_a)
1463
+ metrics={'lufs':TARGET['lufs'],'rms':rms_db(out_a),
1464
+ 'crest':crest_factor(out_a),'lra':lra_estimate(out_a)}
1465
+ score,breakdown=quality_score(out_b,ref_fp,metrics,hf_rolloff_hz)
1466
+ print(f" โ˜… {score}/100 โœ… {output_path}")
1467
+ return {'score':score,'breakdown':breakdown,'final_metrics':metrics}
1468
+
1469
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1470
+ # ENTRY POINT
1471
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1472
+ if __name__ == '__main__':
1473
+ import argparse
1474
+ ap = argparse.ArgumentParser(description='Tilawa Engine v7.0 -- server CLI')
1475
+ ap.add_argument('-i', '--input', required=True)
1476
+ ap.add_argument('-o', '--output', required=True)
1477
+ ap.add_argument('--ref', action='append', default=[],
1478
+ help='Reference audio file; repeat for multiple files')
1479
+ ap.add_argument('--iterations', type=int, default=1,
1480
+ help='Iterations (v7.0: ignored; kept for CLI compat)')
1481
+ args = ap.parse_args()
1482
+
1483
+ if args.ref:
1484
+ valid = [r for r in args.ref if os.path.exists(r)]
1485
+ if valid:
1486
+ globals()['_CLI_REF_FILES'] = valid
1487
+ if os.path.exists(REF_CACHE):
1488
+ try: os.remove(REF_CACHE)
1489
+ except: pass
1490
+ print(f'ู…ุฑุงุฌุน: {len(valid)} ู…ู„ู')
1491
+ else:
1492
+ print('ุชุญุฐูŠุฑ: ู…ู„ูุงุช --ref ุบูŠุฑ ู…ูˆุฌูˆุฏุฉุŒ ุฌุงุฑ ุงุณุชุฎุฏุงู… ุงู„ุจุตู…ุฉ ุงู„ู…ุฎุฒู‘ู†ุฉ')
1493
+
1494
+ print('Pass 1 โ€” ุชุญู„ูŠู„ ุงู„ู…ู„ู ูˆุจู†ุงุก ุงู„ุจุตู…ุฉ ุงู„ู…ุฑุฌุนูŠุฉ...')
1495
+ sys.stdout.flush()
1496
+
1497
+ try:
1498
+ result = enhance(input_path=args.input, output_path=args.output)
1499
+ except Exception as e:
1500
+ print(f'Error: {e}')
1501
+ sys.exit(1)
1502
+
1503
+ score = result.get('score', 0)
1504
+ metrics = result.get('final_metrics', {})
1505
+ lufs = metrics.get('lufs', TARGET['lufs'])
1506
+ rms = metrics.get('rms', TARGET['rms'])
1507
+ crest = metrics.get('crest', TARGET['crest'])
1508
+ lra = metrics.get('lra', TARGET['lra'])
1509
+
1510
+ print('Pass 3 โ€” ุฅู†ู‡ุงุก ุงู„ู…ุนุงู„ุฌุฉ')
1511
+ print(f'Score: {score:.1f}')
1512
+ print(f'LUFS={lufs:.2f} RMS={rms:.2f} Crest={crest:.2f} LRA={lra:.2f}')
1513
+ sys.stdout.flush()
1514
+
1515
+ sys.exit(0 if score >= 90 else 1)