Files changed (15) hide show
  1. .gitattributes +36 -0
  2. .github/workflows/sync-to-hf.yml +0 -20
  3. .gitignore +0 -6
  4. CHANGELOG.md +0 -287
  5. PRESETS_IMPROVEMENTS.md +0 -407
  6. README.md +11 -11
  7. analysis.py +0 -1194
  8. app.py +22 -1073
  9. build_schematic.py +0 -1064
  10. dsp.py +90 -378
  11. launch.bat +0 -21
  12. presets.py +20 -110
  13. sAI.png +3 -0
  14. stereo.py +10 -49
  15. visualization.py +11 -40
.gitattributes CHANGED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ sAI.png filter=lfs diff=lfs merge=lfs -text
.github/workflows/sync-to-hf.yml DELETED
@@ -1,20 +0,0 @@
1
- name: Sync to Hugging Face Spaces
2
- on:
3
- push:
4
- branches: [main]
5
-
6
- jobs:
7
- sync:
8
- runs-on: ubuntu-latest
9
- steps:
10
- - uses: actions/checkout@v4
11
- with:
12
- fetch-depth: 0
13
- lfs: true
14
- - name: Push to HuggingFace
15
- env:
16
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
17
- run: |
18
- git remote add hf https://AnimalMonk:$HF_TOKEN@huggingface.co/spaces/AnimalMonk/audio-mastering-suite || true
19
- git fetch hf || true
20
- git push hf main:main --force
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore DELETED
@@ -1,6 +0,0 @@
1
- __pycache__/
2
- *.pyc
3
- *.wav
4
- nul
5
- ANALOG_SCHEMATIC.pdf
6
- sAI.png
 
 
 
 
 
 
 
CHANGELOG.md CHANGED
@@ -1,292 +1,5 @@
1
  # Changelog — Audio Mastering Suite
2
 
3
- ## v4.2.1 — 2026-03-20
4
-
5
- ### Super AI — Slider Overflow Fix
6
- - **Super AI no longer maps parameters back to UI sliders** — The full parametric params (6-band EQ with arbitrary frequencies, per-band compression) exceed the slider ranges (e.g. bass freq slider max 100 Hz, but AI might use 249 Hz). Sliders now stay untouched after Super AI runs. Full settings are displayed in the DSP Settings table and Applied Settings report.
7
-
8
- ### Gemini Reliability
9
- - **3-retry logic with escalating delays** — On 503 errors, waits 10s, 20s, 30s between retries before failing. Timeout increased to 120s per call.
10
- - **15-second cooldown between Gemini calls** — Prevents rapid-fire API calls in Auto Master (4 calls) and Super AI (5 calls)
11
- - **GeminiUnavailableError** — Clear error message shown to user: "This is an issue with Google's AI servers, not with StudioAI." Process aborts without consuming a usage credit.
12
- - **Usage credits only consumed on success** — `_check_ai_key(consume=True)` only called after all passes complete successfully
13
-
14
- ### Memory Management
15
- - **gc.collect() at start of each run** — Frees previous run's data before starting a new Auto Master or Super AI. No mid-pipeline garbage collection (was causing issues).
16
-
17
- ### GitHub → HuggingFace Auto-Deploy
18
- - **GitHub Action workflow** — Every push to `main` auto-syncs to HuggingFace Spaces. Binary files (PDF, PNG) excluded from git and uploaded directly to HF.
19
-
20
- ---
21
-
22
- ## v4.2 — 2026-03-20
23
-
24
- ### Super AI — 6-Band Parametric EQ
25
- - **Expanded from 4 to 6 parametric EQ bands** — AI now has 6 independently configurable bands (peak/low_shelf/high_shelf with freq, gain, Q). Provides room for both tonal shaping and surgical notch cuts without trade-offs
26
- - **Updated all prompts, clamp functions, DSP loops, and display reports** to handle bands 5 and 6
27
-
28
- ### Super AI — Bass Protection Prompts
29
- - **"HEAVY, UNCONSTRAINED BASS" directive** — AI is now explicitly instructed to prioritize additive EQ for bass weight, never over-compress <200 Hz, and default low-band compressor to bypass (ratio 1:1) on low-crest-factor material
30
- - **Crest factor check between passes** — If crest factor in <200 Hz range decreases between passes, AI must back off low-band compression
31
- - **Sidechain emulation note** — AI treats low-band compressor as if it has a 100 Hz HPF on its sidechain detector (slow attack / low ratio to preserve kick transients)
32
- - **HPF frequency cap** — Must stay at or below 25 Hz for bass-heavy material
33
- - **"LESS IS MORE" philosophy** — AI instructed to use the minimum processing needed, every parameter must have a reason, gentle moves preferred
34
-
35
- ### Super AI — Tighter Parameter Clamps
36
- - **EQ gain clamped to ±6 dB** (was ±12 dB) — prevents overly aggressive EQ moves
37
- - **Compression threshold clamped to -20 dB minimum** (was -40 dB) — prevents always-on compression
38
- - **Compression ratio clamped to 4.0:1 maximum** (was 20.0:1) — prevents brick-wall limiting behavior
39
-
40
- ### DSP Settings Table
41
- - **DSP settings now displayed side-by-side with Loudness Statistics** — New `dsp_display` Markdown component in a `gr.Row()` shows all applied parameters (HPF, EQ bands, crossovers, per-band compression, stereo width) next to the LUFS/true peak table
42
- - **Works for all modes** — Manual mastering, Auto Master, and Super AI all populate the DSP table
43
-
44
- ### AI Context Clearing
45
- - **Analysis history cleared on each Auto Master / Super AI run** — `analysis_history_state` is reset to `[]` on the first yield, preventing previous test runs from influencing new mastering sessions
46
-
47
- ### Unkey API Key Validation
48
- - **Replaced hardcoded AI_ACCESS_KEY with Unkey API verification** — `_check_ai_key()` now validates against Unkey's v2 API with per-key rate limiting and tier-based master counts
49
- - **Usage tracking** — Each AI operation decrements the key's `remaining` count; keys with 0 remaining are rejected with a "limit reached" message
50
- - **Tier metadata** — Keys store tier (starter/pro/studio), email, and Stripe customer ID
51
-
52
- ### Pricing Page (new HF Space)
53
- - **New `AnimalMonk/studio-ai-pricing` Space** — Pure HTML/FastAPI pricing page with Stripe Payment Links for Starter ($9/10 masters), Pro ($24/30 masters), and Studio ($49/100 masters)
54
- - **Stripe webhook integration** — `checkout.session.completed` creates Unkey API keys automatically with tier-appropriate rate limits
55
- - **Success page** — Post-purchase redirect displays the API key with copy button
56
- - **Key lookup** — Email-based key recovery searches Unkey by email metadata
57
- - **Resend email integration** — API key delivered via email on purchase (pending domain verification)
58
-
59
- ### Schematic PDF — Pages 4 & 5
60
- - **Page 4: Super AI 5-Pass Workflow** — Visual diagram of the iterative refinement loop, full parametric parameter table with ranges, fixed vs AI-controlled stages
61
- - **Page 5: AI Prompting Philosophy** — Mastering philosophy, tonal direction (warm + enhanced bass), true peak guidance (soft goal), compare pass guidelines, final report structure
62
- - **Version bumped to v4.1** in PDF title
63
-
64
- ---
65
-
66
- ## v3.9 — 2026-03-18
67
-
68
- ### Maximizer Removed
69
- - **Removed iterative gain + brick-wall limiter loop** — The maximizer was designed to push true peak into the -1.0 to -0.1 dBTP range by iteratively boosting gain, limiting peaks, and re-normalizing. In practice, this created a dynamics death spiral: each iteration crushed crest factor further, producing a flat "sausage" waveform with no dynamics — while still failing to push true peak into range on already-compressed source material
70
- - **`_maximize()` function deleted** from `dsp.py`, `Limiter` import removed
71
- - **Removed from both `master_audio()` and `master_audio_full()` pipelines**
72
-
73
- ### Super AI Prompt Updates
74
- - **True peak is now a soft goal, not a hard constraint** — The -1.0 to -0.1 dBTP range is ideal but the AI is explicitly told NOT to over-compress or crush dynamics to achieve it
75
- - **Added source material awareness** — Prompts now explain that heavily limited source material (Suno, Udio, AI-generated tracks) has low crest factor, and when normalized to streaming LUFS targets the true peak will naturally land well below -1.0 dBTP — this is correct and expected
76
- - **Tonal direction added to all Super AI prompts** — Slightly warm overall tone (low-mid richness 200-500 Hz, smooth non-harsh highs) with slightly enhanced bass response
77
- - **Maximizer references removed** from signal flow documentation in all prompts
78
-
79
- ### Signal Flow (both pipelines)
80
- ```
81
- Pre-Gain Drop (-18 LUFS) → HPF → EQ → Multiband Compression (3-band) → Stereo Width → LUFS Normalization → Soft Clipper → True Peak Ceiling → Output
82
- ```
83
-
84
- ---
85
-
86
- ## v3.8 — 2026-03-18
87
-
88
- ### Super AI (beta)
89
- - **New "Super AI (beta)" button** — 5-pass AI mastering with full parametric control. The AI controls every parameter in the signal chain: 4-band parametric EQ (peak/low_shelf/high_shelf with configurable freq, gain, Q), per-band multiband compression (independent threshold, ratio, attack, release), adjustable crossover frequencies, HPF frequency, and stereo width
90
- - **`master_audio_full()`** — New DSP function accepting granular per-band parameters instead of slider values
91
- - **5-pass workflow:** (1) AI analyzes raw audio → full settings → master, (2-4) AI compares and makes small incremental adjustments → re-master, (5) Final quality report only
92
- - **Session-persistent history** — AI maintains full context across all 5 passes to prevent oscillation
93
- - **Safety clamps** in both `_clamp_super_params()` (analysis) and `master_audio_full()` (DSP) prevent dangerous parameter values
94
-
95
- ### Soft Clipper & True Peak Ceiling
96
- - **Piecewise tanh soft clipper** added as safety net — knee at 2 dB below -0.1 dBTP ceiling, linear below knee, tanh saturation above
97
- - **True peak ceiling** at -0.1 dBTP — scales signal down if residual inter-sample peaks exceed ceiling after 4x oversampled measurement (ITU-R BS.1770)
98
-
99
- ---
100
-
101
- ## v3.7 — 2026-03-11
102
-
103
- ### Auto Master
104
- - **New "Auto Master" button** — One-click iterative AI mastering loop: AI Recommend → Master → AI Compare with revised values → Re-master → AI Compare → Re-master → Final AI Compare report. 3 mastering passes, 4 Gemini API calls, ~2 minutes
105
- - **`compare_master_structured()`** — New analysis function that returns both a markdown comparison report and structured JSON slider values, enabling the auto-master loop to apply AI-suggested adjustments automatically
106
- - **`_clamp_settings()` / `_strip_json()` helpers** — Extracted shared utilities from `recommend_settings()` for reuse by the new structured compare function
107
-
108
- ### UI Improvements
109
- - **AI Compare button hidden until mastered** — Now starts invisible and appears alongside the Download button only after mastering completes
110
- - **AI comparison report moved above mastered playback** — Report text displays above the audio player for better reading flow
111
- - **AI Reasoning display moved below upload** — AI Recommend analysis text now appears directly below the file upload block
112
- - **Browser password autofill disabled** on the AI Access Key field to prevent the password manager from covering the Preset dropdown
113
-
114
- ---
115
-
116
- ## v3.6 — 2026-03-11
117
-
118
- ### Signal Flow Reorder — Compression Before Stereo Width
119
- - **Multiband compression moved before stereo width** — Industry research (iZotope, Waves, FabFilter, Bob Katz, Berklee) shows ~90% consensus: dynamics processing should see the EQ'd signal without M/S side-channel energy affecting per-band behaviour. Stereo width now operates on the dynamically settled signal for stable, predictable imaging
120
- - **New signal flow:** `Pre-Gain Drop (-18 LUFS) → HPF 15 Hz → EQ → Multiband Compression (3-band) → Stereo Width → LUFS Normalization → Output`
121
- - **Old signal flow:** `... → EQ → Stereo Width → Multiband Compression → LUFS Normalization → Output`
122
-
123
- ### Updated Files
124
- - **`dsp.py`** — Stage 3 = Multiband Compression, Stage 4 = Stereo Width (was reversed)
125
- - **`analysis.py`** — AI signal flow prompts updated to match new stage order
126
- - **`build_schematic.py`** — Schematic page layout, gear rack table, and signal flow diagram all updated; version bumped to v3.6
127
- - **`ANALOG_SCHEMATIC.pdf`** — Regenerated with correct stage ordering
128
-
129
- ### UI Layout Rearrangement
130
- - **"Apply AI Settings" button moved above "Master It!"** — One-click AI settings are now immediately visible before mastering
131
- - **"AI Compare Original vs Master" button moved** to the old "Apply AI Settings" location (below sliders, above playback)
132
- - **AI comparison report text** now displays directly below its trigger button
133
-
134
- ---
135
-
136
- ## v3.5.1 — 2026-03-11
137
-
138
- ### Bug Fix
139
- - **AI Recommend "markdown string" fix** — Gemini was returning the JSON template placeholder `"markdown string"` literally instead of generating actual analysis. Updated the prompt template with an explicit example and instruction not to return it verbatim
140
-
141
- ### Schematic PDF v3.5
142
- - **ANALOG_SCHEMATIC.pdf regenerated** with multiband compression architecture:
143
- - Stage 4 now shows 3-band compressor with crossover sub-box, per-band parameter columns, and color-coded bands
144
- - Gear rack table updated: 3U SSL G384 / Manley Vari-Mu / Maselec MLA-3 (was 2U single-band)
145
- - Signal flow diagram updated: "Multiband Comp (3-Band)" replaces "Comp"
146
- - Total rack space: 9U (was 8U)
147
- - **`build_schematic.py` included** in repo for reproducible PDF generation
148
-
149
- ---
150
-
151
- ## v3.5 — 2026-03-11
152
-
153
- ### Multiband Compression
154
- - **3-band multiband compression replaces single-band** — Signal is split at 200 Hz and 4 kHz using Linkwitz-Riley 4th-order crossovers (zero-phase, perfect reconstruction)
155
- - **Per-band parameter curves** tuned to industry mastering best practices:
156
- - **Low (< 200 Hz):** Firmest control, slow 80 ms attack (kick punch-through), ratio 1.2:1 → 2.5:1, threshold -16 → -24 dB
157
- - **Mid (200 Hz – 4 kHz):** Lightest touch (vocal/instrument preservation), 30 ms attack, ratio 1.1:1 → 2.0:1, threshold -14 → -22 dB
158
- - **High (> 4 kHz):** Barely compresses (harsh transient taming only), fast 10 ms attack, ratio 1.05:1 → 1.5:1, threshold -12 → -18 dB
159
- - **Same single slider (0-100)** — No UI changes. Same true bypass at slider = 0. Slider drives all three bands in parallel
160
- - **No makeup gain** — LUFS normalization handles output level (unchanged)
161
-
162
- ### AI Prompt Updates
163
- - **Signal flow description updated** to reflect multiband architecture with per-band parameter ranges
164
- - **AI comparison report now shows per-band compression parameters** (low/mid/high threshold, ratio, attack, release)
165
- - **AI prompts updated** with full signal flow context including crossover frequencies and band-specific behavior
166
-
167
- ### Technical
168
- - `linkwitz_riley_crossover()` made public in `stereo.py` (was private `_linkwitz_riley_crossover()`)
169
- - New `map_multiband_compression()` function in `dsp.py`
170
- - Old `map_compression()` retained as deprecated for backward compatibility
171
- - No new pip dependencies — uses existing scipy + pedalboard
172
-
173
- ### Updated Signal Flow
174
- ```
175
- Pre-Gain Drop (-18 LUFS) → HPF 15 Hz → EQ → Stereo Width → Multiband Compression (3-band) → LUFS Normalization → Output
176
- ```
177
-
178
- ---
179
-
180
- ## v3.4 — 2026-03-11
181
-
182
- ### Filter & Compression Fixes
183
- - **HPF lowered from 20 Hz to 15 Hz** — Old 20 Hz cutoff caused ~0.6 dB loss at 35 Hz. New 15 Hz cutoff is negligible above 35 Hz (-0.1 dB) while still cleaning subsonic rumble
184
- - **LPF at 20 kHz removed** — Was rolling off -1.2 dB at 15 kHz, fighting the 10 kHz high shelf in the air zone and muddying HF shaping. Source audio is already band-limited by sample rate (Nyquist)
185
- - **Compressor now bypasses at slider = 0** — Previously ran a 1.1:1 ratio compressor even at zero. Now truly bypasses when compression slider is at 0
186
-
187
- ### Spectrum Visualization Overhaul
188
- - **Level-aligned spectral comparison** — Mastered spectrum is aligned to the original's average level (100 Hz – 10 kHz passband), so the plot compares spectral *shape* instead of absolute loudness
189
- - **Processing difference trace** — New green Δ dB subplot shows exactly what the mastering chain changed at each frequency (flat at 0 = no change, negative = cut, positive = boost)
190
- - **Fixed false sub-bass loss display** — Old plot showed absolute levels, making the uniform loudness difference (e.g. -9 → -14 LUFS) look like disproportionate bass loss
191
-
192
- ### Updated Signal Flow
193
- ```
194
- Pre-Gain Drop (-18 LUFS) → HPF 15 Hz → EQ → Stereo Width → Compression → LUFS Normalization → Output
195
- ```
196
-
197
- ---
198
-
199
- ## v3.3 — 2026-03-10
200
-
201
- ### AI Analysis (Gemini Pro 3.1)
202
- - **AI Recommend button** — Analyzes uploaded audio (spectral profile, dynamics, stereo field) and recommends optimal mastering settings via Google Gemini Pro 3.1
203
- - **Apply AI Settings** — One-click button to populate all 7 sliders with AI-recommended values
204
- - **Post-master AI report** — After mastering, Gemini compares original vs mastered audio and provides a quality assessment with actionable feedback
205
- - **Audio feature extraction** — New `analysis.py` module: spectral centroid, spectral rolloff, 6-band energy distribution, crest factor, dynamic range, stereo correlation
206
- - **Graceful degradation** — If `GOOGLE_API_KEY` is not set, AI features show a helpful message instead of crashing
207
-
208
- ### Premium Access Key
209
- - **AI features gated behind access key** — Users must enter a valid access key to use AI Recommend and post-master AI report
210
- - Premium prompt directs users to the StudioAI Discord for access
211
- - Access key validated against `AI_ACCESS_KEY` HF Space secret
212
-
213
- ### Technical
214
- - **Gemini REST API** — Uses direct HTTP calls instead of the `google-generativeai` SDK (avoids 200 MB grpcio/protobuf dependency)
215
- - No new pip dependencies — uses `requests` bundled with Gradio
216
-
217
- ---
218
-
219
- ## v3.2 — 2026-03-04
220
-
221
- ### Genre Expansion
222
- - **6 new presets added:** Indie / Alt-Rock (Organic & Glued), R&B / Soul (Silky & Smooth), Lo-Fi / Chillhop (Dusty & Narrow), Jazz (Live & Dynamic), Reggae / Dub (Heavy Sound System), Synthwave / Retrowave (Analog & Driving)
223
- - Total preset count: **14 genres**
224
-
225
- ### Limiter Removed
226
- - **Removed Pedalboard safety limiter** — JUCE-based Limiter was applying automatic makeup gain, pushing output ~4 dB above the target LUFS (e.g. -10 LUFS when targeting -14). Signal flow now ends cleanly at LUFS normalization.
227
- - Removed limiter gain reduction from stats display
228
-
229
- ### UI Fixes
230
- - **Bass frequency slider expanded** — Range changed from 50–60 Hz to **40–100 Hz** to support genres like Reggae (80 Hz bass targeting)
231
- - **Highs slider label corrected** — Changed from "Highs (6 kHz)" to "Highs (10 kHz)" to match the actual HighShelfFilter frequency
232
-
233
- ### Updated Signal Flow
234
- ```
235
- Pre-Gain Drop (-18 LUFS) → HPF/LPF → EQ → Stereo Width → Compression → LUFS Normalization → Output
236
- ```
237
-
238
- ---
239
-
240
- ## v3.1 — 2026-03-03
241
-
242
- ### Research-Backed Preset Audit
243
- All 8 presets audited against industry mastering best practices (iZotope, Waves, Sound On Sound, Mastering The Mix, Sage Audio, Nail The Mix, Attack Magazine, EDMProd, VI-Control).
244
-
245
- - **Vintage Analog:** bass_freq_hz 50→80 Hz (80 Hz = warmth/body, 50 Hz = sub-rumble)
246
- - **Hard Rock / Metal:** highs_db 1.0→0.5 (reduce harshness risk in fizz zone), bass_freq_hz 55→60 (kick thump sweet spot)
247
- - **Acoustic & Vocal:** Renamed "Transparent" → "Clear & Present", mid_boost_db 1.5→1.0 (was most aggressive mid boost of any preset)
248
- - **Hip-Hop / Boom Bap:** lows_db +0.5→-0.5 (cut low-mids, not boost — boom bap dips at 150 Hz), highs_db 0.0→-0.5 (rolled-off highs for signature lo-fi grit)
249
- - **Cinematic / Orchestral:** stereo_width 130→110 (research warns against aggressive wideners on orchestral), lows_db 1.0→0.5 (orchestral EQ moves should be 0.25-0.5 dB)
250
- - **New presets added:** Hip-Hop / Boom Bap (Punch & Grit), Cinematic / Orchestral (Dynamic & Wide)
251
-
252
- ### EQ: High Shelf Raised to 10 kHz
253
- - **HighShelfFilter moved from 6 kHz to 10 kHz** — Research across all genres identifies 10-15 kHz as the "air" zone. The old 6 kHz shelf targeted upper-midrange presence/brightness instead of true air, and risked adding harshness in the 6-10 kHz fizz zone (especially problematic for metal and orchestral strings).
254
-
255
- ### Stereo Width: Frequency-Selective M/S
256
- - **Bass below 200 Hz now stays untouched** — Added a Linkwitz-Riley 4th-order crossover at 200 Hz. Width adjustment (M/S encoding) applies only above the crossover. This keeps the low end tight and phase-coherent on club mono-sub systems and small speakers, matching the universal industry recommendation of "mono bass, wide highs."
257
-
258
- ---
259
-
260
- ## v3.0 — 2026-03-03
261
-
262
- ### Gain Staging Overhaul
263
- - **Pre-gain drop to -18 LUFS** — Hot inputs (e.g. Suno at -9 LUFS) are normalized to -18 LUFS before any processing, preventing EQ boosts from clipping the working audio
264
- - **Removed makeup gain** — LUFS normalization at the end handles volume; compressor makeup gain was redundant and caused cascading gain issues
265
- - **Removed auto-gain reduction stage** — No longer needed with the pre-gain architecture
266
- - **Simplified signal flow:** Pre-Gain Drop → Filters → EQ → Stereo Width → Compression → LUFS Normalize → Safety Limiter
267
-
268
- ### Compression Recalibration
269
- - **Thresholds recalibrated for -18 LUFS working level** — Range changed from -6→-14 dB to **-14→-22 dB** so compression engages properly at the new internal level
270
- - **Ratio range tightened** — 1.1:1 → 2.5:1 (was 1:1 → 4:1) for safer mastering glue
271
- - **Attack fixed at 30 ms** (was 15 ms) to let transients breathe
272
- - **Release now variable** — 250 ms (less compression) → 100 ms (more compression)
273
-
274
- ### Preset Retuning
275
- - All 5 original presets retuned with new compression values calibrated for the -18 LUFS working level
276
- - **New preset: Deep Ambient (Immersive & Sustained)** — Low compression (20), wide stereo (125%), air boost, sub-bass focus
277
-
278
- ### UI & Visualization
279
- - **Download button moved** — Now appears directly under the Mastered playback box instead of at the bottom
280
- - **Spectrum plot height** — Matched to waveform plot height (8x4)
281
- - **Spectrum x-axis labels** — Changed from exponent notation to "10 Hz", "100 Hz", "1 kHz", "10 kHz"
282
- - **Logo & Discord link** — Added base64-embedded sAI logo with Discord.gg/StudioAI link in header
283
-
284
- ### Statistics
285
- - Removed auto-gain reduction from stats display (no longer exists)
286
- - Gain staging table now shows only limiter gain reduction
287
-
288
- ---
289
-
290
  ## v2.3 — 2026-03-01
291
 
292
  ### Cleanup & HF Spaces Deployment
 
1
  # Changelog — Audio Mastering Suite
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## v2.3 — 2026-03-01
4
 
5
  ### Cleanup & HF Spaces Deployment
PRESETS_IMPROVEMENTS.md DELETED
@@ -1,407 +0,0 @@
1
- # Code Improvement Suggestions for presets.py
2
-
3
- ## Overview
4
- The `presets.py` file contains audio mastering presets with good documentation but has several opportunities for refactoring, optimization, and better practices.
5
-
6
- ---
7
-
8
- ## 1. **Data Validation & Type Safety**
9
-
10
- ### Issue
11
- No validation of preset values. Invalid values could cause runtime errors in the audio processing pipeline.
12
-
13
- ### Current Code
14
- ```python
15
- PRESETS = {
16
- "Modern Pop (Bright & Wide)": {
17
- "compression": 80,
18
- "stereo_width": 115,
19
- },
20
- }
21
- ```
22
-
23
- ### Recommendation
24
- Add a `PresetConfig` dataclass or TypedDict with validation:
25
-
26
- ```python
27
- from dataclasses import dataclass
28
- from typing import TypedDict, Optional, Literal
29
-
30
- class PresetConfig(TypedDict):
31
- """Type definition for preset configuration."""
32
- lows_db: float
33
- mid_boost_db: float
34
- highs_db: float
35
- bass_boost_db: float
36
- bass_freq_hz: int
37
- compression: int # 0-100
38
- stereo_width: int # 0-200
39
-
40
- @dataclass
41
- class AudioPreset:
42
- """Validated audio preset with constraints."""
43
- name: str
44
- config: Optional[PresetConfig]
45
-
46
- def __post_init__(self):
47
- if self.config is None:
48
- return
49
-
50
- # Validate ranges
51
- assert 0 <= self.config["compression"] <= 100, "Compression must be 0-100"
52
- assert 0 <= self.config["stereo_width"] <= 200, "Stereo width must be 0-200"
53
- assert 20 <= self.config["bass_freq_hz"] <= 200, "Bass freq must be 20-200 Hz"
54
- assert -12 <= self.config["lows_db"] <= 12, "dB values must be -12 to +12"
55
- ```
56
-
57
- **Benefits:**
58
- - Type hints enable IDE autocomplete and catch errors early
59
- - Runtime validation prevents invalid audio processing
60
- - Self-documenting code
61
-
62
- ---
63
-
64
- ## 2. **Extract Magic Numbers to Constants**
65
-
66
- ### Issue
67
- Hardcoded values (compression ranges, frequency limits, dB ranges) scattered throughout.
68
-
69
- ### Current Code
70
- ```python
71
- "compression": 80, # Ratio ~2.2:1, Rel 130ms
72
- "stereo_width": 115,
73
- ```
74
-
75
- ### Recommendation
76
- ```python
77
- # At the top of the file
78
- class AudioConstraints:
79
- """Valid ranges for audio parameters."""
80
- COMPRESSION_MIN = 0
81
- COMPRESSION_MAX = 100
82
- STEREO_WIDTH_MIN = 0
83
- STEREO_WIDTH_MAX = 200
84
- BASS_FREQ_MIN_HZ = 20
85
- BASS_FREQ_MAX_HZ = 200
86
- DB_MIN = -12
87
- DB_MAX = 12
88
-
89
- # Then use in validation
90
- assert AudioConstraints.COMPRESSION_MIN <= config["compression"] <= AudioConstraints.COMPRESSION_MAX
91
- ```
92
-
93
- **Benefits:**
94
- - Single source of truth for constraints
95
- - Easier to maintain and update limits
96
- - Self-documenting
97
-
98
- ---
99
-
100
- ## 3. **Separate Metadata from Configuration**
101
-
102
- ### Issue
103
- Comments explaining compression ratios and release times are mixed with data. This metadata should be structured.
104
-
105
- ### Current Code
106
- ```python
107
- "compression": 80, # Ratio ~2.2:1, Rel 130ms (Snappy, cohesive glue)
108
- "stereo_width": 115,
109
- ```
110
-
111
- ### Recommendation
112
- ```python
113
- PRESETS_METADATA = {
114
- "Modern Pop (Bright & Wide)": {
115
- "description": "Bright, punchy, and wide stereo field",
116
- "use_cases": ["Pop", "Dance", "Electronic"],
117
- "compression_details": {
118
- "value": 80,
119
- "ratio": "~2.2:1",
120
- "release_ms": 130,
121
- "character": "Snappy, cohesive glue"
122
- },
123
- "stereo_width_details": {
124
- "value": 115,
125
- "character": "Keeps the band feeling 'in the room'"
126
- }
127
- }
128
- }
129
-
130
- PRESETS = {
131
- "-- None --": None,
132
- "Modern Pop (Bright & Wide)": {
133
- "lows_db": 0.0,
134
- "mid_boost_db": 1.0,
135
- "highs_db": 1.5,
136
- "bass_boost_db": 1.0,
137
- "bass_freq_hz": 60,
138
- "compression": 80,
139
- "stereo_width": 115,
140
- },
141
- }
142
- ```
143
-
144
- **Benefits:**
145
- - Cleaner data structure
146
- - Metadata can be used for UI tooltips/help
147
- - Easier to maintain documentation separately
148
-
149
- ---
150
-
151
- ## 4. **Create a Preset Factory/Builder Pattern**
152
-
153
- ### Issue
154
- Repetitive dictionary creation with potential for inconsistency.
155
-
156
- ### Recommendation
157
- ```python
158
- def create_preset(
159
- lows_db: float,
160
- mid_boost_db: float,
161
- highs_db: float,
162
- bass_boost_db: float,
163
- bass_freq_hz: int,
164
- compression: int,
165
- stereo_width: int,
166
- ) -> PresetConfig:
167
- """Factory function to create and validate presets."""
168
- config: PresetConfig = {
169
- "lows_db": lows_db,
170
- "mid_boost_db": mid_boost_db,
171
- "highs_db": highs_db,
172
- "bass_boost_db": bass_boost_db,
173
- "bass_freq_hz": bass_freq_hz,
174
- "compression": compression,
175
- "stereo_width": stereo_width,
176
- }
177
-
178
- # Validation happens here
179
- AudioPreset("temp", config) # Triggers __post_init__ validation
180
- return config
181
-
182
- PRESETS = {
183
- "-- None --": None,
184
- "Modern Pop (Bright & Wide)": create_preset(
185
- lows_db=0.0,
186
- mid_boost_db=1.0,
187
- highs_db=1.5,
188
- bass_boost_db=1.0,
189
- bass_freq_hz=60,
190
- compression=80,
191
- stereo_width=115,
192
- ),
193
- }
194
- ```
195
-
196
- **Benefits:**
197
- - Automatic validation on creation
198
- - Consistent structure
199
- - Easier to refactor later
200
-
201
- ---
202
-
203
- ## 5. **Add Preset Lookup & Utility Functions**
204
-
205
- ### Issue
206
- No helper functions to work with presets programmatically.
207
-
208
- ### Recommendation
209
- ```python
210
- def get_preset(name: str) -> Optional[PresetConfig]:
211
- """Retrieve a preset by name."""
212
- return PRESETS.get(name)
213
-
214
- def list_presets() -> list[str]:
215
- """Get all available preset names."""
216
- return [name for name in PRESETS.keys() if name != "-- None --"]
217
-
218
- def get_preset_info(name: str) -> dict:
219
- """Get preset with metadata."""
220
- if name not in PRESETS:
221
- raise ValueError(f"Preset '{name}' not found")
222
-
223
- return {
224
- "name": name,
225
- "config": PRESETS[name],
226
- "metadata": PRESETS_METADATA.get(name, {})
227
- }
228
- ```
229
-
230
- **Benefits:**
231
- - Encapsulation
232
- - Easier to test
233
- - Prevents direct dictionary access errors
234
-
235
- ---
236
-
237
- ## 6. **Add Documentation & Docstrings**
238
-
239
- ### Issue
240
- Module-level documentation is minimal.
241
-
242
- ### Recommendation
243
- ```python
244
- """Preset definitions for the audio mastering suite.
245
-
246
- This module contains predefined audio mastering configurations optimized for
247
- different music genres and production styles. Each preset includes:
248
- - EQ settings (lows, mids, highs)
249
- - Bass boost parameters
250
- - Compression settings
251
- - Stereo width
252
-
253
- Presets are validated on load to ensure all values are within acceptable ranges.
254
-
255
- Example:
256
- >>> preset = get_preset("Modern Pop (Bright & Wide)")
257
- >>> if preset:
258
- ... apply_mastering(audio, preset)
259
- """
260
- ```
261
-
262
- ---
263
-
264
- ## 7. **Consider Configuration File Format**
265
-
266
- ### Issue
267
- Hardcoding presets in Python limits flexibility.
268
-
269
- ### Recommendation
270
- Consider supporting JSON/YAML for easier editing:
271
-
272
- ```python
273
- import json
274
- from pathlib import Path
275
-
276
- def load_presets_from_file(filepath: Path) -> dict:
277
- """Load presets from JSON file."""
278
- with open(filepath) as f:
279
- return json.load(f)
280
-
281
- # presets.json
282
- {
283
- "Modern Pop (Bright & Wide)": {
284
- "lows_db": 0.0,
285
- "mid_boost_db": 1.0,
286
- ...
287
- }
288
- }
289
- ```
290
-
291
- **Benefits:**
292
- - Users can create custom presets without code changes
293
- - Easier to share presets
294
- - Decouples data from code
295
-
296
- ---
297
-
298
- ## 8. **Add Preset Comparison & Diff Utilities**
299
-
300
- ### Issue
301
- No way to compare presets or understand differences.
302
-
303
- ### Recommendation
304
- ```python
305
- def compare_presets(name1: str, name2: str) -> dict:
306
- """Compare two presets and show differences."""
307
- p1 = get_preset(name1)
308
- p2 = get_preset(name2)
309
-
310
- if not p1 or not p2:
311
- raise ValueError("One or both presets not found")
312
-
313
- differences = {}
314
- for key in p1.keys():
315
- if p1[key] != p2[key]:
316
- differences[key] = {
317
- "preset1": p1[key],
318
- "preset2": p2[key],
319
- "delta": p1[key] - p2[key]
320
- }
321
-
322
- return differences
323
- ```
324
-
325
- ---
326
-
327
- ## 9. **Add Preset Versioning & Changelog**
328
-
329
- ### Issue
330
- No way to track preset changes over time.
331
-
332
- ### Recommendation
333
- ```python
334
- PRESET_VERSION = "1.0.0"
335
-
336
- PRESET_CHANGELOG = {
337
- "1.0.0": "Initial release with 8 genre presets",
338
- "1.1.0": "Adjusted compression ratios for better transparency",
339
- }
340
- ```
341
-
342
- ---
343
-
344
- ## 10. **Specific Improvement for the Snippet**
345
-
346
- The specific code snippet you provided:
347
- ```python
348
- "compression": 45, # Moderate glue. Holds the band together without crushing dynamics.
349
- "stereo_width": 105, # Keeps the band feeling "in the room"
350
- ```
351
-
352
- ### Improvements:
353
-
354
- 1. **Extract comment metadata:**
355
- ```python
356
- "compression": 45,
357
- "stereo_width": 105,
358
- ```
359
- With metadata stored separately:
360
- ```python
361
- "compression_details": {
362
- "value": 45,
363
- "character": "Moderate glue. Holds the band together without crushing dynamics."
364
- }
365
- ```
366
-
367
- 2. **Add inline type hints in docstring:**
368
- ```python
369
- """
370
- Preset configuration with validated audio parameters.
371
-
372
- compression (int): 0-100, where higher = more aggressive glue
373
- stereo_width (int): 0-200, where 100 = mono, 200 = maximum width
374
- """
375
- ```
376
-
377
- 3. **Consider semantic naming:**
378
- ```python
379
- # Instead of generic "compression", consider:
380
- "compression_amount": 45, # More explicit
381
- "stereo_width_percent": 105, # Clearer units
382
- ```
383
-
384
- ---
385
-
386
- ## Summary of Recommendations (Priority Order)
387
-
388
- | Priority | Recommendation | Impact |
389
- |----------|---|---|
390
- | **High** | Add type hints & validation | Prevents runtime errors |
391
- | **High** | Extract magic numbers to constants | Maintainability |
392
- | **Medium** | Add utility functions | Usability |
393
- | **Medium** | Separate metadata from config | Code clarity |
394
- | **Low** | Support external config files | Flexibility |
395
- | **Low** | Add comparison utilities | Developer experience |
396
-
397
- ---
398
-
399
- ## Implementation Roadmap
400
-
401
- 1. **Phase 1:** Add TypedDict/dataclass with validation
402
- 2. **Phase 2:** Extract constants and add utility functions
403
- 3. **Phase 3:** Separate metadata into dedicated structure
404
- 4. **Phase 4:** Add comprehensive docstrings
405
- 5. **Phase 5:** Consider external config file support
406
-
407
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -26,25 +26,25 @@ A web-based audio mastering tool built with Gradio and Spotify's Pedalboard DSP
26
 
27
  - **3-Band EQ** - Lows (200 Hz shelf), Mids (1.2 kHz peak), Highs (6 kHz shelf), each +/-3 dB
28
  - **Bass Boost** - Sub-bass enhancement (0-3 dB) with adjustable frequency (50-60 Hz)
29
- - **Compression** - Mastering glue calibrated for -18 LUFS working level (ratio 1.1:1-2.5:1, threshold -14 to -22 dB)
30
- - **Limiter** - Safety brick-wall at -1 dB ceiling to catch stray transients
31
  - **Stereo Width** - M/S matrix stereo image control (80%-150%)
32
- - **LUFS Targeting** - Post-chain normalization to streaming (-14) or CD (-11) standards
33
- - **Pre-Gain Normalization** - Hot inputs (e.g. Suno at -9 LUFS) are dropped to -18 LUFS before processing to prevent EQ clipping
34
- - **Genre Presets** - Modern Pop, Vintage Analog, Heavy Trap/EDM, Hard Rock/Metal, Acoustic & Vocal, Deep Ambient
35
  - **Visualization** - Before/after waveform and spectrum comparison
36
- - **Statistics** - Original vs mastered LUFS, true peak, limiter gain reduction
37
 
38
  ## Signal Flow
39
 
40
  ```
41
- Pre-Gain Drop (normalize to -18 LUFS)
42
- -> HPF 20 Hz + LPF 20 kHz (12 dB/oct)
43
  -> EQ (Bass Boost, Lows, Highs, Mids)
44
  -> Stereo Width (M/S processing)
45
- -> Compression (no makeup gain)
46
- -> Target LUFS Normalization (pyloudnorm)
47
- -> Safety Limiter (-1 dB ceiling)
 
 
48
  ```
49
 
50
  ## Usage
 
26
 
27
  - **3-Band EQ** - Lows (200 Hz shelf), Mids (1.2 kHz peak), Highs (6 kHz shelf), each +/-3 dB
28
  - **Bass Boost** - Sub-bass enhancement (0-3 dB) with adjustable frequency (50-60 Hz)
29
+ - **Compression** - Variable ratio (1:1 to 4:1) and threshold (-12 to -18 dB)
30
+ - **Limiter** - Brick-wall at -1 dB with auto-gain reduction to keep limiter work under 4 dB
31
  - **Stereo Width** - M/S matrix stereo image control (80%-150%)
32
+ - **LUFS Targeting** - Strict post-chain normalization to streaming (-14) or CD (-11) standards
33
+ - **Genre Presets** - Modern Pop, Vintage Analog, Heavy Trap/EDM, Hard Rock/Metal, Acoustic & Vocal
 
34
  - **Visualization** - Before/after waveform and spectrum comparison
35
+ - **Statistics** - Original vs mastered LUFS, true peak, gain staging metrics
36
 
37
  ## Signal Flow
38
 
39
  ```
40
+ HPF 20 Hz + LPF 20 kHz (12 dB/oct)
 
41
  -> EQ (Bass Boost, Lows, Highs, Mids)
42
  -> Stereo Width (M/S processing)
43
+ -> Compression + Makeup Gain
44
+ -> Auto Gain Reduction (caps limiter at 4 dB)
45
+ -> Limiter (-1 dB ceiling)
46
+ -> LUFS Normalization (pyloudnorm)
47
+ -> Safety Limiter (if true peaks exceed ceiling)
48
  ```
49
 
50
  ## Usage
analysis.py DELETED
@@ -1,1194 +0,0 @@
1
- # Build: 2026-03-21T15:25:58.145163+00:00
2
- """AI-powered audio analysis using Gemini Pro — feature extraction and recommendations."""
3
-
4
- import json
5
- import os
6
- import numpy as np
7
- from scipy.signal import welch
8
-
9
-
10
- # ---------------------------------------------------------------------------
11
- # Audio feature extraction
12
- # ---------------------------------------------------------------------------
13
-
14
- _BANDS = [
15
- ("Sub-bass", 20, 60),
16
- ("Bass", 60, 250),
17
- ("Low-Mids", 250, 500),
18
- ("Mids", 500, 2000),
19
- ("Upper-Mids", 2000, 6000),
20
- ("Highs", 6000, 20000),
21
- ]
22
-
23
-
24
- def extract_features(audio, sample_rate):
25
- """Extract audio features for AI analysis (basic — used by Auto Master).
26
-
27
- Args:
28
- audio: numpy array, shape (samples,) or (samples, channels).
29
- sample_rate: int.
30
-
31
- Returns:
32
- dict with spectral, dynamic, and stereo measurements.
33
- """
34
- # Convert to mono for spectral analysis
35
- if audio.ndim == 2:
36
- mono = audio.mean(axis=1)
37
- else:
38
- mono = audio
39
-
40
- # --- Spectral analysis via Welch ---
41
- nperseg = min(8192, len(mono))
42
- freqs, psd = welch(mono, fs=sample_rate, nperseg=nperseg)
43
-
44
- # Spectral centroid
45
- total_energy = np.sum(psd)
46
- if total_energy > 0:
47
- spectral_centroid = float(np.sum(freqs * psd) / total_energy)
48
- else:
49
- spectral_centroid = 0.0
50
-
51
- # Spectral rolloff (85%)
52
- cumulative = np.cumsum(psd)
53
- if total_energy > 0:
54
- rolloff_idx = np.searchsorted(cumulative, 0.85 * total_energy)
55
- spectral_rolloff = float(freqs[min(rolloff_idx, len(freqs) - 1)])
56
- else:
57
- spectral_rolloff = 0.0
58
-
59
- # Band energy distribution (dB) — use float() to avoid numpy float32
60
- band_energy = {}
61
- for name, lo, hi in _BANDS:
62
- mask = (freqs >= lo) & (freqs < hi)
63
- band_rms = float(np.sqrt(np.mean(psd[mask]))) if np.any(mask) else 0.0
64
- if band_rms > 0:
65
- band_energy[name] = round(20.0 * np.log10(band_rms), 1)
66
- else:
67
- band_energy[name] = -100.0
68
-
69
- # --- Dynamics (cast to Python float for JSON serialization) ---
70
- rms = float(np.sqrt(np.mean(mono ** 2)))
71
- peak = float(np.max(np.abs(mono)))
72
-
73
- rms_db = round(20.0 * np.log10(rms), 1) if rms > 0 else -100.0
74
- peak_db = round(20.0 * np.log10(peak), 1) if peak > 0 else -100.0
75
- crest_factor = round(peak_db - rms_db, 1)
76
- dynamic_range = crest_factor # simplified: same as crest factor for full-file
77
-
78
- # --- Stereo correlation ---
79
- is_mono = audio.ndim == 1 or audio.shape[1] == 1
80
- if not is_mono:
81
- left = audio[:, 0]
82
- right = audio[:, 1]
83
- correlation = np.corrcoef(left, right)[0, 1]
84
- stereo_correlation = round(float(correlation), 3)
85
- else:
86
- stereo_correlation = None
87
-
88
- # --- Loudness (reuse existing functions, lazy import) ---
89
- from loudness import measure_loudness, measure_true_peak
90
- lufs = measure_loudness(audio, sample_rate)
91
- true_peak = measure_true_peak(audio, sample_rate)
92
-
93
- return {
94
- "spectral_centroid_hz": round(float(spectral_centroid), 1),
95
- "spectral_rolloff_hz": round(float(spectral_rolloff), 1),
96
- "band_energy": band_energy,
97
- "rms_db": float(rms_db),
98
- "peak_db": float(peak_db),
99
- "crest_factor_db": float(crest_factor),
100
- "dynamic_range_db": float(dynamic_range),
101
- "stereo_correlation": stereo_correlation,
102
- "lufs": round(float(lufs), 1) if not np.isinf(lufs) else -100.0,
103
- "true_peak_dbtp": float(true_peak),
104
- "is_mono": is_mono,
105
- }
106
-
107
-
108
- # ---------------------------------------------------------------------------
109
- # Detailed feature extraction — Super AI mode
110
- # ---------------------------------------------------------------------------
111
-
112
- # 24 analysis bands for fine-grained spectral view
113
- _DETAIL_BANDS = [
114
- ("20-40", 20, 40),
115
- ("40-60", 40, 60),
116
- ("60-100", 60, 100),
117
- ("100-150", 100, 150),
118
- ("150-200", 150, 200),
119
- ("200-300", 200, 300),
120
- ("300-400", 300, 400),
121
- ("400-600", 400, 600),
122
- ("600-800", 600, 800),
123
- ("800-1k", 800, 1000),
124
- ("1k-1.5k", 1000, 1500),
125
- ("1.5k-2k", 1500, 2000),
126
- ("2k-3k", 2000, 3000),
127
- ("3k-4k", 3000, 4000),
128
- ("4k-5k", 4000, 5000),
129
- ("5k-6k", 5000, 6000),
130
- ("6k-8k", 6000, 8000),
131
- ("8k-10k", 8000, 10000),
132
- ("10k-12k", 10000, 12000),
133
- ("12k-16k", 12000, 16000),
134
- ("16k-20k", 16000, 20000),
135
- ]
136
-
137
- # 3 compression bands matching the DSP crossover defaults
138
- _COMP_BANDS = [
139
- ("low", 20, 200),
140
- ("mid", 200, 4000),
141
- ("high", 4000, 20000),
142
- ]
143
-
144
-
145
- def extract_features_detailed(audio, sample_rate):
146
- """Extract rich spectral + dynamic features for Super AI mode.
147
-
148
- Builds on extract_features() and adds:
149
- - 24-band spectral profile (fine-grained EQ map)
150
- - Spectral peak/resonance detection (top problematic frequencies)
151
- - Per-compression-band dynamics (RMS, peak, crest factor)
152
- - Spectral flatness (tonal vs noisy character)
153
- - Spectral tilt (bass-heavy vs bright)
154
- - Short-time dynamic variation (verse vs chorus energy)
155
- - Per-band stereo correlation
156
-
157
- All numpy — runs in milliseconds on CPU.
158
- """
159
- from scipy.signal import find_peaks
160
-
161
- base = extract_features(audio, sample_rate)
162
-
163
- if audio.ndim == 2:
164
- mono = audio.mean(axis=1)
165
- else:
166
- mono = audio
167
-
168
- # --- High-resolution spectral analysis ---
169
- nperseg = min(16384, len(mono))
170
- freqs, psd = welch(mono, fs=sample_rate, nperseg=nperseg)
171
-
172
- # 24-band spectral profile (dB)
173
- spectral_profile = {}
174
- for name, lo, hi in _DETAIL_BANDS:
175
- mask = (freqs >= lo) & (freqs < hi)
176
- if np.any(mask):
177
- band_rms = float(np.sqrt(np.mean(psd[mask])))
178
- spectral_profile[name] = round(20.0 * np.log10(max(band_rms, 1e-12)), 1)
179
- else:
180
- spectral_profile[name] = -100.0
181
-
182
- # --- Spectral peaks / resonances ---
183
- # Smooth the PSD, find prominent peaks
184
- psd_db = 10.0 * np.log10(np.maximum(psd, 1e-20))
185
- # Use a wider window for smoothing to avoid noise peaks
186
- kernel_size = min(31, len(psd_db) // 4)
187
- if kernel_size % 2 == 0:
188
- kernel_size += 1
189
- if kernel_size >= 3:
190
- kernel = np.ones(kernel_size) / kernel_size
191
- psd_smooth = np.convolve(psd_db, kernel, mode="same")
192
- else:
193
- psd_smooth = psd_db
194
-
195
- # Find peaks that stand out above the smoothed curve
196
- prominence_threshold = 3.0 # at least 3 dB above neighbors
197
- peak_indices, peak_props = find_peaks(
198
- psd_db,
199
- prominence=prominence_threshold,
200
- distance=max(1, int(50 / (freqs[1] - freqs[0]))) # at least 50 Hz apart
201
- )
202
-
203
- # Sort by prominence and take top 8
204
- if len(peak_indices) > 0:
205
- prominences = peak_props["prominences"]
206
- top_idx = np.argsort(prominences)[::-1][:8]
207
- resonances = []
208
- for idx in top_idx:
209
- pi = peak_indices[idx]
210
- if freqs[pi] >= 30: # skip sub-bass noise
211
- resonances.append({
212
- "freq_hz": round(float(freqs[pi]), 1),
213
- "level_db": round(float(psd_db[pi]), 1),
214
- "prominence_db": round(float(prominences[idx]), 1),
215
- })
216
- else:
217
- resonances = []
218
-
219
- # --- Spectral flatness (Wiener entropy) ---
220
- # 1.0 = white noise, 0.0 = pure tone
221
- psd_pos = psd[psd > 0]
222
- if len(psd_pos) > 0:
223
- geo_mean = np.exp(np.mean(np.log(psd_pos)))
224
- arith_mean = np.mean(psd_pos)
225
- spectral_flatness = round(float(geo_mean / arith_mean), 4)
226
- else:
227
- spectral_flatness = 0.0
228
-
229
- # --- Spectral tilt (slope of energy across frequency) ---
230
- # Negative = bass-heavy, positive = bright
231
- if len(freqs) > 1 and np.any(psd > 0):
232
- log_freqs = np.log10(np.maximum(freqs[1:], 1.0)) # skip DC
233
- log_psd = 10.0 * np.log10(np.maximum(psd[1:], 1e-20))
234
- coeffs = np.polyfit(log_freqs, log_psd, 1)
235
- spectral_tilt = round(float(coeffs[0]), 2) # dB/decade
236
- else:
237
- spectral_tilt = 0.0
238
-
239
- # --- Per-compression-band dynamics ---
240
- comp_band_dynamics = {}
241
- for name, lo, hi in _COMP_BANDS:
242
- mask = (freqs >= lo) & (freqs < hi)
243
- if np.any(mask):
244
- band_psd = psd[mask]
245
- band_rms = float(np.sqrt(np.mean(band_psd)))
246
- band_peak = float(np.sqrt(np.max(band_psd)))
247
- rms_db = round(20.0 * np.log10(max(band_rms, 1e-12)), 1)
248
- peak_db = round(20.0 * np.log10(max(band_peak, 1e-12)), 1)
249
- comp_band_dynamics[name] = {
250
- "rms_db": rms_db,
251
- "peak_db": peak_db,
252
- "crest_db": round(peak_db - rms_db, 1),
253
- }
254
- else:
255
- comp_band_dynamics[name] = {"rms_db": -100.0, "peak_db": -100.0, "crest_db": 0.0}
256
-
257
- # --- Short-time dynamic variation ---
258
- # Split audio into ~4-second chunks and measure RMS of each
259
- chunk_samples = int(4.0 * sample_rate)
260
- n_chunks = max(1, len(mono) // chunk_samples)
261
- chunk_rms_list = []
262
- for i in range(n_chunks):
263
- chunk = mono[i * chunk_samples : (i + 1) * chunk_samples]
264
- c_rms = float(np.sqrt(np.mean(chunk ** 2)))
265
- if c_rms > 0:
266
- chunk_rms_list.append(20.0 * np.log10(c_rms))
267
- else:
268
- chunk_rms_list.append(-100.0)
269
-
270
- if len(chunk_rms_list) > 1:
271
- chunk_arr = np.array(chunk_rms_list)
272
- dynamic_variation = {
273
- "min_rms_db": round(float(np.min(chunk_arr)), 1),
274
- "max_rms_db": round(float(np.max(chunk_arr)), 1),
275
- "range_db": round(float(np.max(chunk_arr) - np.min(chunk_arr)), 1),
276
- "std_db": round(float(np.std(chunk_arr)), 2),
277
- "n_chunks": n_chunks,
278
- }
279
- else:
280
- dynamic_variation = {
281
- "min_rms_db": chunk_rms_list[0] if chunk_rms_list else -100.0,
282
- "max_rms_db": chunk_rms_list[0] if chunk_rms_list else -100.0,
283
- "range_db": 0.0, "std_db": 0.0, "n_chunks": 1,
284
- }
285
-
286
- # --- Per-band stereo correlation ---
287
- is_mono = audio.ndim == 1 or audio.shape[1] == 1
288
- stereo_band_corr = {}
289
- if not is_mono:
290
- from scipy.signal import butter, sosfilt
291
-
292
- left = audio[:, 0].astype(np.float64)
293
- right = audio[:, 1].astype(np.float64)
294
-
295
- band_edges = [(20, 200), (200, 2000), (2000, 8000), (8000, min(20000, sample_rate // 2 - 1))]
296
- band_names = ["low", "low_mid", "high_mid", "high"]
297
-
298
- for bname, lo, hi in zip(band_names, *zip(*band_edges)):
299
- try:
300
- sos = butter(4, [lo, hi], btype="band", fs=sample_rate, output="sos")
301
- l_filt = sosfilt(sos, left)
302
- r_filt = sosfilt(sos, right)
303
- corr = np.corrcoef(l_filt, r_filt)[0, 1]
304
- stereo_band_corr[bname] = round(float(corr), 3)
305
- except Exception:
306
- stereo_band_corr[bname] = None
307
-
308
- # --- Merge into base features ---
309
- base["spectral_profile_24band"] = spectral_profile
310
- base["resonances"] = resonances
311
- base["spectral_flatness"] = spectral_flatness
312
- base["spectral_tilt_db_per_decade"] = spectral_tilt
313
- base["comp_band_dynamics"] = comp_band_dynamics
314
- base["dynamic_variation"] = dynamic_variation
315
- base["stereo_band_correlation"] = stereo_band_corr if not is_mono else None
316
-
317
- return base
318
-
319
-
320
- # ---------------------------------------------------------------------------
321
- # Gemini API wrapper
322
- # ---------------------------------------------------------------------------
323
-
324
- class GeminiUnavailableError(Exception):
325
- """Raised when Gemini API is unavailable after retries."""
326
- pass
327
-
328
-
329
- def _call_gemini(system_prompt, user_prompt):
330
- """Call Gemini 2.5 Pro via OpenRouter (preferred) or Google direct API.
331
-
332
- Uses OPENROUTER_API_KEY if set, otherwise falls back to GOOGLE_API_KEY.
333
- Retries up to 3 times on server errors (5xx) with escalating delays.
334
- Raises GeminiUnavailableError on persistent failure.
335
- """
336
- import time
337
- import requests as _requests
338
-
339
- openrouter_key = os.environ.get("OPENROUTER_API_KEY")
340
- google_key = os.environ.get("GOOGLE_API_KEY")
341
-
342
- if not openrouter_key and not google_key:
343
- return None
344
-
345
- if openrouter_key:
346
- # OpenRouter — OpenAI-compatible format
347
- url = "https://openrouter.ai/api/v1/chat/completions"
348
- headers = {
349
- "Authorization": f"Bearer {openrouter_key}",
350
- "Content-Type": "application/json",
351
- }
352
- payload = {
353
- "model": "google/gemini-2.5-pro",
354
- "messages": [
355
- {"role": "system", "content": system_prompt},
356
- {"role": "user", "content": user_prompt},
357
- ],
358
- }
359
-
360
- def _parse_response(data):
361
- return data["choices"][0]["message"]["content"]
362
- else:
363
- # Google direct API
364
- url = (
365
- "https://generativelanguage.googleapis.com/v1beta/models/"
366
- f"gemini-2.5-pro:generateContent?key={google_key}"
367
- )
368
- headers = {"Content-Type": "application/json"}
369
- payload = {
370
- "system_instruction": {"parts": [{"text": system_prompt}]},
371
- "contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
372
- }
373
-
374
- def _parse_response(data):
375
- return data["candidates"][0]["content"]["parts"][0]["text"]
376
-
377
- last_error = None
378
- retry_delays = [10, 20, 30] # 3 retries: wait 10s, 20s, 30s
379
- for attempt in range(4): # initial + 3 retries
380
- try:
381
- resp = _requests.post(url, headers=headers, json=payload, timeout=120)
382
- resp.raise_for_status()
383
- data = resp.json()
384
- return _parse_response(data)
385
- except _requests.exceptions.HTTPError as e:
386
- last_error = e
387
- status = getattr(resp, "status_code", 0)
388
- if status >= 500 and attempt < 3:
389
- time.sleep(retry_delays[attempt])
390
- continue
391
- break
392
- except Exception as e:
393
- last_error = e
394
- if attempt == 0:
395
- time.sleep(5)
396
- continue
397
- break
398
-
399
- provider = "OpenRouter" if openrouter_key else "Google Gemini"
400
- raise GeminiUnavailableError(
401
- f"**{provider} is temporarily unavailable.**\n\n"
402
- "This is an issue with the AI provider's servers, not with StudioAI.\n\n"
403
- "Please try again in a few minutes. If the problem persists, "
404
- "try again in a few hours.\n\n"
405
- "No usage credit was consumed for this attempt.\n\n"
406
- f"*Technical details: {last_error}*"
407
- )
408
-
409
-
410
- # ---------------------------------------------------------------------------
411
- # Phase 1: AI-recommended settings
412
- # ---------------------------------------------------------------------------
413
-
414
- _SIGNAL_FLOW = """
415
- SIGNAL FLOW (fixed processing order):
416
- 1. PRE-GAIN DROP — Input is normalized to -18 LUFS internal working level (stepped attenuator). This prevents EQ clipping on hot masters.
417
- 2. HPF 15 Hz — Always-on 2nd-order Butterworth high-pass filter (12 dB/oct). Subsonic cleanup only; -3 dB at 15 Hz, negligible loss above 35 Hz. There is NO low-pass filter — source is band-limited by sample rate (Nyquist).
418
- 3. 4-BAND PARAMETRIC EQ (user-adjustable):
419
- - Bass Boost: Peak filter (Q=2.0), variable center 40-100 Hz, range 0 to +3.0 dB, step 0.5 dB
420
- - Lows: Low shelf at 200 Hz (Q=1.0), range -3.0 to +3.0 dB, step 0.5 dB
421
- - Highs: High shelf at 10 kHz (Q=0.7, gentle slope), range -3.0 to +3.0 dB, step 0.5 dB — this is the "air" band, no LPF to fight it
422
- - Mids: Peak filter at 1.2 kHz (Q=1.0, wide bell), range -3.0 to +3.0 dB, step 0.1 dB
423
- 4. MULTIBAND COMPRESSION — 3-band dynamics processing (no makeup gain):
424
- - Placed before stereo width so the compressor sees EQ'd audio without M/S side-channel energy affecting per-band behaviour.
425
- - Two Linkwitz-Riley 4th-order crossovers split the signal at 200 Hz and 4 kHz.
426
- - LOGARITHMIC SLIDER CURVE: The 0-100 slider uses a quadratic (t²) mapping — the bottom half of the slider (0-50) covers the transparent-to-light range, while aggressive compression is concentrated in the top 30% (70-100). This gives fine control in the musical "sweet spot."
427
- - LOW band (< 200 Hz): Firmest control. Attack scales 80→20ms (lets kick breathe at low settings, catches bass transients at high). Ratio 1.2:1→3.0:1, threshold -16→-24 dB, release 200→120ms.
428
- - MID band (200 Hz – 4 kHz): Musical peak control. Attack scales 30→10ms (transparent at low settings, tames snare/vocal peaks at high). Ratio 1.1:1→2.5:1, threshold -14→-24 dB, release 150→100ms.
429
- - HIGH band (> 4 kHz): Transient control. Attack scales 10→3ms (light de-essing at low, catches cymbal/click transients at high). Ratio 1.05:1→2.0:1, threshold -12→-22 dB, release 80→40ms.
430
- - TRUE BYPASS when slider = 0 (no compressor in chain at all).
431
- - Higher compression values actively reduce the crest factor (peak-to-loudness ratio), which allows LUFS normalization to push louder without the true peak ceiling pulling level back down.
432
- - Bands are summed back to full-range after compression. No makeup gain — LUFS normalization handles level.
433
- 5. STEREO WIDTH — Frequency-selective M/S matrix (after dynamics for stable imaging):
434
- - Linkwitz-Riley 4th-order crossover at 200 Hz splits signal into low band and high band
435
- - Low band (< 200 Hz): untouched — keeps bass mono-safe
436
- - High band (≥ 200 Hz): M/S encode → width scaling → M/S decode
437
- - Energy-preserving: mid_scale = sqrt(2/(1+w²)), side_scale = w × mid_scale
438
- - Range: 80% (narrow) to 150% (wide). 100% = no change. Clip protection after summing.
439
- 6. LUFS NORMALIZATION — Measures integrated loudness (ITU-R BS.1770-4, K-weighted, gated) and applies uniform linear gain to hit the target LUFS exactly. Targets: -14 (streaming), -11 (CD), or custom.
440
- 7. SOFT CLIPPER — Piecewise tanh saturation after LUFS normalization. A knee sits 2 dB below the -0.1 dBTP ceiling. Everything below the knee is perfectly linear (zero processing). Only the tips of peaks above the knee are shaped with a tanh curve that asymptotes to the ceiling. This is NOT a limiter — it's analog-style waveshaping that typically affects only the top 1-2 dB of the loudest transients. LUFS is preserved (transient tips contribute almost nothing to integrated loudness) while true-peak is reduced significantly.
441
- 8. TRUE PEAK CEILING (safety net) — After the soft clipper, the true peak (4x oversampled, ITU-R BS.1770) is measured. If any residual inter-sample peaks still exceed -0.1 dBTP, the entire signal is scaled down by exactly the overshoot. This rarely engages thanks to the soft clipper, but guarantees compliance.
442
- """
443
-
444
- _RECOMMEND_SYSTEM = f"""You are an expert audio mastering engineer. Analyze the audio measurements below and recommend optimal mastering settings for this tool.
445
-
446
- {_SIGNAL_FLOW}
447
-
448
- AVAILABLE CONTROLS (these are the ONLY parameters you can recommend):
449
- - lows_db: Low shelf at 200 Hz, -3.0 to +3.0 dB
450
- - mid_boost_db: Peak at 1.2 kHz (Q=1.0), -3.0 to +3.0 dB
451
- - highs_db: High shelf at 10 kHz (Q=0.7), -3.0 to +3.0 dB
452
- - bass_boost_db: Peak (Q=2.0), 0 to +3.0 dB
453
- - bass_freq_hz: Center freq for bass boost, 40-100 Hz
454
- - compression: 0 (bypass/off) to 100 (heavy). 0 = true bypass (no processing)
455
- - stereo_width: 80-150%. 100 = no change. Only affects frequencies above 200 Hz.
456
-
457
- IMPORTANT CONTEXT:
458
- - The 15 Hz HPF is always active and cannot be adjusted — do not try to compensate for it.
459
- - There is no LPF, so the 10 kHz high shelf has full authority over the air band with no interference.
460
- - LUFS normalization at the end restores loudness automatically — do not worry about overall level, focus on spectral balance and dynamics.
461
- - TRUE PEAK CEILING: A -0.1 dBTP ceiling is enforced after LUFS normalization. If the audio has a high crest factor (large peaks relative to loudness), the ceiling will pull the final level below the target LUFS. To allow the track to hit the target LUFS, recommend enough compression to reduce the crest factor. Look at the crest_factor_db measurement — values above ~10 dB suggest compression in the 40-70 range; above ~14 dB may need 60-85.
462
- - The compression slider uses a LOGARITHMIC (quadratic) curve. Slider values 0-50 cover subtle/transparent compression. Values 50-75 are moderate. Values 75-100 are aggressive. Recommend accordingly — a slider value of 30 is very light, 50 is moderate, 70+ is firm.
463
- - If the audio already sounds well-balanced, recommend conservative or zero settings. Not everything needs processing.
464
-
465
- Return ONLY a valid JSON object with these exact keys. The "reasoning" field must contain your actual markdown explanation (3-5 bullet points explaining why you chose these values):
466
- {{
467
- "lows_db": number,
468
- "mid_boost_db": number,
469
- "highs_db": number,
470
- "bass_boost_db": number,
471
- "bass_freq_hz": integer,
472
- "compression": integer,
473
- "stereo_width": integer,
474
- "reasoning": "### AI Analysis\\n- **Lows:** reason for lows_db choice\\n- **Highs:** reason for highs_db choice\\n- ... (write your actual analysis here, do NOT return this template literally)"
475
- }}
476
-
477
- Keep values within the valid ranges. Be conservative — subtle moves are better than aggressive ones."""
478
-
479
-
480
- def _clamp_settings(d):
481
- """Clamp AI-returned slider values to valid ranges in-place and return *d*."""
482
- d["lows_db"] = max(-3.0, min(3.0, float(d.get("lows_db", 0))))
483
- d["mid_boost_db"] = max(-3.0, min(3.0, float(d.get("mid_boost_db", 0))))
484
- d["highs_db"] = max(-3.0, min(3.0, float(d.get("highs_db", 0))))
485
- d["bass_boost_db"] = max(0, min(3.0, float(d.get("bass_boost_db", 0))))
486
- d["bass_freq_hz"] = max(40, min(100, int(d.get("bass_freq_hz", 60))))
487
- d["compression"] = max(0, min(100, int(d.get("compression", 50))))
488
- d["stereo_width"] = max(80, min(150, int(d.get("stereo_width", 100))))
489
- return d
490
-
491
-
492
- def _strip_json(text):
493
- """Strip markdown code fences from a JSON response and parse it."""
494
- text = text.strip()
495
- if text.startswith("```"):
496
- lines = text.split("\n")
497
- text = "\n".join(lines[1:-1])
498
- return json.loads(text)
499
-
500
-
501
- def recommend_settings(audio_path):
502
- """Analyze raw audio and return AI-recommended mastering settings.
503
-
504
- Args:
505
- audio_path: path to the uploaded audio file.
506
-
507
- Returns:
508
- dict with recommended slider values and reasoning markdown,
509
- or None if AI is unavailable.
510
- """
511
- from dsp import load_audio
512
- audio, sr = load_audio(audio_path)
513
- features = extract_features(audio, sr)
514
-
515
- user_prompt = f"""Analyze this audio and recommend mastering settings:
516
-
517
- **Audio Measurements:**
518
- - Integrated Loudness: {features['lufs']} LUFS
519
- - True Peak: {features['true_peak_dbtp']} dBTP
520
- - RMS Level: {features['rms_db']} dB
521
- - Crest Factor: {features['crest_factor_db']} dB
522
- - Spectral Centroid: {features['spectral_centroid_hz']} Hz
523
- - Spectral Rolloff (85%): {features['spectral_rolloff_hz']} Hz
524
- - Stereo Correlation: {features['stereo_correlation'] if features['stereo_correlation'] is not None else 'N/A (mono)'}
525
- - Mono: {features['is_mono']}
526
-
527
- **Band Energy (dB):**
528
- {chr(10).join(f'- {k}: {v} dB' for k, v in features['band_energy'].items())}
529
-
530
- Return the JSON object with recommended settings."""
531
-
532
- response = _call_gemini(_RECOMMEND_SYSTEM, user_prompt)
533
- if response is None:
534
- return None
535
-
536
- # Parse JSON from response (Gemini may wrap it in markdown code fence)
537
- try:
538
- result = _strip_json(response)
539
- _clamp_settings(result)
540
-
541
- if "reasoning" not in result:
542
- result["reasoning"] = "*No explanation provided.*"
543
-
544
- return result
545
- except (json.JSONDecodeError, KeyError, TypeError):
546
- return {"reasoning": response, "parse_error": True}
547
-
548
-
549
- # ---------------------------------------------------------------------------
550
- # Phase 2: Post-master comparison report
551
- # ---------------------------------------------------------------------------
552
-
553
- _COMPARE_SYSTEM = f"""You are an expert audio mastering engineer reviewing a completed master. You are evaluating the output of a specific mastering tool with the following architecture:
554
-
555
- {_SIGNAL_FLOW}
556
-
557
- IMPORTANT — When assessing the master:
558
- - The 15 Hz HPF is always active. Any sub-bass roll-off below ~30 Hz is intentional subsonic cleanup, NOT a problem. Do not flag it.
559
- - There is no LPF. The full spectrum above the HPF is passed through, so the 10 kHz high shelf has full authority over the air band.
560
- - LUFS normalization is the final stage — it applies uniform linear gain. Loudness differences between original and mastered are intentional (target LUFS). Focus on spectral shape and dynamics, not absolute level.
561
- - Compression at slider=0 means TRUE BYPASS (compressor was not in the chain at all). Do not comment on compression characteristics if it was bypassed.
562
- - Stereo width only affects frequencies above 200 Hz (Linkwitz-Riley crossover). Bass mono-compatibility is always preserved.
563
- - When suggesting improvements, ONLY recommend changes to the 7 available controls (lows_db, mid_boost_db, highs_db, bass_boost_db, bass_freq_hz, compression 0-100, stereo_width 80-150%). Do not suggest changes the tool cannot make (e.g., adjusting per-band attack times, changing crossover frequencies, changing the HPF frequency). The multiband compression is automatic — the user only controls the single 0-100 slider.
564
- - TRUE PEAK CEILING: If the mastered true peak is at -0.1 dBTP and the LUFS is below target, the peak ceiling pulled the level down. The fix is more compression (higher slider value) to reduce crest factor, NOT removing the ceiling. Mention this trade-off when relevant.
565
- - The compression slider uses a LOGARITHMIC (quadratic) curve: 0-50 = subtle/transparent, 50-75 = moderate, 75-100 = aggressive. Factor this into your slider recommendations.
566
-
567
- Format your response as markdown with these sections:
568
- ### Overall Assessment
569
- (1-2 sentences — was the mastering effective for the material?)
570
-
571
- ### What Worked Well
572
- (bullet points referencing specific measurement changes)
573
-
574
- ### Suggested Improvements
575
- (bullet points with specific slider value recommendations using the 7 available controls. If the master is good, say so — not every master needs changes.)
576
-
577
- ### Technical Notes
578
- (any concerns about dynamics, phase coherence, or frequency balance that the available controls could address)
579
-
580
- Be concise and specific. Reference actual measurement deltas between original and mastered."""
581
-
582
-
583
- def compare_master(original, mastered, sample_rate, settings_dict, history=None):
584
- """Compare original vs mastered audio and return AI quality report.
585
-
586
- Args:
587
- original: numpy array of original audio.
588
- mastered: numpy array of mastered audio.
589
- sample_rate: int.
590
- settings_dict: dict with the mastering settings that were applied.
591
- history: list of dicts from previous analyses (optional).
592
-
593
- Returns:
594
- str: markdown-formatted comparison report, or fallback message.
595
- """
596
- orig_features = extract_features(original, sample_rate)
597
- mast_features = extract_features(mastered, sample_rate)
598
-
599
- # Build the multiband compression details from slider value
600
- from dsp import map_multiband_compression
601
- comp_val = settings_dict.get("compression", 50)
602
- band_params = map_multiband_compression(comp_val)
603
-
604
- def _fmt_band(params):
605
- return (f"threshold {params[0]:.1f} dB, ratio {params[1]:.2f}:1, "
606
- f"attack {params[2]:.0f} ms, release {params[3]:.0f} ms")
607
-
608
- history_text = _format_history(history or [])
609
-
610
- user_prompt = f"""Compare the original and mastered audio:
611
-
612
- **ORIGINAL Audio:**
613
- - Loudness: {orig_features['lufs']} LUFS | True Peak: {orig_features['true_peak_dbtp']} dBTP
614
- - RMS: {orig_features['rms_db']} dB | Crest Factor: {orig_features['crest_factor_db']} dB
615
- - Spectral Centroid: {orig_features['spectral_centroid_hz']} Hz | Rolloff: {orig_features['spectral_rolloff_hz']} Hz
616
- - Stereo Correlation: {orig_features['stereo_correlation'] if orig_features['stereo_correlation'] is not None else 'N/A (mono)'}
617
- - Band Energy: {json.dumps(orig_features['band_energy'])}
618
-
619
- **MASTERED Audio:**
620
- - Loudness: {mast_features['lufs']} LUFS | True Peak: {mast_features['true_peak_dbtp']} dBTP
621
- - RMS: {mast_features['rms_db']} dB | Crest Factor: {mast_features['crest_factor_db']} dB
622
- - Spectral Centroid: {mast_features['spectral_centroid_hz']} Hz | Rolloff: {mast_features['spectral_rolloff_hz']} Hz
623
- - Stereo Correlation: {mast_features['stereo_correlation'] if mast_features['stereo_correlation'] is not None else 'N/A (mono)'}
624
- - Band Energy: {json.dumps(mast_features['band_energy'])}
625
-
626
- **Settings Applied:**
627
- - Lows (200 Hz shelf): {settings_dict.get('lows_db', 0)} dB
628
- - Mids (1.2 kHz peak): {settings_dict.get('mid_boost_db', 0)} dB
629
- - Highs (10 kHz shelf): {settings_dict.get('highs_db', 0)} dB
630
- - Bass Boost: {settings_dict.get('bass_boost_db', 0)} dB @ {settings_dict.get('bass_freq_hz', 60)} Hz
631
- - Compression: slider {comp_val}/100 (multiband, 3 bands)
632
- - Low (< 200 Hz): {_fmt_band(band_params['low'])}
633
- - Mid (200 Hz-4 kHz): {_fmt_band(band_params['mid'])}
634
- - High (> 4 kHz): {_fmt_band(band_params['high'])}
635
- - Stereo Width: {settings_dict.get('stereo_width', 100)}%
636
- - Target LUFS: {settings_dict.get('target_lufs', -14)}{history_text}"""
637
-
638
- response = _call_gemini(_COMPARE_SYSTEM, user_prompt)
639
- if response is None:
640
- return "*Set GOOGLE_API_KEY to enable AI comparison report.*"
641
- return response
642
-
643
-
644
- # ---------------------------------------------------------------------------
645
- # Phase 3: Structured comparison (for Auto Master loop)
646
- # ---------------------------------------------------------------------------
647
-
648
- _COMPARE_STRUCTURED_SYSTEM = f"""You are an expert audio mastering engineer reviewing a completed master. You are evaluating the output of a specific mastering tool with the following architecture:
649
-
650
- {_SIGNAL_FLOW}
651
-
652
- IMPORTANT — When assessing the master:
653
- - The 15 Hz HPF is always active. Any sub-bass roll-off below ~30 Hz is intentional subsonic cleanup, NOT a problem. Do not flag it.
654
- - There is no LPF. The full spectrum above the HPF is passed through, so the 10 kHz high shelf has full authority over the air band.
655
- - LUFS normalization is the final stage — it applies uniform linear gain. Loudness differences between original and mastered are intentional (target LUFS). Focus on spectral shape and dynamics, not absolute level.
656
- - Compression at slider=0 means TRUE BYPASS (compressor was not in the chain at all). Do not comment on compression characteristics if it was bypassed.
657
- - Stereo width only affects frequencies above 200 Hz (Linkwitz-Riley crossover). Bass mono-compatibility is always preserved.
658
- - When suggesting improvements, ONLY recommend changes to the 7 available controls. Do not suggest changes the tool cannot make (e.g., adjusting per-band attack times, changing crossover frequencies, changing the HPF frequency). The multiband compression is automatic — the user only controls the single 0-100 slider.
659
- - TRUE PEAK CEILING: If the mastered true peak is at -0.1 dBTP and the LUFS is below target, the peak ceiling pulled the level down. The fix is more compression (higher slider value) to reduce crest factor, NOT removing the ceiling. Adjust your revised compression value accordingly.
660
- - The compression slider uses a LOGARITHMIC (quadratic) curve: 0-50 = subtle/transparent, 50-75 = moderate, 75-100 = aggressive. Factor this into your slider recommendations.
661
-
662
- Return ONLY a valid JSON object (no markdown fences, no extra text) with these exact keys:
663
- {{
664
- "lows_db": <number, -3.0 to +3.0>,
665
- "mid_boost_db": <number, -3.0 to +3.0>,
666
- "highs_db": <number, -3.0 to +3.0>,
667
- "bass_boost_db": <number, 0 to +3.0>,
668
- "bass_freq_hz": <integer, 40 to 100>,
669
- "compression": <integer, 0 to 100>,
670
- "stereo_width": <integer, 80 to 150>,
671
- "report": "<your full markdown comparison report here — Overall Assessment, What Worked Well, Suggested Improvements, Technical Notes>"
672
- }}
673
-
674
- The numeric values should be your REVISED recommended settings for a re-master based on what you hear in the measurements.
675
- The "report" field should contain the full markdown analysis.
676
- Be concise and specific. Reference actual measurement deltas.
677
-
678
- Do NOT return the template above literally — fill in your actual analysis and values."""
679
-
680
-
681
- def _format_history(history):
682
- """Format analysis history for inclusion in prompts."""
683
- if not history:
684
- return ""
685
- lines = ["\n\n**PREVIOUS ANALYSIS HISTORY** (oldest first — use this to avoid recommending settings that already failed or oscillating between values):"]
686
- for i, entry in enumerate(history, 1):
687
- lines.append(f"\n--- Pass {i} ---")
688
- lines.append(f"Settings tried: {json.dumps({k: v for k, v in entry.get('settings', {}).items() if k != 'target_lufs'})}")
689
- lines.append(f"Result: LUFS={entry.get('lufs', '?')}, True Peak={entry.get('true_peak', '?')} dBTP, Crest Factor={entry.get('crest_factor', '?')} dB")
690
- if entry.get("summary"):
691
- lines.append(f"AI assessment: {entry['summary']}")
692
- lines.append("\nIMPORTANT: Do NOT oscillate. If a previous pass moved a setting in one direction and it helped, continue refining in that direction. If it didn't help, try a DIFFERENT approach rather than reverting to a value that was already tried.")
693
- return "\n".join(lines)
694
-
695
-
696
- def compare_master_structured(original, mastered, sample_rate, settings_dict,
697
- history=None):
698
- """Compare original vs mastered and return structured values + report.
699
-
700
- Same analysis as compare_master() but returns a dict with revised slider
701
- values and a markdown report, for use in the Auto Master loop.
702
-
703
- Args:
704
- history: list of dicts from previous analyses (optional). Each entry
705
- has keys: settings, lufs, true_peak, crest_factor, summary.
706
-
707
- Returns:
708
- dict with keys: lows_db, mid_boost_db, highs_db, bass_boost_db,
709
- bass_freq_hz, compression, stereo_width, report.
710
- On parse error: {"report": raw_text, "parse_error": True}.
711
- On API failure: None.
712
- """
713
- orig_features = extract_features(original, sample_rate)
714
- mast_features = extract_features(mastered, sample_rate)
715
-
716
- from dsp import map_multiband_compression
717
- comp_val = settings_dict.get("compression", 50)
718
- band_params = map_multiband_compression(comp_val)
719
-
720
- def _fmt_band(params):
721
- return (f"threshold {params[0]:.1f} dB, ratio {params[1]:.2f}:1, "
722
- f"attack {params[2]:.0f} ms, release {params[3]:.0f} ms")
723
-
724
- history_text = _format_history(history or [])
725
-
726
- user_prompt = f"""Compare the original and mastered audio and return your revised settings as JSON:
727
-
728
- **ORIGINAL Audio:**
729
- - Loudness: {orig_features['lufs']} LUFS | True Peak: {orig_features['true_peak_dbtp']} dBTP
730
- - RMS: {orig_features['rms_db']} dB | Crest Factor: {orig_features['crest_factor_db']} dB
731
- - Spectral Centroid: {orig_features['spectral_centroid_hz']} Hz | Rolloff: {orig_features['spectral_rolloff_hz']} Hz
732
- - Stereo Correlation: {orig_features['stereo_correlation'] if orig_features['stereo_correlation'] is not None else 'N/A (mono)'}
733
- - Band Energy: {json.dumps(orig_features['band_energy'])}
734
-
735
- **MASTERED Audio:**
736
- - Loudness: {mast_features['lufs']} LUFS | True Peak: {mast_features['true_peak_dbtp']} dBTP
737
- - RMS: {mast_features['rms_db']} dB | Crest Factor: {mast_features['crest_factor_db']} dB
738
- - Spectral Centroid: {mast_features['spectral_centroid_hz']} Hz | Rolloff: {mast_features['spectral_rolloff_hz']} Hz
739
- - Stereo Correlation: {mast_features['stereo_correlation'] if mast_features['stereo_correlation'] is not None else 'N/A (mono)'}
740
- - Band Energy: {json.dumps(mast_features['band_energy'])}
741
-
742
- **Settings Applied:**
743
- - Lows (200 Hz shelf): {settings_dict.get('lows_db', 0)} dB
744
- - Mids (1.2 kHz peak): {settings_dict.get('mid_boost_db', 0)} dB
745
- - Highs (10 kHz shelf): {settings_dict.get('highs_db', 0)} dB
746
- - Bass Boost: {settings_dict.get('bass_boost_db', 0)} dB @ {settings_dict.get('bass_freq_hz', 60)} Hz
747
- - Compression: slider {comp_val}/100 (multiband, 3 bands)
748
- - Low (< 200 Hz): {_fmt_band(band_params['low'])}
749
- - Mid (200 Hz-4 kHz): {_fmt_band(band_params['mid'])}
750
- - High (> 4 kHz): {_fmt_band(band_params['high'])}
751
- - Stereo Width: {settings_dict.get('stereo_width', 100)}%
752
- - Target LUFS: {settings_dict.get('target_lufs', -14)}{history_text}
753
-
754
- Return the JSON object with your revised settings and comparison report."""
755
-
756
- response = _call_gemini(_COMPARE_STRUCTURED_SYSTEM, user_prompt)
757
- if response is None:
758
- return None
759
-
760
- try:
761
- result = _strip_json(response)
762
- _clamp_settings(result)
763
- if "report" not in result:
764
- result["report"] = "*No report provided.*"
765
- return result
766
- except (json.JSONDecodeError, KeyError, TypeError):
767
- return {"report": response, "parse_error": True}
768
-
769
-
770
- # ---------------------------------------------------------------------------
771
- # Super AI mode — full parametric control
772
- # ---------------------------------------------------------------------------
773
-
774
- _SUPER_SIGNAL_FLOW = """
775
- AUDIO ANALYSIS DATA YOU RECEIVE:
776
- You will receive detailed measurements for each audio file, including:
777
- - Standard: LUFS, true peak, RMS, crest factor, spectral centroid, spectral rolloff, stereo correlation
778
- - 24-Band Spectral Profile: Fine-grained energy (dB) across 21 frequency bands from 20 Hz to 20 kHz.
779
- USE THIS to make precise EQ decisions — you can see exactly where energy buildups, dips, and imbalances are.
780
- - Spectral Resonances: Top 8 most prominent spectral peaks with frequency, level, and prominence (dB above neighbors).
781
- USE THIS to identify harsh or ringing frequencies that need surgical EQ cuts.
782
- - Spectral Flatness: 0.0 = pure tonal, 1.0 = white noise. Tells you how tonal vs noisy the material is.
783
- - Spectral Tilt: dB/decade slope. Negative = bass-heavy, positive = bright. Guides overall tonal balance decisions.
784
- - Per-Compression-Band Dynamics: RMS, peak, and crest factor for each of the 3 compression bands (low/mid/high).
785
- USE THIS to set compression thresholds and ratios per band — you can see which bands need taming.
786
- - Dynamic Variation: Min/max/range/std of RMS across 4-second chunks of the track.
787
- Tells you how much the track varies (quiet verse vs loud chorus). High range = preserve dynamics. Low range = already compressed.
788
- - Per-Band Stereo Correlation: Correlation for low, low-mid, high-mid, and high frequency bands.
789
- USE THIS to make stereo width decisions — low correlation = wide, high = narrow/mono.
790
-
791
- SIGNAL FLOW (fixed processing order — you control ALL parameters):
792
- 1. PRE-GAIN DROP — Input is normalized to -18 LUFS (automatic, not adjustable).
793
- 2. HIGH-PASS FILTER — Adjustable cutoff (10-80 Hz). Default 15 Hz, 12 dB/oct Butterworth.
794
- 3. 6-BAND FULLY PARAMETRIC EQ — Each band is independently configurable:
795
- - band1 through band6: type (peak/low_shelf/high_shelf), frequency (20-20000 Hz), gain (-6 to +6 dB), Q (0.1-10.0)
796
- You can use any combination of shelf and peak filters at any frequency. The normal UI locks these to fixed frequencies and ±3 dB — you are NOT limited to that. You have full parametric EQ control.
797
- Set gain_db to 0 on any band you don't need — unused bands are bypassed automatically.
798
- 4. MULTIBAND COMPRESSION — 3-band dynamics with per-band control:
799
- - crossover_low: adjustable crossover frequency for low/mid split (default 200 Hz)
800
- - crossover_high: adjustable crossover frequency for mid/high split (default 4000 Hz)
801
- - Each band (low, mid, high) has independently adjustable: threshold (-20 to 0 dB), ratio (1.0-20.0), attack_ms (0.1-200 ms), release_ms (10-500 ms)
802
- - Ratio 1.0 = bypass for that band
803
- - No makeup gain — LUFS normalization restores level.
804
- 5. STEREO WIDTH — M/S matrix, frequency-selective (crossover at 200 Hz, bass stays mono). Range 80-150%.
805
- 6. LUFS NORMALIZATION — Automatic to target LUFS (fixed by user, DO NOT change).
806
- 7. SOFT CLIPPER — Piecewise tanh saturation, knee 2 dB below -0.1 dBTP ceiling. Always active. Linear below knee, tanh above. This is a safety net — NOT a creative tool.
807
- 8. TRUE PEAK CEILING — Safety net at -0.1 dBTP. Scales signal down if residual peaks exceed ceiling.
808
-
809
- CONSTRAINTS (DO NOT VIOLATE):
810
- - Target LUFS is fixed by the user. Do not change it.
811
- - The soft clipper and true peak ceiling must remain as-is (automatic safety nets).
812
- - You cannot add new processing stages — only adjust the parameters described above.
813
-
814
- TRUE PEAK GUIDANCE (IMPORTANT):
815
- - True peak between -1.0 and -0.1 dBTP is the IDEAL goal, but it is NOT always achievable.
816
- - Source material that is already heavily limited or compressed (e.g., AI-generated tracks from Suno, Udio, etc.) has a very low crest factor (peak-to-loudness ratio). When such material is normalized DOWN to a streaming LUFS target (e.g., -14 LUFS), the true peak will naturally drop well below -1.0 dBTP. This is correct and expected behavior.
817
- - DO NOT over-compress or crush dynamics to try to force the true peak higher. Dynamics preservation is MORE important than hitting a specific true peak number.
818
- - If the source material has a low crest factor, acknowledge this in your analysis and accept the true peak wherever it naturally lands after LUFS normalization.
819
- - Only use compression for tonal shaping and dynamic control — NEVER to artificially raise the true peak.
820
- """
821
-
822
- _SUPER_RECOMMEND_SYSTEM = f"""You are a world-class audio mastering engineer with decades of experience. You have FULL control over every parameter in the mastering chain. Analyze the audio measurements and recommend optimal settings.
823
-
824
- {_SUPER_SIGNAL_FLOW}
825
-
826
- MASTERING PHILOSOPHY:
827
- - LESS IS MORE. A great master sounds like a better version of the original, not a different song.
828
- - Most EQ moves should be ±1 to ±2 dB. Moves beyond ±3 dB are RARE and require strong justification.
829
- - If the source audio already sounds good in a frequency range, LEAVE IT ALONE. Do not EQ for the sake of EQ.
830
- - Use surgical EQ moves — small cuts are often more effective than boosts.
831
- - Compression thresholds should be set so compression only engages on peaks, NOT constantly. A threshold of -35 dB or lower means the compressor is always compressing — that destroys dynamics. Typical mastering thresholds are -15 to -8 dB.
832
- - Match compression to the genre and dynamic character of the material.
833
- - Preserve the artist's intent — enhance, don't transform.
834
- - True peak between -1.0 and -0.1 dBTP is ideal, but do NOT sacrifice dynamics to achieve it. If the source is already heavily compressed, the true peak may land below -1.0 dBTP at the target LUFS — that is acceptable.
835
-
836
- TONAL DIRECTION (apply to all masters):
837
- - Aim for a slightly WARM overall tone — a SUBTLE richness in the low-mids (200-500 Hz) and smooth, non-harsh highs. This means maybe +0.5 to +1.5 dB shelf, NOT +3 dB or more.
838
- - High shelf boosts above +1.5 dB will make the master sound harsh and brittle — avoid this.
839
-
840
- HEAVY, UNCONSTRAINED BASS — Protect low-end punch and transient impact (40-100 Hz) at all costs:
841
- - Rule 1: Prioritize additive EQ (Band 1/Band 2) for bass weight. DO NOT cut any frequencies below 150 Hz with EQ. The HPF already handles rumble removal. Any EQ band targeting frequencies below 150 Hz should have POSITIVE gain (boost) or be bypassed (0 dB). Cutting sub-bass removes the punch and weight from the track.
842
- - Rule 2: DO NOT over-compress the < 200 Hz band. If the source crest factor is already low, default the Low-Band Compressor to BYPASS (Ratio 1:1) or use a very slow attack (>60 ms) so the kick drum transient escapes untouched.
843
- - Rule 3: The sub-bass should feel physical, anchored, and wide open.
844
- - Rule 4: HPF cutoff must stay at or below 25 Hz for bass-heavy material. Only raise it above 30 Hz if measurements show significant rumble below 20 Hz.
845
-
846
- Return ONLY a valid JSON object with this exact structure (no markdown fences):
847
- {{
848
- "hpf_freq": <float, 10-80>,
849
- "eq": {{
850
- "band1": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
851
- "band2": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
852
- "band3": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
853
- "band4": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
854
- "band5": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
855
- "band6": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}}
856
- }},
857
- "compression": {{
858
- "low": {{"threshold": <float dB>, "ratio": <float>, "attack_ms": <float>, "release_ms": <float>}},
859
- "mid": {{"threshold": <float dB>, "ratio": <float>, "attack_ms": <float>, "release_ms": <float>}},
860
- "high": {{"threshold": <float dB>, "ratio": <float>, "attack_ms": <float>, "release_ms": <float>}}
861
- }},
862
- "crossover_low": <float Hz>,
863
- "crossover_high": <float Hz>,
864
- "stereo_width": <int, 80-150>,
865
- "reasoning": "### AI Analysis\\n- **EQ:** reason for EQ choices\\n- **Dynamics:** reason for compression settings\\n- **Stereo:** reason for width choice\\n(write your ACTUAL analysis — do NOT return this template literally)"
866
- }}
867
-
868
- Be musical and intentional. Every parameter should have a reason."""
869
-
870
- _SUPER_COMPARE_SYSTEM = f"""You are a world-class audio mastering engineer reviewing a completed master. You have FULL control over every parameter and can make surgical adjustments.
871
-
872
- {_SUPER_SIGNAL_FLOW}
873
-
874
- REVIEW GUIDELINES:
875
- - Compare original vs mastered measurements carefully.
876
- - Make VERY SMALL adjustments — typically ±0.5 dB EQ tweaks or 1-2 dB threshold changes. If you changed a parameter by more than ±1 dB on the previous pass, do NOT change it again unless the measurements clearly show a problem.
877
- - If something sounds good, LEAVE IT ALONE. The best revision is often the smallest one.
878
- - Compression thresholds should be -15 to -8 dB for mastering. If you see a threshold below -20 dB, raise it — that compressor is over-compressing.
879
- - Focus on what the measurements tell you: spectral balance, dynamics, stereo image.
880
- - True peak between -1.0 and -0.1 dBTP is ideal, but do NOT over-compress to force it. If the source has a low crest factor, accept the true peak wherever it lands naturally.
881
- - LUFS target is fixed — do not try to change it.
882
- - Reference the previous analysis history to avoid oscillating between settings.
883
- - Each revision should be a refinement, not a reset. Aim for 1-2 parameter changes per pass, not 5+.
884
-
885
- TONAL DIRECTION (maintain across all revisions):
886
- - The master should have a slightly WARM overall tone — SUBTLE richness in the low-mids (200-500 Hz) and smooth, non-harsh highs. Avoid clinical or brittle sound.
887
- - High shelf boosts above +1.5 dB will make the master harsh — pull them back if present.
888
-
889
- HEAVY, UNCONSTRAINED BASS — Protect low-end punch and transient impact (40-100 Hz) at all costs:
890
- - Rule 1: Prioritize additive EQ (Band 1/Band 2) for bass weight. DO NOT cut any frequencies below 150 Hz with EQ. If a previous pass cut sub-bass, UNDO that cut (set gain to 0 or positive). Cutting sub-bass removes punch and weight.
891
- - Rule 2: DO NOT over-compress the < 200 Hz band. If the source crest factor is already low, default the Low-Band Compressor to BYPASS (Ratio 1:1) or use a very slow attack (>60 ms) so the kick drum transient escapes untouched.
892
- - Rule 3: The sub-bass should feel physical, anchored, and wide open.
893
- - Rule 4: HPF cutoff must stay at or below 25 Hz for bass-heavy material. Only raise it above 30 Hz if measurements show significant rumble below 20 Hz.
894
-
895
- CREST FACTOR CHECK (passes 2-4):
896
- - If the crest factor in the < 200 Hz range decreases between passes, you have over-compressed the kick drum. BACK OFF the Low-Band compressor ratio or lengthen the attack time. Do not lose the punch.
897
- - Sidechain Emulation: Treat the Low-Band compressor as if it has a 100 Hz HPF on its sidechain detector. Do not let sustained sub-bass notes clamp down on the rhythmic transients.
898
-
899
- Return ONLY a valid JSON object with this exact structure (no markdown fences):
900
- {{
901
- "hpf_freq": <float, 10-80>,
902
- "eq": {{
903
- "band1": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
904
- "band2": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
905
- "band3": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
906
- "band4": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
907
- "band5": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}},
908
- "band6": {{"type": "<peak|low_shelf|high_shelf>", "freq": <float Hz>, "gain_db": <float>, "q": <float>}}
909
- }},
910
- "compression": {{
911
- "low": {{"threshold": <float dB>, "ratio": <float>, "attack_ms": <float>, "release_ms": <float>}},
912
- "mid": {{"threshold": <float dB>, "ratio": <float>, "attack_ms": <float>, "release_ms": <float>}},
913
- "high": {{"threshold": <float dB>, "ratio": <float>, "attack_ms": <float>, "release_ms": <float>}}
914
- }},
915
- "crossover_low": <float Hz>,
916
- "crossover_high": <float Hz>,
917
- "stereo_width": <int, 80-150>,
918
- "report": "<your full markdown comparison report — Overall Assessment, What Worked Well, Suggested Improvements, Technical Notes>"
919
- }}
920
-
921
- The numeric values should be your REVISED settings for a re-master. Make small, targeted adjustments.
922
- The "report" field must contain your actual markdown analysis."""
923
-
924
- _SUPER_FINAL_REPORT_SYSTEM = f"""You are a world-class audio mastering engineer writing a final quality report. You are evaluating whether a master meets professional distribution standards.
925
-
926
- {_SUPER_SIGNAL_FLOW}
927
-
928
- TONAL DIRECTION (evaluate against these goals):
929
- - The desired outcome is a slightly WARM overall tone with smooth, non-harsh highs.
930
- - HEAVY, UNCONSTRAINED BASS — the low end (40-100 Hz) should feel physical, punchy, and anchored. Evaluate whether the kick drum transients survived the compression stage. If sub-bass was cut by EQ or crushed by compression, flag it as a failure.
931
- - HPF should be at or below 25 Hz for bass-heavy material.
932
- - Evaluate whether the final master achieves this tonal character.
933
-
934
- Write a comprehensive final report in markdown format covering:
935
-
936
- ### Overall Assessment
937
- (Was the mastering effective? Does it meet professional standards? Does it achieve the desired warm tone with enhanced bass?)
938
-
939
- ### Spectral Balance
940
- (Evaluate frequency balance — low end warmth, midrange richness, high end smoothness)
941
-
942
- ### Dynamics & Loudness
943
- (LUFS, true peak compliance, crest factor, dynamic range preservation)
944
-
945
- ### Stereo Image
946
- (Width, mono compatibility, balance)
947
-
948
- ### Processing Summary
949
- (What the mastering chain did — EQ moves, compression character, etc.)
950
-
951
- ### Verdict
952
- (Pass/fail for streaming distribution. Any remaining concerns?)
953
-
954
- Be specific. Reference actual measurements. This is the FINAL report — no suggestions for changes, just an honest evaluation of the finished master."""
955
-
956
-
957
- def _clamp_super_params(d):
958
- """Clamp Super AI parameters to safe ranges."""
959
- d["hpf_freq"] = max(10.0, min(80.0, float(d.get("hpf_freq", 15.0))))
960
- d["stereo_width"] = max(80, min(150, int(d.get("stereo_width", 100))))
961
- d["crossover_low"] = max(80.0, min(500.0, float(d.get("crossover_low", 200.0))))
962
- d["crossover_high"] = max(1000.0, min(10000.0, float(d.get("crossover_high", 4000.0))))
963
-
964
- eq = d.get("eq", {})
965
- for bk in ("band1", "band2", "band3", "band4", "band5", "band6"):
966
- band = eq.get(bk, {})
967
- band["freq"] = max(20.0, min(20000.0, float(band.get("freq", 1000))))
968
- band["gain_db"] = max(-6.0, min(6.0, float(band.get("gain_db", 0))))
969
- band["q"] = max(0.1, min(10.0, float(band.get("q", 1.0))))
970
- if band.get("type") not in ("peak", "low_shelf", "high_shelf"):
971
- band["type"] = "peak"
972
- eq[bk] = band
973
- d["eq"] = eq
974
-
975
- comp = d.get("compression", {})
976
- for bk in ("low", "mid", "high"):
977
- bp = comp.get(bk, {})
978
- bp["threshold"] = max(-20.0, min(0.0, float(bp.get("threshold", -14.0))))
979
- bp["ratio"] = max(1.0, min(20.0, float(bp.get("ratio", 1.0))))
980
- bp["attack_ms"] = max(0.1, min(200.0, float(bp.get("attack_ms", 30.0))))
981
- bp["release_ms"] = max(10.0, min(500.0, float(bp.get("release_ms", 150.0))))
982
- comp[bk] = bp
983
- d["compression"] = comp
984
-
985
- return d
986
-
987
-
988
- def _format_super_settings(params):
989
- """Format Super AI parameters into readable text for prompts."""
990
- eq = params.get("eq", {})
991
- comp = params.get("compression", {})
992
- lines = [
993
- f"- HPF: {params.get('hpf_freq', 15)} Hz",
994
- ]
995
- for bk in ("band1", "band2", "band3", "band4", "band5", "band6"):
996
- b = eq.get(bk, {})
997
- if abs(b.get("gain_db", 0)) < 0.01:
998
- lines.append(f"- EQ {bk}: bypassed (0 dB)")
999
- else:
1000
- lines.append(f"- EQ {bk}: {b.get('type','peak')} @ {b.get('freq',1000)} Hz, "
1001
- f"{b.get('gain_db',0):+.1f} dB, Q={b.get('q',1.0):.2f}")
1002
- lines.append(f"- Crossovers: {params.get('crossover_low', 200)} Hz / {params.get('crossover_high', 4000)} Hz")
1003
- for bk in ("low", "mid", "high"):
1004
- bp = comp.get(bk, {})
1005
- lines.append(f"- Comp {bk}: threshold {bp.get('threshold',-14):.1f} dB, "
1006
- f"ratio {bp.get('ratio',1.0):.2f}:1, "
1007
- f"attack {bp.get('attack_ms',30):.1f} ms, "
1008
- f"release {bp.get('release_ms',150):.1f} ms")
1009
- lines.append(f"- Stereo Width: {params.get('stereo_width', 100)}%")
1010
- return "\n".join(lines)
1011
-
1012
-
1013
- def _format_detailed_features(features, label="Audio"):
1014
- """Format detailed features from extract_features_detailed() for prompts."""
1015
- lines = [
1016
- f"**{label} — Core Measurements:**",
1017
- f"- Loudness: {features['lufs']} LUFS | True Peak: {features['true_peak_dbtp']} dBTP",
1018
- f"- RMS: {features['rms_db']} dB | Crest Factor: {features['crest_factor_db']} dB",
1019
- f"- Spectral Centroid: {features['spectral_centroid_hz']} Hz | Rolloff: {features['spectral_rolloff_hz']} Hz",
1020
- f"- Spectral Flatness: {features.get('spectral_flatness', 'N/A')} (0=tonal, 1=noise)",
1021
- f"- Spectral Tilt: {features.get('spectral_tilt_db_per_decade', 'N/A')} dB/decade (negative=bass-heavy, positive=bright)",
1022
- f"- Stereo Correlation: {features['stereo_correlation'] if features['stereo_correlation'] is not None else 'N/A (mono)'}",
1023
- ]
1024
-
1025
- # 24-band spectral profile
1026
- profile = features.get("spectral_profile_24band")
1027
- if profile:
1028
- lines.append(f"\n**{label} — 24-Band Spectral Profile (dB):**")
1029
- for band, val in profile.items():
1030
- bar = "█" * max(0, int((val + 60) / 2)) if val > -60 else ""
1031
- lines.append(f" {band:>8s}: {val:>7.1f} dB {bar}")
1032
-
1033
- # Resonances
1034
- resonances = features.get("resonances", [])
1035
- if resonances:
1036
- lines.append(f"\n**{label} — Spectral Resonances (peaks above neighbors):**")
1037
- for r in resonances:
1038
- lines.append(f" {r['freq_hz']:>8.1f} Hz: {r['level_db']:+.1f} dB "
1039
- f"(prominence: {r['prominence_db']:.1f} dB)")
1040
-
1041
- # Per-compression-band dynamics
1042
- cbd = features.get("comp_band_dynamics")
1043
- if cbd:
1044
- lines.append(f"\n**{label} — Per-Compression-Band Dynamics:**")
1045
- for band_name in ("low", "mid", "high"):
1046
- bd = cbd.get(band_name, {})
1047
- lines.append(f" {band_name:>4s}: RMS {bd.get('rms_db', '?')} dB, "
1048
- f"Peak {bd.get('peak_db', '?')} dB, "
1049
- f"Crest {bd.get('crest_db', '?')} dB")
1050
-
1051
- # Dynamic variation
1052
- dv = features.get("dynamic_variation")
1053
- if dv:
1054
- lines.append(f"\n**{label} — Dynamic Variation (4-sec chunks, {dv.get('n_chunks', '?')} chunks):**")
1055
- lines.append(f" RMS range: {dv.get('min_rms_db', '?')} to {dv.get('max_rms_db', '?')} dB "
1056
- f"(span: {dv.get('range_db', '?')} dB, σ: {dv.get('std_db', '?')} dB)")
1057
-
1058
- # Per-band stereo correlation
1059
- sbc = features.get("stereo_band_correlation")
1060
- if sbc:
1061
- lines.append(f"\n**{label} — Per-Band Stereo Correlation:**")
1062
- for band_name in ("low", "low_mid", "high_mid", "high"):
1063
- val = sbc.get(band_name)
1064
- lines.append(f" {band_name:>8s}: {val if val is not None else 'N/A'}")
1065
-
1066
- # Original 6-band energy for backward compat
1067
- lines.append(f"\n**{label} — 6-Band Energy Summary:**")
1068
- for k, v in features.get("band_energy", {}).items():
1069
- lines.append(f" {k}: {v} dB")
1070
-
1071
- return "\n".join(lines)
1072
-
1073
-
1074
- def _format_super_history(history):
1075
- """Format Super AI analysis history for prompts."""
1076
- if not history:
1077
- return ""
1078
- lines = ["\n\n**PREVIOUS ANALYSIS HISTORY** (use this to avoid oscillating — refine, don't reset):"]
1079
- for i, entry in enumerate(history, 1):
1080
- lines.append(f"\n--- Pass {i} ---")
1081
- lines.append(f"Settings:\n{_format_super_settings(entry.get('params', {}))}")
1082
- lines.append(f"Result: LUFS={entry.get('lufs', '?')}, True Peak={entry.get('true_peak', '?')} dBTP, "
1083
- f"Crest Factor={entry.get('crest_factor', '?')} dB")
1084
- if entry.get("summary"):
1085
- lines.append(f"AI assessment: {entry['summary']}")
1086
- lines.append("\nIMPORTANT: Do NOT oscillate. Refine incrementally. If a setting helped, keep it and fine-tune. "
1087
- "If it didn't help, try a DIFFERENT approach rather than reverting.")
1088
- return "\n".join(lines)
1089
-
1090
-
1091
- def super_ai_recommend(audio_path):
1092
- """Analyze raw audio and return full-parametric AI mastering settings.
1093
-
1094
- Returns:
1095
- dict with full parameter set + reasoning, or None.
1096
- """
1097
- from dsp import load_audio
1098
- audio, sr = load_audio(audio_path)
1099
- features = extract_features_detailed(audio, sr)
1100
-
1101
- user_prompt = f"""Analyze this audio and recommend full mastering parameters:
1102
-
1103
- {_format_detailed_features(features, "INPUT")}
1104
-
1105
- Return the JSON object with your recommended full parameter set.
1106
- Use the 24-band spectral profile to make precise EQ decisions.
1107
- Use the resonances to identify frequencies that need surgical cuts.
1108
- Use the per-band dynamics to set compression thresholds and ratios.
1109
- Use the dynamic variation to decide how aggressively to compress."""
1110
-
1111
- response = _call_gemini(_SUPER_RECOMMEND_SYSTEM, user_prompt)
1112
- if response is None:
1113
- return None
1114
-
1115
- try:
1116
- result = _strip_json(response)
1117
- _clamp_super_params(result)
1118
- if "reasoning" not in result:
1119
- result["reasoning"] = "*No explanation provided.*"
1120
- return result
1121
- except (json.JSONDecodeError, KeyError, TypeError):
1122
- return {"reasoning": response, "parse_error": True}
1123
-
1124
-
1125
- def super_ai_compare(original, mastered, sample_rate, params, target_lufs,
1126
- history=None):
1127
- """Compare original vs mastered with full-parametric revision.
1128
-
1129
- Returns:
1130
- dict with revised full params + report, or None.
1131
- """
1132
- orig_features = extract_features_detailed(original, sample_rate)
1133
- mast_features = extract_features_detailed(mastered, sample_rate)
1134
- history_text = _format_super_history(history or [])
1135
-
1136
- user_prompt = f"""Compare original vs mastered audio and return revised full parameters:
1137
-
1138
- {_format_detailed_features(orig_features, "ORIGINAL")}
1139
-
1140
- {_format_detailed_features(mast_features, "MASTERED")}
1141
-
1142
- **Settings Applied:**
1143
- {_format_super_settings(params)}
1144
- - Target LUFS: {target_lufs}{history_text}
1145
-
1146
- Return the JSON with your REVISED full parameter set and comparison report.
1147
- Make SMALL, incremental adjustments — refine what's working, fix what isn't.
1148
- Compare the 24-band profiles to see exactly where the EQ moved things.
1149
- Compare per-band dynamics to evaluate compression effectiveness.
1150
- Check if resonances were tamed or if new ones were introduced."""
1151
-
1152
- response = _call_gemini(_SUPER_COMPARE_SYSTEM, user_prompt)
1153
- if response is None:
1154
- return None
1155
-
1156
- try:
1157
- result = _strip_json(response)
1158
- _clamp_super_params(result)
1159
- if "report" not in result:
1160
- result["report"] = "*No report provided.*"
1161
- return result
1162
- except (json.JSONDecodeError, KeyError, TypeError):
1163
- return {"report": response, "parse_error": True}
1164
-
1165
-
1166
- def super_ai_final_report(original, mastered, sample_rate, params, target_lufs,
1167
- history=None):
1168
- """Generate a final quality assessment report (no new settings).
1169
-
1170
- Returns:
1171
- str: markdown report.
1172
- """
1173
- orig_features = extract_features_detailed(original, sample_rate)
1174
- mast_features = extract_features_detailed(mastered, sample_rate)
1175
- history_text = _format_super_history(history or [])
1176
-
1177
- user_prompt = f"""Write a final mastering quality report for this completed master:
1178
-
1179
- {_format_detailed_features(orig_features, "ORIGINAL")}
1180
-
1181
- {_format_detailed_features(mast_features, "MASTERED")}
1182
-
1183
- **Final Settings Applied:**
1184
- {_format_super_settings(params)}
1185
- - Target LUFS: {target_lufs}{history_text}
1186
-
1187
- Write the final quality report. No suggestions — just an honest assessment of whether this master meets professional standards.
1188
- Reference specific frequency bands and measurements from the detailed analysis above."""
1189
-
1190
- response = _call_gemini(_SUPER_FINAL_REPORT_SYSTEM, user_prompt)
1191
- if response is None:
1192
- return "*AI final report unavailable.*"
1193
- return response
1194
- # v4.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,233 +1,15 @@
1
  """Audio Mastering Suite — Gradio web application."""
2
 
3
- import os
4
- import gc
5
- import time
6
  import gradio as gr
7
- from analysis import GeminiUnavailableError
8
- from dsp import master_audio, master_audio_full
9
  from presets import PRESETS
10
  from visualization import plot_waveform_comparison, plot_spectrum_comparison
11
- # Lazy-imported inside callbacks to avoid slowing app startup
12
- # from analysis import recommend_settings, compare_master
13
-
14
-
15
- _PRICING_URL = "https://huggingface.co/spaces/AnimalMonk/studio-ai-pricing"
16
-
17
-
18
- def _dsp_settings_md(dsp):
19
- """Format actual DSP parameters as a markdown table."""
20
- if not dsp:
21
- return ""
22
-
23
- lines = [
24
- "### DSP Settings",
25
- "| Parameter | Value |",
26
- "|---|---|",
27
- f"| **HPF** | {dsp.get('hpf_freq', 15):.0f} Hz |",
28
- ]
29
-
30
- # EQ — could be list (manual) or dict with band1-4 (super)
31
- eq = dsp.get("eq", {})
32
- if isinstance(eq, list):
33
- for i, e in enumerate(eq):
34
- lines.append(
35
- f"| **EQ {i+1}** | {e.get('type','peak')} @ "
36
- f"{e.get('freq',1000):.0f} Hz, {e.get('gain_db',0):+.1f} dB, "
37
- f"Q={e.get('q',1.0):.2f} |"
38
- )
39
- if not eq:
40
- lines.append("| **EQ** | flat (no adjustments) |")
41
- else:
42
- for bk in ("band1", "band2", "band3", "band4", "band5", "band6"):
43
- b = eq.get(bk, {})
44
- g = b.get("gain_db", 0)
45
- if abs(g) < 0.01:
46
- lines.append(f"| **EQ {bk}** | bypassed |")
47
- else:
48
- lines.append(
49
- f"| **EQ {bk}** | {b.get('type','peak')} @ "
50
- f"{b.get('freq',1000):.0f} Hz, {g:+.1f} dB, "
51
- f"Q={b.get('q',1.0):.2f} |"
52
- )
53
-
54
- # Crossovers
55
- if "crossover_low" in dsp:
56
- lines.append(
57
- f"| **Crossovers** | {dsp.get('crossover_low',200):.0f} / "
58
- f"{dsp.get('crossover_high',4000):.0f} Hz |"
59
- )
60
-
61
- # Compression
62
- comp = dsp.get("compression", {})
63
- if comp:
64
- for bk in ("low", "mid", "high"):
65
- bp = comp.get(bk, {})
66
- r = bp.get("ratio", 1.0)
67
- if r <= 1.0:
68
- lines.append(f"| **Comp {bk}** | bypassed |")
69
- else:
70
- lines.append(
71
- f"| **Comp {bk}** | {bp.get('threshold',-14):.0f} dB, "
72
- f"{r:.1f}:1, atk {bp.get('attack_ms',30):.0f} ms, "
73
- f"rel {bp.get('release_ms',150):.0f} ms |"
74
- )
75
- else:
76
- lines.append("| **Compression** | bypassed |")
77
-
78
- lines.append(f"| **Stereo Width** | {dsp.get('stereo_width', 100)}% |")
79
- return "\n".join(lines)
80
-
81
- _PREMIUM_MSG = (
82
- "**AI features require a StudioAI API key.**\n\n"
83
- f"**[Get an API Key]({_PRICING_URL})** — plans start at $9/month.\n\n"
84
- "*Manual mastering is always free.*"
85
- )
86
-
87
-
88
- def _check_ai_key(user_key, consume=False):
89
- """Validate user-provided AI access key via Unkey.
90
-
91
- Args:
92
- user_key: The key string from the user.
93
- consume: If True, decrement one usage credit. If False, just validate
94
- without consuming (cost=0).
95
-
96
- Returns:
97
- (valid: bool, error_msg: str, remaining: int|None)
98
- """
99
- if not user_key:
100
- return (False, _PREMIUM_MSG, None)
101
- key = user_key.strip()
102
-
103
- # Legacy key support (transition period)
104
- for env_var in ("AI_ACCESS_KEY", "AI_ACCESS_KEY_2"):
105
- expected = os.environ.get(env_var, "")
106
- if expected and key == expected.strip():
107
- return (True, "", None) # legacy keys — no usage limit
108
-
109
- # Unkey verification — checks key validity and rate limits
110
- unkey_api_id = os.environ.get("UNKEY_API_ID", "")
111
- if not unkey_api_id:
112
- # Unkey not configured — fall back to rejecting unknown keys
113
- return (False, _PREMIUM_MSG, None)
114
-
115
- try:
116
- import requests as _req
117
- unkey_root = os.environ.get("UNKEY_ROOT_KEY", "")
118
- auth_headers = {
119
- "Content-Type": "application/json",
120
- "Authorization": f"Bearer {unkey_root}",
121
- }
122
- cost = 1 if consume else 0
123
-
124
- # Try with rate limits first (subscription keys with monthly_masters)
125
- resp = _req.post(
126
- "https://api.unkey.com/v2/keys.verifyKey",
127
- headers=auth_headers,
128
- json={"key": key, "ratelimits": [{"name": "monthly_masters", "cost": cost}]},
129
- timeout=5,
130
- )
131
-
132
- # If rate limit doesn't exist on this key, Unkey returns 412.
133
- # Fall back to credits-only verification.
134
- if resp.status_code == 412:
135
- resp = _req.post(
136
- "https://api.unkey.com/v2/keys.verifyKey",
137
- headers=auth_headers,
138
- json={"key": key},
139
- timeout=5,
140
- )
141
-
142
- data = resp.json()
143
- except Exception:
144
- return (True, "", None) # fail open on network error
145
-
146
- result = data.get("data", data)
147
-
148
- if not result.get("valid", False):
149
- code = result.get("code", "UNKNOWN")
150
- msgs = {
151
- "NOT_FOUND": f"Invalid API key. [Get one here]({_PRICING_URL}).",
152
- "RATE_LIMITED": (
153
- "You've used all your AI masters this month.\n\n"
154
- f"[Upgrade your plan]({_PRICING_URL}) for more."
155
- ),
156
- "USAGE_EXCEEDED": (
157
- "You've used all your AI masters this month.\n\n"
158
- f"[Upgrade your plan]({_PRICING_URL}) for more."
159
- ),
160
- "DISABLED": "Your API key has been disabled. Contact support.",
161
- "EXPIRED": f"Your subscription has expired. [Renew here]({_PRICING_URL}).",
162
- }
163
- return (False, msgs.get(code, f"Key validation failed ({code})."), 0)
164
-
165
- # Check remaining from either rate limits or credits
166
- remaining = None
167
- ratelimits = result.get("ratelimits", [])
168
- for rl in ratelimits:
169
- if rl.get("name") == "monthly_masters":
170
- remaining = rl.get("remaining")
171
- break
172
- if remaining is None:
173
- remaining = result.get("credits", result.get("remaining"))
174
-
175
- return (True, "", remaining)
176
 
177
 
178
  # ---------------------------------------------------------------------------
179
  # Callbacks
180
  # ---------------------------------------------------------------------------
181
 
182
- def check_key_status(user_key):
183
- """Check key validity and remaining uses without consuming one."""
184
- if not user_key or not user_key.strip():
185
- return ""
186
- key = user_key.strip()
187
-
188
- # Legacy keys
189
- for env_var in ("AI_ACCESS_KEY", "AI_ACCESS_KEY_2"):
190
- expected = os.environ.get(env_var, "")
191
- if expected and key == expected.strip():
192
- return "**Key valid** — unlimited uses (legacy key)"
193
-
194
- unkey_api_id = os.environ.get("UNKEY_API_ID", "")
195
- if not unkey_api_id:
196
- return ""
197
-
198
- try:
199
- import requests as _req
200
- resp = _req.post(
201
- "https://api.unkey.com/v2/keys.verifyKey",
202
- headers={"Authorization": f"Bearer {os.environ.get('UNKEY_ROOT_KEY', '')}"},
203
- json={"key": key, "ratelimits": [{"name": "monthly_masters", "cost": 0}]},
204
- timeout=5,
205
- )
206
- data = resp.json()
207
- result = data.get("data", data)
208
- except Exception:
209
- return ""
210
-
211
- if not result.get("valid", False):
212
- code = result.get("code", "UNKNOWN")
213
- if code == "NOT_FOUND":
214
- return f"**Invalid key.** [Get one here]({_PRICING_URL})"
215
- elif code in ("RATE_LIMITED", "USAGE_EXCEEDED"):
216
- return "**No masters remaining this month.** [Upgrade]({_PRICING_URL})"
217
- elif code == "DISABLED":
218
- return "**Key disabled.** Contact support."
219
- return f"**Key error:** {code}"
220
-
221
- tier = result.get("meta", {}).get("tier", "unknown")
222
- ratelimits = result.get("ratelimits", [])
223
- for rl in ratelimits:
224
- if rl.get("name") == "monthly_masters":
225
- remaining = rl.get("remaining", "?")
226
- limit = rl.get("limit", "?")
227
- return f"**{tier.capitalize()}** — {remaining}/{limit} AI masters remaining this month"
228
- return f"**{tier.capitalize()}** — key valid"
229
-
230
-
231
  def apply_preset(preset_name):
232
  """Update all sliders when a preset is selected."""
233
  if preset_name == "-- None --" or preset_name not in PRESETS or PRESETS[preset_name] is None:
@@ -253,77 +35,6 @@ def toggle_custom_lufs(target_choice):
253
  return gr.update(visible=(target_choice == "Custom"))
254
 
255
 
256
- def _lock_btns():
257
- """Return 6 gr.Button updates to disable all AI/action buttons."""
258
- return tuple(gr.Button(interactive=False) for _ in range(6))
259
-
260
-
261
- def _unlock_btns():
262
- """Return 6 gr.Button updates to re-enable all AI/action buttons."""
263
- return tuple(gr.Button(interactive=True) for _ in range(6))
264
-
265
-
266
- def ai_recommend(audio_path, ai_access_key):
267
- """Analyze raw audio and return AI-recommended settings + reasoning.
268
-
269
- Yields 14 values: 7 sliders + ai_values_state + ai_reasoning_display + 5 buttons.
270
- """
271
- _skip = gr.Slider()
272
- _no_change = (_skip, _skip, _skip, _skip, _skip, _skip, _skip, {})
273
-
274
- valid, err, _remaining = _check_ai_key(ai_access_key)
275
- if not valid:
276
- yield (*_no_change, err, *_unlock_btns())
277
- return
278
-
279
- if audio_path is None:
280
- raise gr.Error("Please upload an audio file first.")
281
-
282
- # Immediate feedback
283
- yield (*_no_change, "# \u26a0\ufe0f AI WORKING \u2014 PLEASE WAIT\n\n---\n\n### \u23f3 Analyzing audio with Gemini\u2026\n*This takes 15-30 seconds.*",
284
- *_lock_btns(),
285
- )
286
-
287
- from analysis import recommend_settings
288
- result = recommend_settings(audio_path)
289
-
290
- if result is None:
291
- yield (*_no_change, "*Set GOOGLE_API_KEY to enable AI recommendations.*", *_unlock_btns())
292
- return
293
-
294
- if result.get("parse_error"):
295
- yield (*_no_change, result.get("reasoning", "*Could not parse AI response.*"), *_unlock_btns())
296
- return
297
-
298
- yield (
299
- gr.Slider(label=f"Lows (200 Hz) | AI: {result['lows_db']:+.1f} dB"),
300
- gr.Slider(label=f"Mids (1.2 kHz) | AI: {result['mid_boost_db']:+.1f} dB"),
301
- gr.Slider(label=f"Highs (10 kHz) | AI: {result['highs_db']:+.1f} dB"),
302
- gr.Slider(label=f"Bass Boost (dB) | AI: {result['bass_boost_db']:.1f} dB"),
303
- gr.Slider(label=f"Bass Freq (Hz) | AI: {result['bass_freq_hz']} Hz"),
304
- gr.Slider(label=f"Compression | AI: {result['compression']}"),
305
- gr.Slider(label=f"Stereo Width (%) | AI: {result['stereo_width']}%"),
306
- result,
307
- result.get("reasoning", ""),
308
- *_unlock_btns(),
309
- )
310
-
311
-
312
- def apply_ai(ai_values):
313
- """Populate sliders with AI-recommended values, reset labels."""
314
- if not ai_values:
315
- raise gr.Error("No AI recommendations yet. Click 'AI Recommend' first.")
316
- return (
317
- gr.Slider(value=ai_values["lows_db"], label="Lows (200 Hz)"),
318
- gr.Slider(value=ai_values["mid_boost_db"], label="Mids (1.2 kHz)"),
319
- gr.Slider(value=ai_values["highs_db"], label="Highs (10 kHz)"),
320
- gr.Slider(value=ai_values["bass_boost_db"], label="Bass Boost (dB)"),
321
- gr.Slider(value=ai_values["bass_freq_hz"], label="Bass Boost Frequency (Hz)"),
322
- gr.Slider(value=ai_values["compression"], label="Compression: Less <-> More"),
323
- gr.Slider(value=ai_values["stereo_width"], label="Stereo Width (%)"),
324
- )
325
-
326
-
327
  def process(audio_path, lows_db, mid_boost_db, highs_db, bass_boost_db, bass_freq_hz,
328
  comp_val, width, target_choice, custom_lufs):
329
  """Run the mastering pipeline and return all outputs."""
@@ -338,650 +49,40 @@ def process(audio_path, lows_db, mid_boost_db, highs_db, bass_boost_db, bass_fre
338
  else:
339
  target = float(custom_lufs)
340
 
 
341
  output_path, original, mastered, sr, stats = master_audio(
342
  audio_path, lows_db, mid_boost_db, highs_db, bass_boost_db, bass_freq_hz,
343
- comp_val, width, target,
344
  )
345
 
346
  # Plots
347
  waveform_fig = plot_waveform_comparison(original, mastered, sr)
348
  spectrum_fig = plot_spectrum_comparison(original, mastered, sr)
349
 
350
- # Stats markdown (split into two for side-by-side display)
351
  mono_note = "\n\n*Input is mono — stereo width adjustment was skipped.*" if stats["mono"] else ""
352
- dsp_md = _dsp_settings_md(stats.get("dsp", {}))
353
  stats_md = (
354
  "### Loudness Statistics\n"
355
  "| | LUFS | True Peak (dBTP) |\n"
356
  "|---|---|---|\n"
357
  f"| **Original** | {stats['orig_lufs']:.1f} | {stats['orig_peak']:.1f} |\n"
358
  f"| **Mastered** | {stats['mast_lufs']:.1f} | {stats['mast_peak']:.1f} |\n\n"
359
- f"{mono_note}"
 
 
 
 
 
360
  )
361
 
362
- # Store data for on-demand AI comparison
363
- master_data = {
364
- "original": original,
365
- "mastered": mastered,
366
- "sr": sr,
367
- "settings": {
368
- "lows_db": lows_db,
369
- "mid_boost_db": mid_boost_db,
370
- "highs_db": highs_db,
371
- "bass_boost_db": bass_boost_db,
372
- "bass_freq_hz": bass_freq_hz,
373
- "compression": comp_val,
374
- "stereo_width": width,
375
- "target_lufs": target,
376
- },
377
- }
378
-
379
  return (
380
  output_path,
381
  waveform_fig, spectrum_fig,
382
- dsp_md, stats_md,
383
- gr.Button("AI Compare Original vs Master", variant="secondary", visible=True),
384
  gr.DownloadButton("Download Mastered File", value=output_path, visible=True),
385
- master_data,
386
- "",
387
- )
388
-
389
-
390
- def _slider_updates(values):
391
- """Return 7 gr.Slider updates from a settings dict."""
392
- return (
393
- gr.Slider(value=values["lows_db"]),
394
- gr.Slider(value=values["mid_boost_db"]),
395
- gr.Slider(value=values["highs_db"]),
396
- gr.Slider(value=values["bass_boost_db"]),
397
- gr.Slider(value=values["bass_freq_hz"]),
398
- gr.Slider(value=values["compression"]),
399
- gr.Slider(value=values["stereo_width"]),
400
- )
401
-
402
- _NO_SLIDER_CHANGE = tuple(gr.update() for _ in range(7))
403
-
404
-
405
- def _make_history_entry(settings_dict, mast_features, summary=""):
406
- """Build a history entry dict for analysis context."""
407
- return {
408
- "settings": {k: v for k, v in settings_dict.items() if k != "target_lufs"},
409
- "lufs": mast_features.get("lufs", "?"),
410
- "true_peak": mast_features.get("true_peak_dbtp", "?"),
411
- "crest_factor": mast_features.get("crest_factor_db", "?"),
412
- "summary": summary,
413
- }
414
-
415
-
416
- def ai_compare(master_data, ai_access_key, history):
417
- """Run AI comparison report on demand (costs Gemini tokens).
418
-
419
- Yields 15 values: 7 sliders + ai_values_state + ai_report_display
420
- + analysis_history_state + 5 buttons.
421
- """
422
- SKIP = gr.update()
423
- valid, err, _remaining = _check_ai_key(ai_access_key)
424
- if not valid:
425
- yield (
426
- *_NO_SLIDER_CHANGE,
427
- SKIP,
428
- err,
429
- SKIP, # history unchanged
430
- *_unlock_btns(),
431
- )
432
- return
433
-
434
- if not master_data:
435
- raise gr.Error("Master a track first, then click AI Compare.")
436
-
437
- # Immediate feedback -- lock buttons + prominent indicator
438
- yield (
439
- *_NO_SLIDER_CHANGE,
440
- SKIP,
441
- "# \u26a0\ufe0f AI WORKING \u2014 PLEASE WAIT\n\n---\n\n### \u23f3 Comparing original vs master\u2026\n*This takes 15-30 seconds.*",
442
- SKIP, # history unchanged
443
- *_lock_btns(),
444
- )
445
- try:
446
- from analysis import compare_master_structured, extract_features
447
- result = compare_master_structured(
448
- master_data["original"],
449
- master_data["mastered"],
450
- master_data["sr"],
451
- master_data["settings"],
452
- history=history,
453
- )
454
- if result is None or result.get("parse_error"):
455
- report = result.get("report", "*AI comparison unavailable.*") if result else "*AI unavailable.*"
456
- yield (
457
- *_NO_SLIDER_CHANGE,
458
- SKIP,
459
- "---\n### AI Post-Master Analysis\n" + report,
460
- SKIP, # history unchanged
461
- *_unlock_btns(),
462
- )
463
- else:
464
- report = result.get("report", "*No report provided.*")
465
- # Add this analysis to history
466
- mast_features = extract_features(master_data["mastered"], master_data["sr"])
467
- new_history = list(history) + [_make_history_entry(
468
- master_data["settings"], mast_features,
469
- summary=report[:200] if report else "",
470
- )]
471
- yield (
472
- *_slider_updates(result),
473
- result,
474
- "---\n### AI Post-Master Analysis\n" + report,
475
- new_history,
476
- *_unlock_btns(),
477
- )
478
- except Exception as e:
479
- yield (
480
- *_NO_SLIDER_CHANGE,
481
- SKIP,
482
- f"---\n### AI Post-Master Analysis\n*AI comparison unavailable: {e}*",
483
- SKIP, # history unchanged
484
- *_unlock_btns(),
485
- )
486
-
487
- def auto_master(audio_path, ai_access_key, target_choice, custom_lufs):
488
- """Iterative AI-driven mastering loop (generator).
489
-
490
- Flow: AI Recommend → Master → AI Compare (×3 with revisions).
491
- Yields progress to 24 outputs at each step (17 + 5 lockable buttons + history + dsp).
492
- """
493
- gc.collect() # Free any previous run's data before starting
494
- SKIP = gr.update()
495
- _SLIDER_KEYS = ("lows_db", "mid_boost_db", "highs_db",
496
- "bass_boost_db", "bass_freq_hz", "compression", "stereo_width")
497
-
498
- def _yield_progress(progress, sliders=None, ai_vals=SKIP,
499
- audio=SKIP, wf=SKIP, sp=SKIP, dsp=SKIP, stats=SKIP,
500
- cmp_btn=SKIP, dl=SKIP, mdata=SKIP, report=SKIP,
501
- btns=None, history=SKIP):
502
- """Build the 24-element tuple for each yield."""
503
- s = sliders or (SKIP,) * 7
504
- b = btns or (SKIP,) * 5
505
- return (*s, ai_vals, progress,
506
- audio, wf, sp, dsp, stats, cmp_btn, dl, mdata, report, *b, history)
507
-
508
- def _slider_updates(vals):
509
- """Return 7 gr.Slider updates from a values dict."""
510
- return (
511
- gr.Slider(value=vals["lows_db"]),
512
- gr.Slider(value=vals["mid_boost_db"]),
513
- gr.Slider(value=vals["highs_db"]),
514
- gr.Slider(value=vals["bass_boost_db"]),
515
- gr.Slider(value=vals["bass_freq_hz"]),
516
- gr.Slider(value=vals["compression"]),
517
- gr.Slider(value=vals["stereo_width"]),
518
- )
519
-
520
- # --- Validation ---
521
- valid, err, _remaining = _check_ai_key(ai_access_key)
522
- if not valid:
523
- yield _yield_progress(
524
- err,
525
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
526
- )
527
- return
528
-
529
- if audio_path is None:
530
- raise gr.Error("Please upload an audio file first.")
531
-
532
- if not os.path.exists(audio_path):
533
- raise gr.Error("Audio file not found — please re-upload your file.")
534
-
535
- # Resolve target LUFS
536
- if target_choice == "-14 (Streaming)":
537
- target = -14.0
538
- elif target_choice == "-11 (CD)":
539
- target = -11.0
540
- else:
541
- target = float(custom_lufs)
542
-
543
- # === STEP 1: AI Recommend ===
544
- P = "## \U0001f3db Auto Master\n\n" # progress lines (clean, no analysis text)
545
- P += "**Pass 1 of 3**\n\n"
546
- P += "\u23f3 *Analyzing audio with Gemini...*"
547
- yield _yield_progress(P, history=[])
548
-
549
- from analysis import recommend_settings, compare_master_structured, compare_master, extract_features
550
- loop_history = [] # accumulate history across passes
551
- try:
552
- result = recommend_settings(audio_path)
553
- except GeminiUnavailableError as e:
554
- yield _yield_progress(
555
- str(e),
556
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
557
- )
558
- return
559
-
560
- if result is None:
561
- yield _yield_progress("*Set GOOGLE_API_KEY to enable AI recommendations.*")
562
- return
563
- if result.get("parse_error"):
564
- yield _yield_progress(result.get("reasoning", "*Could not parse AI response.*"))
565
- return
566
-
567
- values = {k: result[k] for k in _SLIDER_KEYS}
568
-
569
- P += "\n\n\u2705 AI recommended initial settings"
570
- P += "\n\n\u23f3 *Mastering with initial settings...*"
571
- yield _yield_progress(P, sliders=_slider_updates(values), ai_vals=values)
572
-
573
- # === STEP 2: Master pass 1 ===
574
- output_path, original, mastered, sr, stats = master_audio(
575
- audio_path,
576
- values["lows_db"], values["mid_boost_db"], values["highs_db"],
577
- values["bass_boost_db"], values["bass_freq_hz"],
578
- values["compression"], values["stereo_width"], target,
579
  )
580
 
581
- P += "\n\n\u2705 Pass 1 mastered"
582
- P += "\n\n\u23f3 *Waiting before next AI call...*"
583
- yield _yield_progress(P)
584
- time.sleep(15)
585
-
586
- P += "\n\n\u23f3 *AI analyzing master and recommending adjustments...*"
587
- yield _yield_progress(P)
588
-
589
- # === STEP 3: AI Compare (structured) — iteration 1 ===
590
- settings_dict = {**values, "target_lufs": target}
591
- mast_feat = extract_features(mastered, sr)
592
- loop_history.append(_make_history_entry(settings_dict, mast_feat, "Pass 1 initial"))
593
- try:
594
- result2 = compare_master_structured(original, mastered, sr, settings_dict,
595
- history=loop_history)
596
- except GeminiUnavailableError as e:
597
- yield _yield_progress(
598
- str(e),
599
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
600
- )
601
- return
602
-
603
- if result2 is None or result2.get("parse_error"):
604
- P += "\n\n\u26a0\ufe0f Could not parse AI adjustments \u2014 keeping current settings"
605
- else:
606
- values = {k: result2[k] for k in _SLIDER_KEYS}
607
- P += "\n\n\u2705 AI suggested adjustments applied"
608
- del result2
609
-
610
- P += "\n\n---\n\n**Pass 2 of 3**\n\n"
611
- P += "\u23f3 *Re-mastering with revised settings...*"
612
- yield _yield_progress(P, sliders=_slider_updates(values), ai_vals=values)
613
-
614
- # === STEP 4: Master pass 2 ===
615
- output_path, original, mastered, sr, stats = master_audio(
616
- audio_path,
617
- values["lows_db"], values["mid_boost_db"], values["highs_db"],
618
- values["bass_boost_db"], values["bass_freq_hz"],
619
- values["compression"], values["stereo_width"], target,
620
- )
621
-
622
- P += "\n\n\u2705 Pass 2 mastered"
623
- P += "\n\n\u23f3 *Waiting before next AI call...*"
624
- yield _yield_progress(P)
625
- time.sleep(15)
626
-
627
- P += "\n\n\u23f3 *AI analyzing and recommending final adjustments...*"
628
- yield _yield_progress(P)
629
-
630
- # === STEP 5: AI Compare (structured) — iteration 2 ===
631
- settings_dict = {**values, "target_lufs": target}
632
- mast_feat = extract_features(mastered, sr)
633
- loop_history.append(_make_history_entry(settings_dict, mast_feat, "Pass 2"))
634
- try:
635
- result3 = compare_master_structured(original, mastered, sr, settings_dict,
636
- history=loop_history)
637
- except GeminiUnavailableError as e:
638
- yield _yield_progress(
639
- str(e),
640
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
641
- )
642
- return
643
-
644
- if result3 is None or result3.get("parse_error"):
645
- P += "\n\n\u26a0\ufe0f Could not parse AI adjustments \u2014 keeping current settings"
646
- else:
647
- values = {k: result3[k] for k in _SLIDER_KEYS}
648
- P += "\n\n\u2705 AI suggested final adjustments applied"
649
- del result3
650
-
651
- P += "\n\n---\n\n**Pass 3 of 3**\n\n"
652
- P += "\u23f3 *Final mastering pass...*"
653
- yield _yield_progress(P, sliders=_slider_updates(values), ai_vals=values)
654
-
655
- # === STEP 6: Master pass 3 (final) ===
656
- output_path, original, mastered, sr, stats = master_audio(
657
- audio_path,
658
- values["lows_db"], values["mid_boost_db"], values["highs_db"],
659
- values["bass_boost_db"], values["bass_freq_hz"],
660
- values["compression"], values["stereo_width"], target,
661
- )
662
-
663
- P += "\n\n\u2705 Final master complete"
664
- P += "\n\n\u23f3 *Waiting before final AI call...*"
665
- yield _yield_progress(P)
666
- time.sleep(15)
667
-
668
- P += "\n\n\u23f3 *Running final AI analysis...*"
669
- yield _yield_progress(P)
670
-
671
- # === STEP 7: Final AI Compare (markdown only) ===
672
- mast_feat = extract_features(mastered, sr)
673
- loop_history.append(_make_history_entry({**values, "target_lufs": target}, mast_feat, "Pass 3 final"))
674
- try:
675
- final_report = compare_master(
676
- original, mastered, sr, {**values, "target_lufs": target},
677
- history=loop_history,
678
- )
679
- except GeminiUnavailableError as e:
680
- # Final report is non-critical — mastering is done
681
- final_report = str(e)
682
- except Exception as e:
683
- final_report = f"*AI comparison unavailable: {e}*"
684
-
685
- # Generate plots and stats
686
- waveform_fig = plot_waveform_comparison(original, mastered, sr)
687
- spectrum_fig = plot_spectrum_comparison(original, mastered, sr)
688
-
689
- mono_note = "\n\n*Input is mono \u2014 stereo width adjustment was skipped.*" if stats["mono"] else ""
690
- dsp_md = _dsp_settings_md(stats.get("dsp", {}))
691
- stats_md = (
692
- "### Loudness Statistics\n"
693
- "| | LUFS | True Peak (dBTP) |\n"
694
- "|---|---|---|\n"
695
- f"| **Original** | {stats['orig_lufs']:.1f} | {stats['orig_peak']:.1f} |\n"
696
- f"| **Mastered** | {stats['mast_lufs']:.1f} | {stats['mast_peak']:.1f} |\n\n"
697
- f"{mono_note}"
698
- )
699
-
700
- master_data = {
701
- "original": original,
702
- "mastered": mastered,
703
- "sr": sr,
704
- "settings": {**values, "target_lufs": target},
705
- }
706
-
707
- # Consume one usage credit now that mastering succeeded
708
- _check_ai_key(ai_access_key, consume=True)
709
-
710
- P += "\n\n\u2705 **Auto Master complete!** 3 passes, 4 AI analyses."
711
-
712
- yield (
713
- gr.Slider(value=values["lows_db"], label="Lows (200 Hz)"),
714
- gr.Slider(value=values["mid_boost_db"], label="Mids (1.2 kHz)"),
715
- gr.Slider(value=values["highs_db"], label="Highs (10 kHz)"),
716
- gr.Slider(value=values["bass_boost_db"], label="Bass Boost (dB)"),
717
- gr.Slider(value=values["bass_freq_hz"], label="Bass Boost Frequency (Hz)"),
718
- gr.Slider(value=values["compression"], label="Compression: Less <-> More"),
719
- gr.Slider(value=values["stereo_width"], label="Stereo Width (%)"),
720
- values, # ai_values_state
721
- P, # ai_reasoning_display
722
- output_path, # ab_player
723
- waveform_fig, # waveform_plot
724
- spectrum_fig, # spectrum_plot
725
- dsp_md, # dsp_display
726
- stats_md, # stats_display
727
- gr.Button("AI Compare Original vs Master",
728
- variant="secondary", visible=True), # ai_compare_btn
729
- gr.DownloadButton("Download Mastered File",
730
- value=output_path, visible=True), # download_file
731
- master_data, # master_data_state
732
- "---\n### AI Post-Master Analysis\n" + final_report, # ai_report_display
733
- gr.Button(interactive=True), # master_btn
734
- gr.Button(interactive=True), # apply_ai_btn
735
- gr.Button(interactive=True), # ai_recommend_btn
736
- gr.Button(interactive=True), # auto_master_btn
737
- gr.Button(interactive=True), # super_ai_btn
738
- loop_history, # analysis_history_state
739
- )
740
-
741
-
742
- def super_ai_master(audio_path, ai_access_key, target_choice, custom_lufs):
743
- """Super AI mastering loop with full parametric control (generator).
744
-
745
- Flow:
746
- Pass 1: AI analyzes raw audio → full-parametric settings → master
747
- Pass 2: AI compares + adjusts ALL params (small moves) → master
748
- Pass 3: Same as pass 2
749
- Pass 4: Same as pass 2
750
- Pass 5: Final AI compare — report only, no suggestions.
751
-
752
- Yields progress to 24 outputs at each step (17 + 5 lockable buttons + history + dsp).
753
- """
754
- gc.collect() # Free any previous run's data before starting
755
- SKIP = gr.update()
756
-
757
- def _yield_progress(progress, sliders=None, ai_vals=SKIP,
758
- audio=SKIP, wf=SKIP, sp=SKIP, dsp=SKIP, stats=SKIP,
759
- cmp_btn=SKIP, dl=SKIP, mdata=SKIP, report=SKIP,
760
- btns=None, history=SKIP):
761
- """Build the 24-element tuple for each yield."""
762
- s = sliders or (SKIP,) * 7
763
- b = btns or (SKIP,) * 5
764
- return (*s, ai_vals, progress,
765
- audio, wf, sp, dsp, stats, cmp_btn, dl, mdata, report, *b, history)
766
-
767
- # --- Validation ---
768
- valid, err, _remaining = _check_ai_key(ai_access_key)
769
- if not valid:
770
- yield _yield_progress(
771
- err,
772
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
773
- )
774
- return
775
-
776
- if audio_path is None:
777
- raise gr.Error("Please upload an audio file first.")
778
-
779
- if not os.path.exists(audio_path):
780
- raise gr.Error("Audio file not found — please re-upload your file.")
781
-
782
- # Resolve target LUFS
783
- if target_choice == "-14 (Streaming)":
784
- target = -14.0
785
- elif target_choice == "-11 (CD)":
786
- target = -11.0
787
- else:
788
- target = float(custom_lufs)
789
-
790
- # === PASS 1: AI full-parametric recommendation ===
791
- P = "## \U0001f9e0 Super AI Master (beta)\n\n"
792
- P += "**Pass 1 of 5** — AI analyzing audio with full parametric control\n\n"
793
- P += "\u23f3 *Deep analysis with Gemini...*"
794
- yield _yield_progress(P, history=[])
795
-
796
- from analysis import (super_ai_recommend, super_ai_compare,
797
- super_ai_final_report, extract_features)
798
-
799
- loop_history = []
800
- try:
801
- result = super_ai_recommend(audio_path)
802
- except GeminiUnavailableError as e:
803
- yield _yield_progress(
804
- str(e),
805
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
806
- )
807
- return
808
-
809
- if result is None:
810
- yield _yield_progress("*Set GOOGLE_API_KEY to enable AI recommendations.*")
811
- return
812
- if result.get("parse_error"):
813
- yield _yield_progress(result.get("reasoning", "*Could not parse AI response.*"))
814
- return
815
-
816
- # Extract the full parameter set (everything except reasoning)
817
- params = {k: v for k, v in result.items() if k != "reasoning"}
818
-
819
- P += "\n\n\u2705 AI recommended initial settings"
820
- P += "\n\n\u23f3 *Mastering with full parametric settings...*"
821
- yield _yield_progress(P)
822
-
823
- # Master pass 1
824
- output_path, original, mastered, sr, stats = master_audio_full(
825
- audio_path, params, target,
826
- )
827
-
828
- mast_feat = extract_features(mastered, sr)
829
- loop_history.append({
830
- "params": params,
831
- "lufs": mast_feat.get("lufs", "?"),
832
- "true_peak": mast_feat.get("true_peak_dbtp", "?"),
833
- "crest_factor": mast_feat.get("crest_factor_db", "?"),
834
- "summary": "Pass 1 initial recommendation",
835
- })
836
-
837
- P += "\n\n\u2705 Pass 1 mastered"
838
-
839
- # === PASSES 2-4: AI compare + adjust + master ===
840
- for pass_num in range(2, 5):
841
- P += f"\n\n---\n\n**Pass {pass_num} of 5** — AI comparing and refining\n\n"
842
- P += "\u23f3 *Waiting before next AI call...*"
843
- yield _yield_progress(P)
844
- time.sleep(15)
845
-
846
- P += "\n\n\u23f3 *AI analyzing master and adjusting parameters...*"
847
- yield _yield_progress(P)
848
-
849
- try:
850
- result_cmp = super_ai_compare(
851
- original, mastered, sr, params, target,
852
- history=loop_history,
853
- )
854
- except GeminiUnavailableError as e:
855
- yield _yield_progress(
856
- str(e),
857
- btns=tuple(gr.Button(interactive=True) for _ in range(5)),
858
- )
859
- return
860
-
861
- if result_cmp is None or result_cmp.get("parse_error"):
862
- P += "\n\n\u26a0\ufe0f Could not parse AI adjustments — keeping current settings"
863
- else:
864
- params = {k: v for k, v in result_cmp.items() if k != "report"}
865
- P += "\n\n\u2705 AI adjustments applied"
866
-
867
- P += f"\n\n\u23f3 *Re-mastering pass {pass_num}...*"
868
- yield _yield_progress(P)
869
-
870
- output_path, original, mastered, sr, stats = master_audio_full(
871
- audio_path, params, target,
872
- )
873
-
874
- mast_feat = extract_features(mastered, sr)
875
- report_snippet = ""
876
- if result_cmp and not result_cmp.get("parse_error"):
877
- report_snippet = result_cmp.get("report", "")[:200]
878
- loop_history.append({
879
- "params": params,
880
- "lufs": mast_feat.get("lufs", "?"),
881
- "true_peak": mast_feat.get("true_peak_dbtp", "?"),
882
- "crest_factor": mast_feat.get("crest_factor_db", "?"),
883
- "summary": f"Pass {pass_num}: {report_snippet}",
884
- })
885
-
886
- P += f"\n\n\u2705 Pass {pass_num} mastered"
887
-
888
- # === PASS 5: Final report only ===
889
- P += "\n\n---\n\n**Pass 5 of 5** — Final quality assessment\n\n"
890
- P += "\u23f3 *Waiting before final AI call...*"
891
- yield _yield_progress(P)
892
- time.sleep(15)
893
-
894
- P += "\n\n\u23f3 *Running final AI evaluation...*"
895
- yield _yield_progress(P)
896
-
897
- try:
898
- final_report = super_ai_final_report(
899
- original, mastered, sr, params, target,
900
- history=loop_history,
901
- )
902
- except GeminiUnavailableError as e:
903
- # Pass 5 is report-only — mastering is done, so we can still
904
- # show results even if the report fails. Don't abort here.
905
- final_report = str(e)
906
- except Exception as e:
907
- final_report = f"*AI final report unavailable: {e}*"
908
-
909
- # Generate plots and stats
910
- waveform_fig = plot_waveform_comparison(original, mastered, sr)
911
- spectrum_fig = plot_spectrum_comparison(original, mastered, sr)
912
-
913
- mono_note = "\n\n*Input is mono — stereo width adjustment was skipped.*" if stats["mono"] else ""
914
- dsp_md = _dsp_settings_md(stats.get("dsp", {}))
915
- stats_md = (
916
- "### Loudness Statistics\n"
917
- "| | LUFS | True Peak (dBTP) |\n"
918
- "|---|---|---|\n"
919
- f"| **Original** | {stats['orig_lufs']:.1f} | {stats['orig_peak']:.1f} |\n"
920
- f"| **Mastered** | {stats['mast_lufs']:.1f} | {stats['mast_peak']:.1f} |\n\n"
921
- f"{mono_note}"
922
- )
923
-
924
- # Super AI uses full parametric — don't map back to sliders
925
- # (sliders have fixed ranges that can't represent the full parameter space)
926
- master_data = {
927
- "original": original,
928
- "mastered": mastered,
929
- "sr": sr,
930
- "settings": {"target_lufs": target},
931
- }
932
-
933
- # Consume one usage credit now that mastering succeeded
934
- _check_ai_key(ai_access_key, consume=True)
935
-
936
- P += "\n\n\u2705 **Super AI Master complete!** 5 passes, full parametric control."
937
-
938
- # Build the full settings report for display
939
- eq = params.get("eq", {})
940
- comp = params.get("compression", {})
941
- settings_report = "---\n### Super AI — Applied Settings\n"
942
- settings_report += f"**HPF:** {params.get('hpf_freq', 15)} Hz\n\n"
943
- settings_report += "**EQ:**\n"
944
- for bk in ("band1", "band2", "band3", "band4", "band5", "band6"):
945
- b = eq.get(bk, {})
946
- if abs(b.get("gain_db", 0)) < 0.01:
947
- settings_report += f"- {bk}: bypassed\n"
948
- else:
949
- settings_report += (f"- {bk}: {b.get('type','peak')} @ {b.get('freq',1000):.0f} Hz, "
950
- f"{b.get('gain_db',0):+.1f} dB, Q={b.get('q',1.0):.2f}\n")
951
- settings_report += f"\n**Crossovers:** {params.get('crossover_low', 200)} / {params.get('crossover_high', 4000)} Hz\n\n"
952
- settings_report += "**Compression:**\n"
953
- for bk in ("low", "mid", "high"):
954
- bp = comp.get(bk, {})
955
- settings_report += (f"- {bk}: threshold {bp.get('threshold',-14):.1f} dB, "
956
- f"ratio {bp.get('ratio',1.0):.1f}:1, "
957
- f"attack {bp.get('attack_ms',30):.1f} ms, "
958
- f"release {bp.get('release_ms',150):.1f} ms\n")
959
- settings_report += f"\n**Stereo Width:** {params.get('stereo_width', 100)}%\n\n"
960
-
961
- # Final yield — Super AI doesn't update sliders (params exceed slider ranges)
962
- SKIP = gr.update()
963
- yield (
964
- SKIP, SKIP, SKIP, SKIP, SKIP, SKIP, SKIP, # leave sliders unchanged
965
- {}, # ai_values_state
966
- P, # ai_reasoning_display
967
- output_path, # ab_player
968
- waveform_fig, # waveform_plot
969
- spectrum_fig, # spectrum_plot
970
- dsp_md, # dsp_display
971
- stats_md, # stats_display
972
- gr.Button("AI Compare Original vs Master",
973
- variant="secondary", visible=True), # ai_compare_btn
974
- gr.DownloadButton("Download Mastered File",
975
- value=output_path, visible=True), # download_file
976
- master_data, # master_data_state
977
- settings_report + "---\n### AI Final Report\n" + final_report, # ai_report_display
978
- gr.Button(interactive=True), # master_btn
979
- gr.Button(interactive=True), # apply_ai_btn
980
- gr.Button(interactive=True), # ai_recommend_btn
981
- gr.Button(interactive=True), # auto_master_btn
982
- gr.Button(interactive=True), # super_ai_btn
983
- loop_history, # analysis_history_state
984
- )
985
 
986
 
987
  # ---------------------------------------------------------------------------
@@ -989,33 +90,15 @@ def super_ai_master(audio_path, ai_access_key, target_choice, custom_lufs):
989
  # ---------------------------------------------------------------------------
990
 
991
  with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
992
- gr.HTML(
993
- '<div style="display:flex;align-items:center;gap:16px;">'
994
- '<img src="data:image/png;base64,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" alt="s.AI logo" style="height:80px;border-radius:12px;">'
995
- '<div>'
996
- '<h1 style="margin:0;">Audio Mastering Suite</h1>'
997
- '<p style="margin:4px 0 0 0;font-size:0.9em;">by AnimalMonk &nbsp;|&nbsp; '
998
- '<a href="https://discord.gg/StudioAI" target="_blank">Join us on Discord</a></p>'
999
- '</div></div>'
1000
- )
1001
 
1002
- # --- Preset, AI Recommend & Target LUFS ---
1003
  with gr.Row():
1004
  preset_dropdown = gr.Dropdown(
1005
  label="Preset",
1006
  choices=list(PRESETS.keys()),
1007
  value="-- None --",
1008
  )
1009
- ai_access_input = gr.Textbox(
1010
- label="AI Access Key",
1011
- placeholder="Enter key to unlock AI",
1012
- type="password",
1013
- scale=1,
1014
- autofocus=False,
1015
- elem_id="ai-access-key",
1016
- )
1017
- ai_recommend_btn = gr.Button("AI Recommend", variant="secondary")
1018
- auto_master_btn = gr.Button("Auto Master", variant="primary")
1019
  target_dropdown = gr.Dropdown(
1020
  label="Target LUFS",
1021
  choices=["-14 (Streaming)", "-11 (CD)", "Custom"],
@@ -1027,31 +110,8 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1027
  visible=False,
1028
  )
1029
 
1030
- super_ai_btn = gr.Button("\U0001f9e0 Super AI (beta)", variant="primary", size="lg")
1031
-
1032
- key_status_display = gr.Markdown("")
1033
-
1034
- gr.HTML(
1035
- f'<p style="margin:0;font-size:14px;color:#ccc;">'
1036
- f'<a href="{_PRICING_URL}" target="_blank" rel="noopener" '
1037
- f'style="color:#7c6cf0;font-weight:bold;text-decoration:underline;">'
1038
- f'Get an API Key</a>'
1039
- f' &mdash; plans start at $9/month. '
1040
- f'<em>Manual mastering is always free.</em></p>'
1041
- )
1042
-
1043
- # Disable browser password autofill on the AI key field
1044
- gr.HTML("""<script>
1045
- document.addEventListener('DOMContentLoaded', function() {
1046
- var el = document.querySelector('#ai-access-key input');
1047
- if (el) { el.autocomplete = 'off'; el.setAttribute('autocomplete', 'off'); }
1048
- });
1049
- // Gradio may render late — retry after a short delay
1050
- setTimeout(function() {
1051
- var el = document.querySelector('#ai-access-key input');
1052
- if (el) { el.autocomplete = 'off'; el.setAttribute('autocomplete', 'off'); }
1053
- }, 1500);
1054
- </script>""", visible=False)
1055
 
1056
  # --- Upload (full width) ---
1057
  audio_input = gr.Audio(
@@ -1060,12 +120,6 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1060
  sources=["upload"],
1061
  )
1062
 
1063
- # --- AI Recommendations (below upload) ---
1064
- ai_reasoning_display = gr.Markdown(value="", visible=True)
1065
-
1066
- # --- Apply AI Recommended Settings (below AI reasoning) ---
1067
- apply_ai_btn = gr.Button("Apply AI Recommended Settings", variant="secondary")
1068
-
1069
  # --- Control sliders ---
1070
  with gr.Row():
1071
  lows_slider = gr.Slider(
@@ -1077,7 +131,7 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1077
  minimum=-3.0, maximum=3.0, value=0.0, step=0.1,
1078
  )
1079
  highs_slider = gr.Slider(
1080
- label="Highs (10 kHz)",
1081
  minimum=-3.0, maximum=3.0, value=0.0, step=0.1,
1082
  )
1083
 
@@ -1089,7 +143,7 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1089
  )
1090
  bass_freq_slider = gr.Slider(
1091
  label="Bass Boost Frequency (Hz)",
1092
- minimum=40, maximum=100, value=60, step=1,
1093
  )
1094
  with gr.Column():
1095
  comp_slider = gr.Slider(
@@ -1101,30 +155,16 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1101
  minimum=80, maximum=150, value=100, step=1,
1102
  )
1103
 
1104
- # --- State for AI values, master data, and analysis history ---
1105
- master_data_state = gr.State({})
1106
- ai_values_state = gr.State({})
1107
- analysis_history_state = gr.State([])
1108
-
1109
- # --- Master button ---
1110
- master_btn = gr.Button("Master It!", variant="primary", size="lg")
1111
-
1112
- # --- AI Post-Master Comparison (above playback) ---
1113
- ai_report_display = gr.Markdown(value="", visible=True, label="AI Analysis")
1114
-
1115
  # --- Playback ---
1116
  ab_player = gr.Audio(label="Mastered", interactive=False)
1117
- ai_compare_btn = gr.Button("AI Compare Original vs Master", variant="secondary", visible=False)
1118
- download_file = gr.DownloadButton("Download Mastered File", visible=False)
1119
 
1120
  # --- Visualization ---
1121
  with gr.Row():
1122
  waveform_plot = gr.Plot(label="Waveform Comparison")
1123
  spectrum_plot = gr.Plot(label="Spectrum Comparison")
1124
 
1125
- with gr.Row():
1126
- dsp_display = gr.Markdown()
1127
- stats_display = gr.Markdown()
1128
 
1129
  # --- Event wiring ---
1130
  preset_dropdown.change(
@@ -1141,36 +181,6 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1141
  outputs=[custom_lufs_input],
1142
  )
1143
 
1144
- ai_access_input.change(
1145
- check_key_status,
1146
- inputs=[ai_access_input],
1147
- outputs=[key_status_display],
1148
- )
1149
-
1150
- ai_recommend_btn.click(
1151
- ai_recommend,
1152
- inputs=[audio_input, ai_access_input],
1153
- outputs=[
1154
- lows_slider, mid_boost_slider, highs_slider,
1155
- bass_boost_slider, bass_freq_slider,
1156
- comp_slider, width_slider,
1157
- ai_values_state,
1158
- ai_reasoning_display,
1159
- master_btn, apply_ai_btn, ai_recommend_btn,
1160
- auto_master_btn, ai_compare_btn, super_ai_btn,
1161
- ],
1162
- )
1163
-
1164
- apply_ai_btn.click(
1165
- apply_ai,
1166
- inputs=[ai_values_state],
1167
- outputs=[
1168
- lows_slider, mid_boost_slider, highs_slider,
1169
- bass_boost_slider, bass_freq_slider,
1170
- comp_slider, width_slider,
1171
- ],
1172
- )
1173
-
1174
  master_btn.click(
1175
  process,
1176
  inputs=[
@@ -1182,72 +192,11 @@ with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
1182
  outputs=[
1183
  ab_player,
1184
  waveform_plot, spectrum_plot,
1185
- dsp_display, stats_display,
1186
- ai_compare_btn, download_file,
1187
- master_data_state,
1188
- ai_report_display,
1189
- ],
1190
- )
1191
-
1192
- ai_compare_btn.click(
1193
- ai_compare,
1194
- inputs=[master_data_state, ai_access_input, analysis_history_state],
1195
- outputs=[
1196
- lows_slider, mid_boost_slider, highs_slider,
1197
- bass_boost_slider, bass_freq_slider,
1198
- comp_slider, width_slider,
1199
- ai_values_state,
1200
- ai_report_display,
1201
- analysis_history_state,
1202
- master_btn, apply_ai_btn, ai_recommend_btn,
1203
- auto_master_btn, ai_compare_btn, super_ai_btn,
1204
- ],
1205
- )
1206
-
1207
- auto_master_btn.click(
1208
- auto_master,
1209
- inputs=[audio_input, ai_access_input, target_dropdown, custom_lufs_input],
1210
- outputs=[
1211
- lows_slider, mid_boost_slider, highs_slider,
1212
- bass_boost_slider, bass_freq_slider,
1213
- comp_slider, width_slider,
1214
- ai_values_state,
1215
- ai_reasoning_display,
1216
- ab_player,
1217
- waveform_plot, spectrum_plot,
1218
- dsp_display, stats_display,
1219
- ai_compare_btn, download_file,
1220
- master_data_state,
1221
- ai_report_display,
1222
- master_btn, apply_ai_btn, ai_recommend_btn,
1223
- auto_master_btn, super_ai_btn,
1224
- analysis_history_state,
1225
  ],
1226
  )
1227
 
1228
- super_ai_btn.click(
1229
- super_ai_master,
1230
- inputs=[audio_input, ai_access_input, target_dropdown, custom_lufs_input],
1231
- outputs=[
1232
- lows_slider, mid_boost_slider, highs_slider,
1233
- bass_boost_slider, bass_freq_slider,
1234
- comp_slider, width_slider,
1235
- ai_values_state,
1236
- ai_reasoning_display,
1237
- ab_player,
1238
- waveform_plot, spectrum_plot,
1239
- dsp_display, stats_display,
1240
- ai_compare_btn, download_file,
1241
- master_data_state,
1242
- ai_report_display,
1243
- master_btn, apply_ai_btn, ai_recommend_btn,
1244
- auto_master_btn, super_ai_btn,
1245
- analysis_history_state,
1246
- ],
1247
- )
1248
 
1249
 
1250
  if __name__ == "__main__":
1251
- import os as _os
1252
- _os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
1253
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False, ssr_mode=False)
 
1
  """Audio Mastering Suite — Gradio web application."""
2
 
 
 
 
3
  import gradio as gr
4
+ from dsp import master_audio
 
5
  from presets import PRESETS
6
  from visualization import plot_waveform_comparison, plot_spectrum_comparison
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  # ---------------------------------------------------------------------------
10
  # Callbacks
11
  # ---------------------------------------------------------------------------
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def apply_preset(preset_name):
14
  """Update all sliders when a preset is selected."""
15
  if preset_name == "-- None --" or preset_name not in PRESETS or PRESETS[preset_name] is None:
 
35
  return gr.update(visible=(target_choice == "Custom"))
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def process(audio_path, lows_db, mid_boost_db, highs_db, bass_boost_db, bass_freq_hz,
39
  comp_val, width, target_choice, custom_lufs):
40
  """Run the mastering pipeline and return all outputs."""
 
49
  else:
50
  target = float(custom_lufs)
51
 
52
+ # Master (limiter hardcoded to -1 dB)
53
  output_path, original, mastered, sr, stats = master_audio(
54
  audio_path, lows_db, mid_boost_db, highs_db, bass_boost_db, bass_freq_hz,
55
+ comp_val, -1.0, width, target,
56
  )
57
 
58
  # Plots
59
  waveform_fig = plot_waveform_comparison(original, mastered, sr)
60
  spectrum_fig = plot_spectrum_comparison(original, mastered, sr)
61
 
62
+ # Stats markdown
63
  mono_note = "\n\n*Input is mono — stereo width adjustment was skipped.*" if stats["mono"] else ""
64
+ warning_note = f"\n\n**Warning:** {stats['warning']}" if stats.get("warning") else ""
65
  stats_md = (
66
  "### Loudness Statistics\n"
67
  "| | LUFS | True Peak (dBTP) |\n"
68
  "|---|---|---|\n"
69
  f"| **Original** | {stats['orig_lufs']:.1f} | {stats['orig_peak']:.1f} |\n"
70
  f"| **Mastered** | {stats['mast_lufs']:.1f} | {stats['mast_peak']:.1f} |\n\n"
71
+ "### Gain Staging\n"
72
+ "| Metric | dB |\n"
73
+ "|---|---|\n"
74
+ f"| Auto Gain Reduction | {stats['auto_gain_reduction']:.1f} |\n"
75
+ f"| Limiter Gain Reduction | {stats['limiter_gain_reduction']:.1f} |"
76
+ f"{mono_note}{warning_note}"
77
  )
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  return (
80
  output_path,
81
  waveform_fig, spectrum_fig,
82
+ stats_md,
 
83
  gr.DownloadButton("Download Mastered File", value=output_path, visible=True),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  )
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
 
88
  # ---------------------------------------------------------------------------
 
90
  # ---------------------------------------------------------------------------
91
 
92
  with gr.Blocks(title="Audio Mastering Suite", theme=gr.themes.Soft()) as demo:
93
+ gr.Markdown("# Audio Mastering Suite\n##### by AnimalMonk")
 
 
 
 
 
 
 
 
94
 
95
+ # --- Preset & Target LUFS (side by side) ---
96
  with gr.Row():
97
  preset_dropdown = gr.Dropdown(
98
  label="Preset",
99
  choices=list(PRESETS.keys()),
100
  value="-- None --",
101
  )
 
 
 
 
 
 
 
 
 
 
102
  target_dropdown = gr.Dropdown(
103
  label="Target LUFS",
104
  choices=["-14 (Streaming)", "-11 (CD)", "Custom"],
 
110
  visible=False,
111
  )
112
 
113
+ # --- Master button ---
114
+ master_btn = gr.Button("Master It!", variant="primary", size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  # --- Upload (full width) ---
117
  audio_input = gr.Audio(
 
120
  sources=["upload"],
121
  )
122
 
 
 
 
 
 
 
123
  # --- Control sliders ---
124
  with gr.Row():
125
  lows_slider = gr.Slider(
 
131
  minimum=-3.0, maximum=3.0, value=0.0, step=0.1,
132
  )
133
  highs_slider = gr.Slider(
134
+ label="Highs (6 kHz)",
135
  minimum=-3.0, maximum=3.0, value=0.0, step=0.1,
136
  )
137
 
 
143
  )
144
  bass_freq_slider = gr.Slider(
145
  label="Bass Boost Frequency (Hz)",
146
+ minimum=50, maximum=60, value=55, step=1,
147
  )
148
  with gr.Column():
149
  comp_slider = gr.Slider(
 
155
  minimum=80, maximum=150, value=100, step=1,
156
  )
157
 
 
 
 
 
 
 
 
 
 
 
 
158
  # --- Playback ---
159
  ab_player = gr.Audio(label="Mastered", interactive=False)
 
 
160
 
161
  # --- Visualization ---
162
  with gr.Row():
163
  waveform_plot = gr.Plot(label="Waveform Comparison")
164
  spectrum_plot = gr.Plot(label="Spectrum Comparison")
165
 
166
+ stats_display = gr.Markdown()
167
+ download_file = gr.DownloadButton("Download Mastered File", visible=False)
 
168
 
169
  # --- Event wiring ---
170
  preset_dropdown.change(
 
181
  outputs=[custom_lufs_input],
182
  )
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  master_btn.click(
185
  process,
186
  inputs=[
 
192
  outputs=[
193
  ab_player,
194
  waveform_plot, spectrum_plot,
195
+ stats_display, download_file,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  ],
197
  )
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
 
201
  if __name__ == "__main__":
202
+ demo.launch()
 
 
build_schematic.py DELETED
@@ -1,1064 +0,0 @@
1
- """Generate ANALOG_SCHEMATIC.pdf v4.2 — clean graphical layout."""
2
-
3
- import os
4
- from reportlab.lib.pagesizes import letter
5
- from reportlab.lib.units import inch
6
- from reportlab.lib.colors import HexColor, black, white
7
- from reportlab.pdfgen import canvas
8
- from reportlab.pdfbase import pdfmetrics
9
- from reportlab.pdfbase.ttfonts import TTFont
10
-
11
- # ── Fonts ──────────────────────────────────────────────────────────────────
12
- FONT_DIR = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")
13
- for name, filename in [
14
- ("Cal", "calibri.ttf"),
15
- ("CalB", "calibrib.ttf"),
16
- ("CalI", "calibrii.ttf"),
17
- ("Con", "consola.ttf"),
18
- ]:
19
- path = os.path.join(FONT_DIR, filename)
20
- if os.path.exists(path):
21
- pdfmetrics.registerFont(TTFont(name, path))
22
-
23
- BODY = "Cal"
24
- BOLD = "CalB"
25
- ITALIC = "CalI"
26
- MONO = "Con"
27
-
28
- # ── Colors ─────────────────────────────────────────────────────────────────
29
- BG_DARK = HexColor("#1a1a2e")
30
- BG_STAGE = HexColor("#f4f4f8")
31
- BG_SUB = HexColor("#ffffff")
32
- ACCENT = HexColor("#3a6ea5")
33
- ACCENT2 = HexColor("#6b4c9a")
34
- BORDER = HexColor("#cccccc")
35
- TEXT_DARK = HexColor("#1a1a1a")
36
- TEXT_MID = HexColor("#555555")
37
- TEXT_LIGHT = HexColor("#999999")
38
- ARROW_CLR = HexColor("#3a6ea5")
39
- WARM_RED = HexColor("#b5442d")
40
- WARM_ORANGE = HexColor("#c47a20")
41
-
42
- OUTPUT = "ANALOG_SCHEMATIC.pdf"
43
- W, H = letter
44
- M = 0.6 * inch # margin
45
-
46
- # ── Drawing helpers ────────────────────────────────────────────────────────
47
-
48
- def draw_arrow_down(c, x, y_top, y_bot, color=ARROW_CLR):
49
- """Draw a downward arrow from y_top to y_bot at x."""
50
- c.setStrokeColor(color)
51
- c.setLineWidth(1.5)
52
- c.line(x, y_top, x, y_bot + 6)
53
- # arrowhead
54
- c.setFillColor(color)
55
- p = c.beginPath()
56
- p.moveTo(x, y_bot)
57
- p.lineTo(x - 4, y_bot + 8)
58
- p.lineTo(x + 4, y_bot + 8)
59
- p.close()
60
- c.drawPath(p, fill=1, stroke=0)
61
-
62
-
63
- def stage_box(c, x, y, w, h, title, content_fn, stage_num=None):
64
- """Draw a rounded stage box with title bar and call content_fn for body."""
65
- r = 6
66
- # Shadow
67
- c.setFillColor(HexColor("#e0e0e0"))
68
- c.roundRect(x + 2, y - 2, w, h, r, fill=1, stroke=0)
69
- # Main box
70
- c.setFillColor(BG_STAGE)
71
- c.setStrokeColor(BORDER)
72
- c.setLineWidth(0.75)
73
- c.roundRect(x, y, w, h, r, fill=1, stroke=1)
74
- # Title bar
75
- title_h = 22
76
- c.setFillColor(ACCENT)
77
- c.roundRect(x, y + h - title_h, w, title_h, r, fill=1, stroke=0)
78
- # Fix bottom corners of title bar
79
- c.rect(x, y + h - title_h, w, r, fill=1, stroke=0)
80
- # Title text
81
- c.setFillColor(white)
82
- c.setFont(BOLD, 10)
83
- label = f"STAGE {stage_num}: {title}" if stage_num else title
84
- c.drawString(x + 10, y + h - 16, label)
85
- # Body area
86
- if content_fn:
87
- content_fn(c, x + 10, y + h - title_h - 6, w - 20)
88
-
89
-
90
- def sub_box(c, x, y, w, h, title=None, color=None):
91
- """Draw a smaller inner box. Returns (x, y) of content start."""
92
- c.setFillColor(color or BG_SUB)
93
- c.setStrokeColor(HexColor("#dddddd"))
94
- c.setLineWidth(0.5)
95
- c.roundRect(x, y, w, h, 4, fill=1, stroke=1)
96
- if title:
97
- c.setFillColor(TEXT_DARK)
98
- c.setFont(BOLD, 8.5)
99
- c.drawString(x + 6, y + h - 12, title)
100
- return x + 6, y + h - 24
101
- return x + 6, y + h - 12
102
-
103
-
104
- def text(c, x, y, s, font=None, size=8.5, color=None):
105
- """Draw a string, return y below it."""
106
- c.setFillColor(color or TEXT_DARK)
107
- c.setFont(font or BODY, size)
108
- c.drawString(x, y, s)
109
- return y - size - 2
110
-
111
-
112
- def text_pair(c, x, y, label, value, lw=120):
113
- """Draw a bold label + normal value on the same line."""
114
- c.setFillColor(TEXT_DARK)
115
- c.setFont(BOLD, 8)
116
- c.drawString(x, y, label)
117
- c.setFont(BODY, 8)
118
- c.drawString(x + lw, y, value)
119
- return y - 12
120
-
121
-
122
- def equiv(c, x, y, gear):
123
- """Draw an 'Equivalent:' line in italic."""
124
- c.setFillColor(TEXT_MID)
125
- c.setFont(ITALIC, 7.5)
126
- c.drawString(x, y, f"Analog equivalent: {gear}")
127
- return y - 10
128
-
129
-
130
- # ══════════════════════════════════════════════════════════════════════════
131
- # PAGE 1
132
- # ══════════════════════════════════════════════════════════════════════════
133
-
134
- def page1(c):
135
- # ── Title banner ──────────────────────────────────────────────────
136
- bh = 52
137
- c.setFillColor(BG_DARK)
138
- c.rect(0, H - bh, W, bh, fill=1, stroke=0)
139
- c.setFillColor(white)
140
- c.setFont(BOLD, 16)
141
- c.drawCentredString(W / 2, H - 24, "s.AI AUDIO MASTERING SUITE")
142
- c.setFont(BODY, 10)
143
- c.setFillColor(HexColor("#aaaacc"))
144
- c.drawCentredString(W / 2, H - 42, "Analog Signal Flow Schematic \u2022 v4.2 \u2022 Stereo")
145
-
146
- # ── I/O labels ────────────────────────────────────────────────────
147
- top_y = H - bh - 18
148
- c.setFillColor(TEXT_MID)
149
- c.setFont(MONO, 7.5)
150
- c.drawString(M, top_y, "INPUT: L/R XLR Balanced +4 dBu")
151
- c.drawRightString(W - M, top_y, "OUTPUT: L/R XLR Balanced +4 dBu \u2022 24-bit WAV")
152
-
153
- # ── Stage 1: Input Gain ───────────────────────────────────────────
154
- s1_y = H - bh - 38
155
- s1_h = 100
156
- s1_x = M
157
- s1_w = W - 2 * M
158
-
159
- def s1_body(c, x, y, w):
160
- y = text(c, x, y, "Stepped attenuator (resistor ladder, Elma gold-contact switch)", BODY, 8.5)
161
- y -= 4
162
- y = text(c, x, y, "Normalizes input to -18 LUFS internal working level.", BODY, 8.5)
163
- y = text(c, x, y, "Prevents EQ stage clipping on hot masters (e.g. Suno at -9 LUFS).", BODY, 8.5)
164
- y -= 4
165
- y = text(c, x, y, "Stepped positions: -3 / -6 / -9 / -12 / -15 / -18 dB", MONO, 7.5, TEXT_MID)
166
- y -= 4
167
- y = equiv(c, x, y, "Dangerous Music Liaison / Crookwood M1")
168
-
169
- stage_box(c, s1_x, s1_y - s1_h, s1_w, s1_h, "INPUT GAIN", s1_body, 1)
170
-
171
- # Arrow
172
- arr_x = W / 2
173
- draw_arrow_down(c, arr_x, s1_y - s1_h, s1_y - s1_h - 22)
174
-
175
- # ── Stage 2: Subsonic Filter + EQ ─────────────────────────────────
176
- s2_y = s1_y - s1_h - 24
177
- s2_h = 380
178
- s2_x = M
179
- s2_w = W - 2 * M
180
-
181
- def s2_body(c, x, y, w):
182
- # HPF sub-box
183
- hpf_h = 72
184
- hpf_x = x
185
- hpf_w = w
186
- bx, by = sub_box(c, hpf_x, y - hpf_h, hpf_w, hpf_h, "HPF 15 Hz")
187
- by = text(c, bx, by, "2nd-order Butterworth (12 dB/oct) \u2022 Passive LC network", BODY, 8)
188
- by -= 2
189
- by = text(c, bx, by, "-3 dB at 15 Hz, negligible loss above 35 Hz (-0.1 dB)", BODY, 8)
190
- by -= 2
191
- by = text(c, bx, by, "LPF removed in v3.4 \u2014 source band-limited by sample rate;", BODY, 8, TEXT_MID)
192
- by = text(c, bx, by, "old 20 kHz LPF fought the 10 kHz high shelf in the air zone", BODY, 8, TEXT_MID)
193
- by -= 2
194
- equiv(c, bx, by, "Sontec MEP-250A filter section")
195
-
196
- y = y - hpf_h - 6
197
- draw_arrow_down(c, x + w / 2, y + 4, y - 10)
198
- y -= 14
199
-
200
- # EQ sub-box
201
- eq_h = 258
202
- bx, by = sub_box(c, x, y - eq_h, w, eq_h, "6-BAND PARAMETRIC EQ")
203
- by = text(c, bx, by, "Discrete Class-A op-amps, inductorless gyrator topology", ITALIC, 7.5, TEXT_MID)
204
- by -= 6
205
-
206
- bands = [
207
- ("BAND 1: BASS BOOST", "PeakFilter \u2022 Q = 2.0 \u2022 40\u2013100 Hz",
208
- "Narrow peak, 0 to +3 dB \u2022 Twin-T notch / gyrator",
209
- "Pultec EQP-1A low-end boost"),
210
- ("BAND 2: LOWS SHELF", "LowShelf \u2022 Q = 1.0 \u2022 200 Hz",
211
- "Broad shelf below 200 Hz, \u00b13 dB \u2022 RC shelf network",
212
- "Manley Massive Passive low band"),
213
- ("BAND 3: HIGHS SHELF", "HighShelf \u2022 Q = 0.7 \u2022 10 kHz",
214
- "Broad shelf above 10 kHz (\"Air\" band), \u00b13 dB \u2022 Gentle slope",
215
- "Maselec MEA-2 air band"),
216
- ("BAND 4: MIDS PEAK", "PeakFilter \u2022 Q = 1.0 \u2022 1.2 kHz",
217
- "Wide bell centered at 1.2 kHz, \u00b13 dB \u2022 Gyrator parametric",
218
- "GML 8200 / Sontec MEP-250A"),
219
- ]
220
-
221
- for i, (name, params, desc, eq_gear) in enumerate(bands):
222
- band_h = 46
223
- band_y = by - band_h
224
- band_color = HexColor("#f0f4fa") if i % 2 == 0 else HexColor("#faf0f4")
225
- c.setFillColor(band_color)
226
- c.roundRect(bx - 2, band_y, w - 12, band_h, 3, fill=1, stroke=0)
227
- ty = band_y + band_h - 12
228
- ty = text(c, bx + 2, ty, name, BOLD, 8)
229
- ty = text(c, bx + 2, ty, params, MONO, 7, TEXT_MID)
230
- ty = text(c, bx + 2, ty, desc, BODY, 7.5)
231
- equiv(c, bx + 2, ty, eq_gear)
232
-
233
- by = band_y - 4
234
-
235
- stage_box(c, s2_x, s2_y - s2_h, s2_w, s2_h, "SUBSONIC FILTER + EQ SECTION", s2_body, 2)
236
-
237
- # Arrow to page 2
238
- draw_arrow_down(c, arr_x, s2_y - s2_h, s2_y - s2_h - 18)
239
- c.setFillColor(TEXT_LIGHT)
240
- c.setFont(ITALIC, 7.5)
241
- c.drawCentredString(arr_x, s2_y - s2_h - 28, "continued on next page")
242
-
243
-
244
- # ══════════════════════════════════════════════════════════════════════════
245
- # PAGE 2
246
- # ══════════════════════════════════════════════════════════════════════════
247
-
248
- def page2(c):
249
- arr_x = W / 2
250
-
251
- # ── Stage 3: Multiband Compressor ────────────────────────────────
252
- s3_y = H - 28
253
- s3_h = 370
254
- s3_x = M
255
- s3_w = W - 2 * M
256
-
257
- def s3_body(c, x, y, w):
258
- # Slider curve note
259
- y = text(c, x, y, "LOGARITHMIC SLIDER CURVE: t = (slider / 100)\u00b2 \u2014 "
260
- "bottom half (0\u201350) is transparent-to-light, aggressive in top 30% (70\u2013100)",
261
- BOLD, 7.5, WARM_RED)
262
- y -= 4
263
-
264
- # Crossover sub-box
265
- xo_h = 38
266
- bx, by = sub_box(c, x, y - xo_h, w, xo_h,
267
- "LINKWITZ-RILEY CROSSOVERS (4th-order, 200 Hz + 4 kHz)")
268
- by = text(c, bx, by, "Two cascaded 2nd-order Butterworth filters per split "
269
- "\u2022 Zero-phase \u2022 Perfect reconstruction", BODY, 7.5)
270
- y = y - xo_h - 6
271
-
272
- # 3-band columns
273
- col_w = (w - 30) / 3
274
- band_data = [
275
- ("LOW BAND (< 200 Hz)", ACCENT2, [
276
- ("Threshold:", "-16 \u2500 -24 dB"),
277
- ("Ratio:", "1.2:1 \u2500 3.0:1"),
278
- ("Attack:", "80 \u2500 20 ms"),
279
- ("Release:", "200 \u2500 120 ms"),
280
- ], "Firmest control \u2014 attack scales\ndown to catch kick/bass transients",
281
- "SSL G384 Bus Comp (low band)"),
282
- ("MID BAND (200 Hz \u2013 4 kHz)", ACCENT, [
283
- ("Threshold:", "-14 \u2500 -24 dB"),
284
- ("Ratio:", "1.1:1 \u2500 2.5:1"),
285
- ("Attack:", "30 \u2500 10 ms"),
286
- ("Release:", "150 \u2500 100 ms"),
287
- ], "Musical peak control \u2014 attack\ncatches snare/vocal peaks at high comp",
288
- "Manley Vari-Mu (mid band)"),
289
- ("HIGH BAND (> 4 kHz)", HexColor("#2a7a5a"), [
290
- ("Threshold:", "-12 \u2500 -22 dB"),
291
- ("Ratio:", "1.05:1 \u2500 2.0:1"),
292
- ("Attack:", "10 \u2500 3 ms"),
293
- ("Release:", "80 \u2500 40 ms"),
294
- ], "Transient control \u2014 fast attack\ntames cymbal/click transients",
295
- "Maselec MLA-3 (high band)"),
296
- ]
297
-
298
- band_h = 155
299
- for i, (title, color, params, desc, gear) in enumerate(band_data):
300
- bx_col = x + i * (col_w + 15)
301
- # Band box
302
- c.setFillColor(HexColor("#f8f8fc") if i % 2 == 0 else HexColor("#faf8f0"))
303
- c.setStrokeColor(HexColor("#dddddd"))
304
- c.setLineWidth(0.5)
305
- c.roundRect(bx_col, y - band_h, col_w, band_h, 4, fill=1, stroke=1)
306
- # Band title
307
- c.setFillColor(color)
308
- c.roundRect(bx_col, y - 16, col_w, 16, 4, fill=1, stroke=0)
309
- c.rect(bx_col, y - 16, col_w, 4, fill=1, stroke=0)
310
- c.setFillColor(white)
311
- c.setFont(BOLD, 7.5)
312
- c.drawCentredString(bx_col + col_w / 2, y - 12, title)
313
- # Parameters
314
- py = y - 28
315
- for label, val in params:
316
- c.setFillColor(TEXT_DARK)
317
- c.setFont(BOLD, 7)
318
- c.drawString(bx_col + 6, py, label)
319
- c.setFont(MONO, 7)
320
- c.drawString(bx_col + 58, py, val)
321
- py -= 11
322
- # Description
323
- py -= 4
324
- c.setFillColor(TEXT_MID)
325
- c.setFont(ITALIC, 7)
326
- for dl in desc.split("\n"):
327
- c.drawString(bx_col + 6, py, dl)
328
- py -= 9
329
- # Gear equiv
330
- py -= 2
331
- c.setFont(ITALIC, 6.5)
332
- c.drawString(bx_col + 6, py, f"Equiv: {gear}")
333
-
334
- y = y - band_h - 8
335
-
336
- # VCA topology note
337
- y = text(c, x, y, "VCA topology \u2022 RMS sidechain \u2022 "
338
- "No makeup gain \u2014 LUFS normalization restores output level",
339
- BODY, 7.5, TEXT_MID)
340
- y -= 2
341
- # All parameters scale note
342
- y = text(c, x, y, "All parameters (including attack) scale with slider position via quadratic curve",
343
- BOLD, 7.5, WARM_ORANGE)
344
- y -= 4
345
- # Bypass note
346
- c.setFillColor(HexColor("#cc4444"))
347
- c.setFont(BOLD, 8.5)
348
- c.drawString(x, y, "TRUE BYPASS when slider = 0 (no processing)")
349
- y -= 12
350
- equiv(c, x, y, "SSL G384 / Manley Vari-Mu / Maselec MLA-3 (3-band)")
351
-
352
- stage_box(c, s3_x, s3_y - s3_h, s3_w, s3_h, "MULTIBAND COMPRESSOR (3-Band)", s3_body, 3)
353
-
354
- draw_arrow_down(c, arr_x, s3_y - s3_h, s3_y - s3_h - 20)
355
-
356
- # ── Stage 4: Stereo Width ─────────────────────────────────────────
357
- s4_y = s3_y - s3_h - 22
358
- s4_h = 330
359
- s4_x = M
360
- s4_w = W - 2 * M
361
-
362
- def s4_body(c, x, y, w):
363
- # Crossover
364
- cx_h = 48
365
- bx, by = sub_box(c, x, y - cx_h, w, cx_h, "LINKWITZ-RILEY CROSSOVER (4th-order, 200 Hz)")
366
- by = text(c, bx, by, "Two cascaded 2nd-order Butterworth filters", BODY, 8)
367
- by -= 2
368
- equiv(c, bx, by, "Rane AC 22B active crossover")
369
-
370
- y = y - cx_h - 6
371
-
372
- # Split into two columns
373
- col_w = (w - 20) / 2
374
- left_x = x
375
- right_x = x + col_w + 20
376
-
377
- # LOW BAND label
378
- c.setFillColor(ACCENT2)
379
- c.setFont(BOLD, 8)
380
- c.drawCentredString(left_x + col_w / 2, y - 10, "LOW BAND (< 200 Hz)")
381
- c.setFillColor(TEXT_MID)
382
- c.setFont(BODY, 7.5)
383
- c.drawCentredString(left_x + col_w / 2, y - 22, "Untouched \u2014 mono-safe bass")
384
-
385
- # HIGH BAND label
386
- c.setFillColor(ACCENT)
387
- c.setFont(BOLD, 8)
388
- c.drawCentredString(right_x + col_w / 2, y - 10, "HIGH BAND (\u2265 200 Hz)")
389
-
390
- y -= 30
391
-
392
- # M/S boxes on the right
393
- ms_blocks = [
394
- ("M/S ENCODE", "Mid = (L + R) / 2\nSide = (L \u2212 R) / 2",
395
- "Resistor summing matrix (matched 0.1% precision)"),
396
- ("WIDTH CONTROL", "Mid \u00d7 mid_scale\nSide \u00d7 side_scale",
397
- "Energy-preserving: mid_scale = \u221a(2/(1+w\u00b2)) Range: 80\u2013150%"),
398
- ("M/S DECODE", "L = Mid + Side\nR = Mid \u2212 Side",
399
- "Resistor summing matrix"),
400
- ]
401
-
402
- for i, (name, formulas, desc) in enumerate(ms_blocks):
403
- bh = 52
404
- bx2, by2 = sub_box(c, right_x, y - bh, col_w, bh, name)
405
- for line in formulas.split("\n"):
406
- by2 = text(c, bx2, by2, line, MONO, 7.5, TEXT_MID)
407
- text(c, bx2, by2, desc, BODY, 7, TEXT_MID)
408
- if i < 2:
409
- draw_arrow_down(c, right_x + col_w / 2, y - bh, y - bh - 10)
410
- y = y - bh - 14
411
-
412
- # Summing
413
- y -= 6
414
- c.setStrokeColor(ACCENT)
415
- c.setLineWidth(1)
416
- c.line(left_x + col_w / 2, y + 70, left_x + col_w / 2, y + 10) # low band vertical
417
- c.line(left_x + col_w / 2, y + 10, right_x + col_w / 2, y + 10) # horizontal join
418
- c.line(right_x + col_w / 2, y + 30, right_x + col_w / 2, y + 10)
419
- # Plus symbol
420
- cx = (left_x + col_w / 2 + right_x + col_w / 2) / 2
421
- c.setFont(BOLD, 12)
422
- c.setFillColor(ACCENT)
423
- c.drawCentredString(cx, y + 6, "+")
424
-
425
- draw_arrow_down(c, cx, y + 4, y - 8)
426
- y -= 10
427
- c.setFillColor(TEXT_MID)
428
- c.setFont(BODY, 7.5)
429
- c.drawCentredString(cx, y - 4, "Summing amp (clip protection if peak > 1.0)")
430
- equiv(c, x, y - 18, "Brainworx bx_control V2")
431
-
432
- stage_box(c, s4_x, s4_y - s4_h, s4_w, s4_h, "STEREO WIDTH \u2014 FREQUENCY-SELECTIVE M/S MATRIX", s4_body, 4)
433
-
434
- draw_arrow_down(c, arr_x, s4_y - s4_h, s4_y - s4_h - 18)
435
- c.setFillColor(TEXT_LIGHT)
436
- c.setFont(ITALIC, 7.5)
437
- c.drawCentredString(arr_x, s4_y - s4_h - 28, "continued on next page")
438
-
439
-
440
- # ══════════════════════════════════════════════════════════════════════════
441
- # PAGE 3
442
- # ══════════════════════════════════════════════════════════════════════════
443
-
444
- def page3(c):
445
- arr_x = W / 2
446
-
447
- # ── Stage 5: LUFS Normalization ──────────────────────────────────
448
- s5_y = H - 28
449
- s5_h = 130
450
- s5_x = M
451
- s5_w = W - 2 * M
452
-
453
- def s5_body(c, x, y, w):
454
- col_w = (w - 20) / 2
455
-
456
- # LUFS Meter
457
- bh = 52
458
- bx, by = sub_box(c, x, y - bh, col_w, bh, "LUFS METER (ITU-R BS.1770)")
459
- by = text(c, bx, by, "Integrated loudness measurement", BODY, 8)
460
- by -= 2
461
- by = text(c, bx, by, "K-weighted, gated per BS.1770-4", MONO, 7, TEXT_MID)
462
-
463
- # Output Gain Trim
464
- rx = x + col_w + 20
465
- bx2, by2 = sub_box(c, rx, y - bh, col_w, bh, "OUTPUT GAIN TRIM")
466
- by2 = text(c, bx2, by2, "Stepped relay attenuator", BODY, 8)
467
- by2 = text(c, bx2, by2, "Applies gain delta to hit target LUFS exactly", BODY, 8)
468
- by2 -= 2
469
- by2 = text(c, bx2, by2, "Targets: -14 (Streaming) / -11 (CD) / Custom", MONO, 7, TEXT_MID)
470
-
471
- # Arrow between them
472
- c.setStrokeColor(ACCENT)
473
- c.setLineWidth(1.5)
474
- mid_y = y - bh / 2
475
- c.line(x + col_w + 2, mid_y, rx - 2, mid_y)
476
- c.setFillColor(ACCENT)
477
- p = c.beginPath()
478
- p.moveTo(rx - 2, mid_y)
479
- p.lineTo(rx - 10, mid_y + 4)
480
- p.lineTo(rx - 10, mid_y - 4)
481
- p.close()
482
- c.drawPath(p, fill=1, stroke=0)
483
-
484
- y -= bh + 8
485
- equiv(c, x, y, "Dorrough 40-A loudness meter + Dangerous Music Monitor ST")
486
-
487
- stage_box(c, s5_x, s5_y - s5_h, s5_w, s5_h, "OUTPUT LEVEL \u2014 LUFS NORMALIZATION", s5_body, 5)
488
-
489
- draw_arrow_down(c, arr_x, s5_y - s5_h, s5_y - s5_h - 18)
490
-
491
- # ── Stage 6: Soft Clipper ────────────────────────────────────────
492
- s6_y = s5_y - s5_h - 20
493
- s6_h = 155
494
- s6_x = M
495
- s6_w = W - 2 * M
496
-
497
- def s6_body(c, x, y, w):
498
- y = text(c, x, y, "Piecewise tanh saturation \u2014 analog-style waveshaping (NOT a limiter)", BOLD, 8.5)
499
- y -= 4
500
-
501
- col_w = (w - 20) / 2
502
-
503
- # Left: How it works
504
- bh = 80
505
- bx, by = sub_box(c, x, y - bh, col_w, bh, "TRANSFER FUNCTION")
506
- by = text(c, bx, by, "Ceiling: -0.1 dBTP (linear \u2248 0.9885)", MONO, 7, TEXT_MID)
507
- by = text(c, bx, by, "Knee: 2 dB below ceiling", MONO, 7, TEXT_MID)
508
- by -= 4
509
- by = text(c, bx, by, "Below knee: perfectly linear (zero processing)", BODY, 7.5)
510
- by = text(c, bx, by, "Above knee: tanh(excess / headroom) \u2192 asymptotes to ceiling", BODY, 7.5)
511
- by -= 2
512
- by = text(c, bx, by, "Only affects top 1\u20132 dB of loudest transient tips", BOLD, 7, WARM_RED)
513
-
514
- # Right: Behavior
515
- rx = x + col_w + 20
516
- bx2, by2 = sub_box(c, rx, y - bh, col_w, bh, "BEHAVIOR")
517
- by2 = text(c, bx2, by2, "Shaves peak tips without reducing LUFS", BODY, 7.5)
518
- by2 = text(c, bx2, by2, "Allows normalization to reach target loudness", BODY, 7.5)
519
- by2 -= 4
520
- by2 = text(c, bx2, by2, "Reduces crest factor by 1\u20132 dB on hot material", MONO, 7, TEXT_MID)
521
- by2 = text(c, bx2, by2, "Transparent on well-mixed sources", MONO, 7, TEXT_MID)
522
- by2 -= 2
523
- by2 = text(c, bx2, by2, "Always active (no bypass needed \u2014 linear below knee)", BOLD, 7, WARM_ORANGE)
524
-
525
- y -= bh + 8
526
- equiv(c, x, y, "Kush Omega N / Thermionic Culture Vulture (gentle saturation mode)")
527
-
528
- stage_box(c, s6_x, s6_y - s6_h, s6_w, s6_h, "SOFT CLIPPER \u2014 PIECEWISE TANH SATURATION", s6_body, 6)
529
-
530
- draw_arrow_down(c, arr_x, s6_y - s6_h, s6_y - s6_h - 18)
531
-
532
- # ── Stage 7: True Peak Ceiling ───────────────────────────────────
533
- s7_y = s6_y - s6_h - 20
534
- s7_h = 105
535
- s7_x = M
536
- s7_w = W - 2 * M
537
-
538
- def s7_body(c, x, y, w):
539
- y = text(c, x, y, "Safety net \u2014 catches any residual inter-sample peaks the soft clipper didn't fully tame",
540
- BODY, 8.5)
541
- y -= 4
542
- y = text(c, x, y, "Measures true peak via 4x oversampling per ITU-R BS.1770", MONO, 7.5, TEXT_MID)
543
- y = text(c, x, y, "If true peak > -0.1 dBTP: scales entire signal down by overshoot amount", MONO, 7.5, TEXT_MID)
544
- y -= 4
545
- y = text(c, x, y, "Rarely engages thanks to soft clipper \u2014 typically < 0.1 dB correction", BODY, 8)
546
- y -= 4
547
- c.setFillColor(HexColor("#cc4444"))
548
- c.setFont(BOLD, 8)
549
- c.drawString(x, y, "GUARANTEES: True peak \u2264 -0.1 dBTP on all output")
550
- y -= 12
551
- equiv(c, x, y, "Dorrough 40-A peak meter + relay attenuator")
552
-
553
- stage_box(c, s7_x, s7_y - s7_h, s7_w, s7_h, "TRUE PEAK CEILING (-0.1 dBTP)", s7_body, 7)
554
-
555
- draw_arrow_down(c, arr_x, s7_y - s7_h, s7_y - s7_h - 18)
556
-
557
- # Output file box
558
- out_y = s7_y - s7_h - 20
559
- out_w = 140
560
- out_h = 30
561
- out_x = arr_x - out_w / 2
562
- c.setFillColor(BG_DARK)
563
- c.roundRect(out_x, out_y - out_h, out_w, out_h, 6, fill=1, stroke=0)
564
- c.setFillColor(white)
565
- c.setFont(BOLD, 10)
566
- c.drawCentredString(arr_x, out_y - 19, "24-bit WAV")
567
-
568
- # ── Gear Rack Table ───────────────────────────────────────────────
569
- table_y = out_y - out_h - 30
570
- c.setFillColor(BG_DARK)
571
- c.setFont(BOLD, 11)
572
- c.drawString(M, table_y, "ANALOG GEAR RACK EQUIVALENT")
573
- table_y -= 6
574
-
575
- # Table
576
- c.setStrokeColor(BORDER)
577
- c.setLineWidth(0.5)
578
-
579
- rows = [
580
- ("1U", "Dangerous Music Liaison", "Input gain / level matching"),
581
- ("2U", "Sontec MEP-250A", "HPF + 6-band parametric EQ"),
582
- ("3U", "SSL G384 / Manley Vari-Mu / Maselec MLA-3", "3-band multiband compression, bypass at 0"),
583
- ("1U", "Rane AC 22B", "Linkwitz-Riley crossover (200 Hz)"),
584
- ("1U", "Brainworx bx_control V2", "M/S encode / width / decode"),
585
- ("1U", "Dorrough 40-A + Dangerous ST", "LUFS metering + output gain trim"),
586
- ("1U", "Kush Omega N", "Piecewise tanh soft clipper (-0.1 dBTP ceiling)"),
587
- ("--", "Dorrough 40-A (peak section)", "True peak ceiling / safety net"),
588
- ]
589
-
590
- col_x = [M, M + 30, M + 230]
591
- row_h = 16
592
- table_w = W - 2 * M
593
-
594
- # Header
595
- c.setFillColor(ACCENT)
596
- c.rect(M, table_y - row_h, table_w, row_h, fill=1, stroke=0)
597
- c.setFillColor(white)
598
- c.setFont(BOLD, 7.5)
599
- c.drawString(col_x[0] + 4, table_y - 12, "SIZE")
600
- c.drawString(col_x[1] + 4, table_y - 12, "UNIT")
601
- c.drawString(col_x[2] + 4, table_y - 12, "FUNCTION")
602
- table_y -= row_h
603
-
604
- for i, (size, unit, func) in enumerate(rows):
605
- bg = HexColor("#f8f8fc") if i % 2 == 0 else white
606
- c.setFillColor(bg)
607
- c.rect(M, table_y - row_h, table_w, row_h, fill=1, stroke=0)
608
- c.setStrokeColor(HexColor("#eeeeee"))
609
- c.line(M, table_y - row_h, M + table_w, table_y - row_h)
610
- c.setFillColor(TEXT_DARK)
611
- c.setFont(BOLD, 7.5)
612
- c.drawString(col_x[0] + 4, table_y - 12, size)
613
- c.setFont(BODY, 7.5)
614
- c.drawString(col_x[1] + 4, table_y - 12, unit)
615
- c.setFont(BODY, 7.5)
616
- c.drawString(col_x[2] + 4, table_y - 12, func)
617
- table_y -= row_h
618
-
619
- # Total row
620
- c.setFillColor(BG_DARK)
621
- c.rect(M, table_y - row_h, table_w, row_h, fill=1, stroke=0)
622
- c.setFillColor(white)
623
- c.setFont(BOLD, 8)
624
- c.drawString(col_x[0] + 4, table_y - 12, "10U")
625
- c.drawString(col_x[1] + 4, table_y - 12, "Total rack space")
626
-
627
- # ── Signal flow summary ───────────────────────────────────────────
628
- table_y -= row_h + 20
629
- c.setFillColor(TEXT_MID)
630
- c.setFont(BOLD, 8)
631
- c.drawCentredString(W / 2, table_y, "SIGNAL FLOW")
632
-
633
- table_y -= 14
634
- flow_parts = [
635
- "Pre-Gain\nDrop\n(-18 LUFS)",
636
- "HPF\n15 Hz",
637
- "EQ\n6-Band",
638
- "Multiband\nComp\n(3-Band)",
639
- "Stereo\nWidth\n(M/S)",
640
- "LUFS\nNormalize",
641
- "Soft\nClipper",
642
- "True Peak\nCeiling",
643
- "Output",
644
- ]
645
-
646
- total_w = W - 2 * M
647
- box_w = 56
648
- gap = (total_w - len(flow_parts) * box_w) / (len(flow_parts) - 1)
649
- bx_start = M
650
-
651
- for i, part in enumerate(flow_parts):
652
- bx = bx_start + i * (box_w + gap)
653
- bh = 36
654
-
655
- if i == len(flow_parts) - 1:
656
- c.setFillColor(BG_DARK)
657
- elif i == 6: # Soft Clipper
658
- c.setFillColor(WARM_RED)
659
- elif i == 7: # True Peak Ceiling
660
- c.setFillColor(WARM_ORANGE)
661
- else:
662
- c.setFillColor(ACCENT if i % 2 == 0 else ACCENT2)
663
- c.roundRect(bx, table_y - bh, box_w, bh, 4, fill=1, stroke=0)
664
-
665
- c.setFillColor(white)
666
- c.setFont(BOLD, 6.5)
667
- lines = part.split("\n")
668
- line_y = table_y - 9 - (len(lines) - 1) * 4
669
- for line in lines:
670
- c.drawCentredString(bx + box_w / 2, line_y, line)
671
- line_y -= 8
672
-
673
- # Arrow between boxes
674
- if i < len(flow_parts) - 1:
675
- ax1 = bx + box_w + 1
676
- ax2 = bx + box_w + gap - 1
677
- ay = table_y - bh / 2
678
- c.setStrokeColor(TEXT_MID)
679
- c.setLineWidth(1)
680
- c.line(ax1, ay, ax2 - 5, ay)
681
- c.setFillColor(TEXT_MID)
682
- p = c.beginPath()
683
- p.moveTo(ax2, ay)
684
- p.lineTo(ax2 - 5, ay + 3)
685
- p.lineTo(ax2 - 5, ay - 3)
686
- p.close()
687
- c.drawPath(p, fill=1, stroke=0)
688
-
689
-
690
- # ══════════════════════════════════════════════════════════════════════════
691
- # PAGE 4 — Super AI (beta) Workflow
692
- # ══════════════════════════════════════════════════════════════════════════
693
-
694
- SUPER_GREEN = HexColor("#1a7a3a")
695
- SUPER_BLUE = HexColor("#2a5a9a")
696
- SUPER_GOLD = HexColor("#8a6d1b")
697
-
698
- def page4(c):
699
- arr_x = W / 2
700
-
701
- # ── Title banner ──────────────────────────────────────────────────
702
- bh = 52
703
- c.setFillColor(BG_DARK)
704
- c.rect(0, H - bh, W, bh, fill=1, stroke=0)
705
- c.setFillColor(white)
706
- c.setFont(BOLD, 16)
707
- c.drawCentredString(W / 2, H - 24, "SUPER AI (BETA) — 5-PASS MASTERING WORKFLOW")
708
- c.setFont(BODY, 10)
709
- c.setFillColor(HexColor("#aaaacc"))
710
- c.drawCentredString(W / 2, H - 42,
711
- "Full Parametric AI Control • Gemini 2.5 Pro • v4.2")
712
-
713
- # ── 5-Pass Flow Diagram ───────────────────────────────────────────
714
- flow_y = H - bh - 18
715
- c.setFillColor(TEXT_DARK)
716
- c.setFont(BOLD, 11)
717
- c.drawString(M, flow_y, "5-PASS ITERATIVE REFINEMENT LOOP")
718
- flow_y -= 8
719
-
720
- passes = [
721
- ("PASS 1", "AI ANALYZE → MASTER",
722
- "AI analyzes raw audio measurements (spectral profile, dynamics,\n"
723
- "stereo field, crest factor). Returns full parametric settings.\n"
724
- "DSP chain processes audio with AI-chosen parameters.",
725
- SUPER_GREEN),
726
- ("PASS 2", "AI COMPARE → REVISE → RE-MASTER",
727
- "AI compares original vs mastered measurements. Makes SMALL\n"
728
- "incremental adjustments to all parameters. Re-masters with\n"
729
- "revised settings. Full history context prevents oscillation.",
730
- SUPER_BLUE),
731
- ("PASS 3", "AI COMPARE → REVISE → RE-MASTER",
732
- "Same as Pass 2. Further refinement. AI references all prior\n"
733
- "passes to avoid reverting previous improvements.",
734
- SUPER_BLUE),
735
- ("PASS 4", "AI COMPARE → REVISE → RE-MASTER",
736
- "Same as Pass 2–3. Final adjustment pass. AI makes the\n"
737
- "smallest, most surgical changes at this stage.",
738
- SUPER_BLUE),
739
- ("PASS 5", "FINAL REPORT (NO CHANGES)",
740
- "AI compares final master against original. Writes a quality\n"
741
- "report covering spectral balance, dynamics, stereo image,\n"
742
- "and streaming compliance. No parameter changes — report only.",
743
- SUPER_GOLD),
744
- ]
745
-
746
- box_h = 60
747
- gap = 6
748
- for i, (label, title, desc, color) in enumerate(passes):
749
- bx = M
750
- by = flow_y - (i * (box_h + gap + 14))
751
- bw = W - 2 * M
752
-
753
- # Pass number badge
754
- badge_w = 52
755
- c.setFillColor(color)
756
- c.roundRect(bx, by - box_h, badge_w, box_h, 4, fill=1, stroke=0)
757
- c.setFillColor(white)
758
- c.setFont(BOLD, 9)
759
- c.drawCentredString(bx + badge_w / 2, by - box_h / 2 + 4, label)
760
-
761
- # Content box
762
- cx = bx + badge_w + 6
763
- cw = bw - badge_w - 6
764
- c.setFillColor(BG_STAGE)
765
- c.setStrokeColor(BORDER)
766
- c.setLineWidth(0.5)
767
- c.roundRect(cx, by - box_h, cw, box_h, 4, fill=1, stroke=1)
768
-
769
- # Title
770
- c.setFillColor(color)
771
- c.setFont(BOLD, 9)
772
- c.drawString(cx + 8, by - 14, title)
773
-
774
- # Description
775
- c.setFillColor(TEXT_MID)
776
- c.setFont(BODY, 7.5)
777
- desc_y = by - 26
778
- for line in desc.strip().split("\n"):
779
- c.drawString(cx + 8, desc_y, line.strip())
780
- desc_y -= 10
781
-
782
- # Arrow between passes
783
- if i < len(passes) - 1:
784
- draw_arrow_down(c, bx + badge_w / 2, by - box_h,
785
- by - box_h - gap - 12)
786
-
787
- flow_y = by # track for next section
788
-
789
- # ── AI-Controlled Parameters ──────────────────────────────────────
790
- param_y = flow_y - box_h - gap - 30
791
- param_h = 300
792
- param_x = M
793
- param_w = W - 2 * M
794
-
795
- def param_body(c, x, y, w):
796
- col_w = (w - 16) / 2
797
-
798
- # LEFT COLUMN — EQ + HPF
799
- left_x = x
800
- y_left = y
801
-
802
- # HPF
803
- y_left = text(c, left_x, y_left, "HIGH-PASS FILTER", BOLD, 8.5, ACCENT)
804
- y_left = text(c, left_x, y_left, "Cutoff: 10 – 80 Hz (default 15 Hz)", MONO, 7, TEXT_MID)
805
- y_left = text(c, left_x, y_left, "12 dB/oct Butterworth", BODY, 7.5)
806
- y_left -= 6
807
-
808
- # EQ
809
- y_left = text(c, left_x, y_left, "6-BAND FULLY PARAMETRIC EQ", BOLD, 8.5, ACCENT)
810
- y_left = text(c, left_x, y_left, "Each band independently configurable:", BODY, 7.5)
811
- y_left -= 2
812
- eq_params = [
813
- ("Type:", "peak / low_shelf / high_shelf"),
814
- ("Frequency:", "20 – 20,000 Hz"),
815
- ("Gain:", "-6 to +6 dB"),
816
- ("Q:", "0.1 – 10.0"),
817
- ]
818
- for label, val in eq_params:
819
- c.setFillColor(TEXT_DARK)
820
- c.setFont(BOLD, 7)
821
- c.drawString(left_x + 8, y_left, label)
822
- c.setFont(MONO, 7)
823
- c.drawString(left_x + 75, y_left, val)
824
- y_left -= 10
825
- y_left -= 2
826
- y_left = text(c, left_x, y_left, "Not limited to UI ±3 dB — full ±6 dB range", BOLD, 7, WARM_RED)
827
- y_left -= 6
828
-
829
- # Stereo
830
- y_left = text(c, left_x, y_left, "STEREO WIDTH", BOLD, 8.5, ACCENT)
831
- y_left = text(c, left_x, y_left, "Range: 80 – 150% (M/S matrix)", MONO, 7, TEXT_MID)
832
- y_left = text(c, left_x, y_left, "Crossover at 200 Hz (fixed) — bass stays mono", BODY, 7.5)
833
-
834
- # RIGHT COLUMN — Compression + Crossovers
835
- right_x = x + col_w + 16
836
- y_right = y
837
-
838
- y_right = text(c, right_x, y_right, "MULTIBAND COMPRESSION (3-band)", BOLD, 8.5, ACCENT2)
839
- y_right = text(c, right_x, y_right, "Per-band independent control:", BODY, 7.5)
840
- y_right -= 2
841
-
842
- comp_params = [
843
- ("Threshold:", "-20 to 0 dB"),
844
- ("Ratio:", "1.0 – 4.0 (1.0 = bypass)"),
845
- ("Attack:", "0.1 – 200 ms"),
846
- ("Release:", "10 – 500 ms"),
847
- ]
848
- for label, val in comp_params:
849
- c.setFillColor(TEXT_DARK)
850
- c.setFont(BOLD, 7)
851
- c.drawString(right_x + 8, y_right, label)
852
- c.setFont(MONO, 7)
853
- c.drawString(right_x + 75, y_right, val)
854
- y_right -= 10
855
- y_right -= 2
856
- y_right = text(c, right_x, y_right,
857
- "No makeup gain — LUFS normalization restores level",
858
- BODY, 7.5, TEXT_MID)
859
- y_right -= 8
860
-
861
- y_right = text(c, right_x, y_right, "CROSSOVER FREQUENCIES", BOLD, 8.5, ACCENT2)
862
- y_right = text(c, right_x, y_right, "Low/Mid split: 80 – 500 Hz (default 200 Hz)", MONO, 7, TEXT_MID)
863
- y_right = text(c, right_x, y_right, "Mid/High split: 1,000 – 10,000 Hz (default 4 kHz)", MONO, 7, TEXT_MID)
864
- y_right = text(c, right_x, y_right, "Linkwitz-Riley 4th-order (zero-phase, perfect reconstruction)", BODY, 7.5)
865
- y_right -= 8
866
-
867
- # Fixed stages note
868
- y_right = text(c, right_x, y_right, "FIXED (NOT AI-CONTROLLED):", BOLD, 8.5, WARM_RED)
869
- y_right = text(c, right_x, y_right, "• Pre-gain drop to -18 LUFS", BODY, 7.5)
870
- y_right = text(c, right_x, y_right, "• LUFS target (set by user)", BODY, 7.5)
871
- y_right = text(c, right_x, y_right, "• Soft clipper (-0.1 dBTP ceiling)", BODY, 7.5)
872
- y_right = text(c, right_x, y_right, "• True peak ceiling (-0.1 dBTP)", BODY, 7.5)
873
- y_right = text(c, right_x, y_right, "• Stereo width crossover (200 Hz)", BODY, 7.5)
874
-
875
- stage_box(c, param_x, param_y - param_h, param_w, param_h,
876
- "AI-CONTROLLED PARAMETERS — FULL PARAMETRIC RANGE", param_body)
877
-
878
-
879
- def page5(c):
880
- """Page 5: AI Prompting Instructions & Philosophy."""
881
- arr_x = W / 2
882
-
883
- # ── Title banner ──────────────────────────────────────────────────
884
- bh = 52
885
- c.setFillColor(BG_DARK)
886
- c.rect(0, H - bh, W, bh, fill=1, stroke=0)
887
- c.setFillColor(white)
888
- c.setFont(BOLD, 16)
889
- c.drawCentredString(W / 2, H - 24, "SUPER AI — PROMPTING PHILOSOPHY")
890
- c.setFont(BODY, 10)
891
- c.setFillColor(HexColor("#aaaacc"))
892
- c.drawCentredString(W / 2, H - 42,
893
- "AI Mastering Engineer Instructions • v4.2")
894
-
895
- y = H - bh - 24
896
-
897
- # ── Mastering Philosophy ──────────────────────────────────────────
898
- phil_h = 118
899
- def phil_body(c, x, y, w):
900
- points = [
901
- "Listen to what the audio NEEDS, not what sounds impressive on paper.",
902
- "Use surgical EQ moves — small cuts are often more effective than boosts.",
903
- "Match compression to the genre and dynamic character of the material.",
904
- "Preserve the artist's intent — enhance, don't transform.",
905
- "Be musical and intentional. Every parameter should have a reason.",
906
- "LESS IS MORE — use the minimum processing needed. Gentle moves preferred.",
907
- ]
908
- for pt in points:
909
- c.setFillColor(ACCENT)
910
- c.setFont(BOLD, 8)
911
- c.drawString(x, y, "•")
912
- c.setFillColor(TEXT_DARK)
913
- c.setFont(BODY, 8)
914
- c.drawString(x + 12, y, pt)
915
- y -= 14
916
- y -= 4
917
- c.setFillColor(TEXT_MID)
918
- c.setFont(ITALIC, 7.5)
919
- c.drawString(x, y, "The AI receives spectral centroid, rolloff, 6-band energy distribution, "
920
- "crest factor, dynamic range, and stereo correlation.")
921
-
922
- stage_box(c, M, y - phil_h, W - 2 * M, phil_h, "MASTERING PHILOSOPHY", phil_body)
923
- y -= phil_h + 20
924
-
925
- # ── Tonal Direction ───────────────────────────────────────────────
926
- tone_h = 100
927
- def tone_body(c, x, y, w):
928
- col_w = (w - 20) / 2
929
-
930
- # Warm
931
- bh = 52
932
- bx, by = sub_box(c, x, y - bh, col_w, bh, "SLIGHTLY WARM TONE")
933
- by = text(c, bx, by, "Favor gentle richness in the low-mids (200–500 Hz)", BODY, 8)
934
- by = text(c, bx, by, "Smooth, non-harsh highs — avoid clinical or brittle sound", BODY, 8)
935
- by -= 2
936
- text(c, bx, by, "Think: Neve console warmth, not Pultec mud", ITALIC, 7, TEXT_MID)
937
-
938
- # Bass
939
- rx = x + col_w + 20
940
- bx2, by2 = sub_box(c, rx, y - bh, col_w, bh, "HEAVY, UNCONSTRAINED BASS")
941
- by2 = text(c, bx2, by2, "Protect low-end punch and transients (40-100 Hz)", BODY, 8)
942
- by2 = text(c, bx2, by2, "DO NOT over-compress <200 Hz. HPF max 25 Hz.", BODY, 8)
943
- by2 -= 2
944
- text(c, bx2, by2, "Sub-bass should feel physical, anchored, wide open", ITALIC, 7, TEXT_MID)
945
-
946
- stage_box(c, M, y - tone_h, W - 2 * M, tone_h,
947
- "TONAL DIRECTION (applied to all masters)", tone_body)
948
- y -= tone_h + 20
949
-
950
- # ── True Peak Guidance ────────────────────────────────────────────
951
- tp_h = 148
952
- def tp_body(c, x, y, w):
953
- y = text(c, x, y, "IDEAL GOAL: True peak between -1.0 and -0.1 dBTP", BOLD, 9, SUPER_GREEN)
954
- y -= 6
955
- y = text(c, x, y, "However, this is a SOFT GOAL — not a hard constraint.", BOLD, 8.5, TEXT_DARK)
956
- y -= 6
957
-
958
- points = [
959
- ("Source awareness:", "AI-generated tracks (Suno, Udio, etc.) are already heavily "
960
- "limited with very low crest factor."),
961
- ("Expected behavior:", "When normalized DOWN to a streaming LUFS target (e.g. -14), "
962
- "the true peak will naturally drop well below -1.0 dBTP. This is correct."),
963
- ("Dynamics > True peak:", "DO NOT over-compress or crush dynamics to force the true "
964
- "peak higher. Dynamics preservation is MORE important."),
965
- ("Compression purpose:", "Use compression for tonal shaping and dynamic control — "
966
- "NEVER to artificially raise the true peak."),
967
- ]
968
- for label, desc in points:
969
- c.setFillColor(WARM_RED)
970
- c.setFont(BOLD, 7.5)
971
- c.drawString(x, y, label)
972
- c.setFillColor(TEXT_DARK)
973
- c.setFont(BODY, 7.5)
974
- c.drawString(x + 100, y, desc)
975
- y -= 20
976
-
977
- stage_box(c, M, y - tp_h, W - 2 * M, tp_h,
978
- "TRUE PEAK GUIDANCE", tp_body)
979
- y -= tp_h + 20
980
-
981
- # ── Review Guidelines (Compare Passes) ────────────────────────────
982
- rev_h = 118
983
- def rev_body(c, x, y, w):
984
- points = [
985
- "Compare original vs mastered measurements carefully.",
986
- "Make SMALL, incremental adjustments — do not overhaul settings that are working.",
987
- "If something sounds good, leave it alone.",
988
- "Reference the previous analysis history to avoid oscillating between settings.",
989
- "Each revision should be a refinement, not a reset.",
990
- "LUFS target is fixed — do not try to change it.",
991
- ]
992
- for pt in points:
993
- c.setFillColor(ACCENT2)
994
- c.setFont(BOLD, 8)
995
- c.drawString(x, y, "•")
996
- c.setFillColor(TEXT_DARK)
997
- c.setFont(BODY, 8)
998
- c.drawString(x + 12, y, pt)
999
- y -= 14
1000
-
1001
- stage_box(c, M, y - rev_h, W - 2 * M, rev_h,
1002
- "COMPARE PASS GUIDELINES (Passes 2–4)", rev_body)
1003
- y -= rev_h + 20
1004
-
1005
- # ── Final Report ──────────────────────────────────────────────────
1006
- rep_h = 118
1007
- def rep_body(c, x, y, w):
1008
- y = text(c, x, y, "Pass 5 produces a comprehensive quality report — NO parameter changes.",
1009
- BOLD, 8.5, TEXT_DARK)
1010
- y -= 6
1011
- sections = [
1012
- ("Overall Assessment:", "Mastering effectiveness, professional standards, tonal goals met?"),
1013
- ("Spectral Balance:", "Low end warmth, midrange richness, high end smoothness"),
1014
- ("Dynamics & Loudness:", "LUFS compliance, true peak, crest factor, dynamic range"),
1015
- ("Stereo Image:", "Width, mono compatibility, balance"),
1016
- ("Processing Summary:", "What the chain did — EQ moves, compression character"),
1017
- ("Verdict:", "Pass/fail for streaming distribution"),
1018
- ]
1019
- for label, desc in sections:
1020
- c.setFillColor(SUPER_GOLD)
1021
- c.setFont(BOLD, 7.5)
1022
- c.drawString(x, y, label)
1023
- c.setFillColor(TEXT_MID)
1024
- c.setFont(BODY, 7.5)
1025
- c.drawString(x + 130, y, desc)
1026
- y -= 12
1027
-
1028
- stage_box(c, M, y - rep_h, W - 2 * M, rep_h,
1029
- "FINAL REPORT STRUCTURE (Pass 5)", rep_body)
1030
-
1031
- # ── Footer ────────────────────────────────────────────────────────
1032
- c.setFillColor(TEXT_LIGHT)
1033
- c.setFont(ITALIC, 7)
1034
- c.drawCentredString(W / 2, M - 10,
1035
- "AI engine: Google Gemini 2.5 Pro • 5 API calls per master • "
1036
- "Session-persistent history prevents oscillation")
1037
-
1038
-
1039
- # ══════════════════════════════════════════════════════════════════════════
1040
- # BUILD
1041
- # ══════════════════════════════════════════════════════════════════════════
1042
-
1043
- def build():
1044
- c = canvas.Canvas(OUTPUT, pagesize=letter)
1045
- c.setTitle("s.AI Audio Mastering Suite \u2014 Analog Signal Flow Schematic v4.2")
1046
- c.setAuthor("StudioAI")
1047
-
1048
- page1(c)
1049
- c.showPage()
1050
- page2(c)
1051
- c.showPage()
1052
- page3(c)
1053
- c.showPage()
1054
- page4(c)
1055
- c.showPage()
1056
- page5(c)
1057
- c.showPage()
1058
-
1059
- c.save()
1060
- print(f"Created {OUTPUT}")
1061
-
1062
-
1063
- if __name__ == "__main__":
1064
- build()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dsp.py CHANGED
@@ -3,14 +3,14 @@
3
  import tempfile
4
  import numpy as np
5
  from pedalboard import (
6
- Pedalboard, Compressor,
7
  LowShelfFilter, HighShelfFilter, PeakFilter,
8
- HighpassFilter,
9
  )
10
  from pedalboard.io import ReadableAudioFile
11
  import soundfile as sf
12
 
13
- from stereo import apply_stereo_width, linkwitz_riley_crossover
14
  from loudness import measure_loudness, measure_true_peak, normalize_to_lufs
15
 
16
 
@@ -18,85 +18,28 @@ from loudness import measure_loudness, measure_true_peak, normalize_to_lufs
18
  # Slider-to-parameter mappings
19
  # ---------------------------------------------------------------------------
20
 
21
- # DEPRECATED — Retained for backward compat. Use map_multiband_compression().
22
  def map_compression(comp_value):
23
- """Map compression slider (0 less to 100 more) to safe mastering glue params.
24
- Calibrated for a -18 LUFS internal working level.
25
 
26
  Returns (threshold_db, ratio, attack_ms, release_ms).
27
  """
28
  t = comp_value / 100.0 # 0.0 (less) → 1.0 (more)
29
 
30
- # Threshold: -14.0 dB (less) → -22.0 dB (more)
31
- threshold_db = -14.0 + t * (-8.0)
32
 
33
- # Ratio: 1.1:1 (less) → 2.5:1 (more)
34
- ratio = 1.1 + t * 1.4
35
 
36
- # Attack: 30 ms (Fixed - lets transients breathe)
37
- attack_ms = 30.0
38
 
39
- # Release: 250 ms (slow/smooth for acoustic) → 100 ms (fast/punchy for EDM/Pop)
40
- release_ms = 250.0 + t * (-150.0)
41
 
42
  return threshold_db, ratio, attack_ms, release_ms
43
 
44
 
45
- # ---------------------------------------------------------------------------
46
- # Multiband compression parameter curves (v3.5)
47
- # ---------------------------------------------------------------------------
48
-
49
- # Crossover frequencies for 3-band split
50
- _MB_CROSS_LOW = 200.0 # Hz — below this is "Low" band
51
- _MB_CROSS_HIGH = 4000.0 # Hz — above this is "High" band
52
-
53
-
54
- def map_multiband_compression(comp_value):
55
- """Map compression slider (0-100) to per-band mastering parameters.
56
-
57
- Three bands tuned to industry best practices:
58
- Low (< 200 Hz) — Firmest control, slow attack lets kick punch through
59
- Mid (200 Hz-4 kHz) — Lightest touch, preserve vocals/instruments
60
- High (> 4 kHz) — Barely compresses, fast attack tames harsh transients
61
-
62
- Each band returns (threshold_db, ratio, attack_ms, release_ms).
63
- All curves calibrated for -18 LUFS internal working level.
64
-
65
- Returns dict: {"low": (...), "mid": (...), "high": (...)}.
66
- """
67
- # Logarithmic curve: bottom half of slider stays in transparent range,
68
- # top half ramps into aggressive territory. t² gives ~50% of slider
69
- # travel before reaching 25% of parameter range.
70
- t = (comp_value / 100.0) ** 2 # 0→0, 50→0.25, 70→0.49, 100→1.0
71
-
72
- # --- Low band (< 200 Hz): Firmest control ---
73
- # Attack scales down so higher values catch kick/bass transients
74
- low_threshold = -16.0 + t * (-8.0) # -16 → -24 dB
75
- low_ratio = 1.2 + t * 1.8 # 1.2:1 → 3.0:1
76
- low_attack = 80.0 + t * (-60.0) # 80 → 20 ms (grabs transients at high comp)
77
- low_release = 200.0 + t * (-80.0) # 200 → 120 ms
78
-
79
- # --- Mid band (200 Hz – 4 kHz): Musical peak control ---
80
- # Attack scales from transparent to transient-taming
81
- mid_threshold = -14.0 + t * (-10.0) # -14 → -24 dB
82
- mid_ratio = 1.1 + t * 1.4 # 1.1:1 → 2.5:1
83
- mid_attack = 30.0 + t * (-20.0) # 30 → 10 ms (catches snare/vocal peaks)
84
- mid_release = 150.0 + t * (-50.0) # 150 → 100 ms (avoids pumping)
85
-
86
- # --- High band (> 4 kHz): Transient control ---
87
- # Already fast, gets faster and more aggressive
88
- high_threshold = -12.0 + t * (-10.0) # -12 → -22 dB
89
- high_ratio = 1.05 + t * 0.95 # 1.05:1 → 2.0:1
90
- high_attack = 10.0 + t * (-7.0) # 10 → 3 ms (catches cymbal/click transients)
91
- high_release = 80.0 + t * (-40.0) # 80 → 40 ms
92
-
93
- return {
94
- "low": (low_threshold, low_ratio, low_attack, low_release),
95
- "mid": (mid_threshold, mid_ratio, mid_attack, mid_release),
96
- "high": (high_threshold, high_ratio, high_attack, high_release),
97
- }
98
-
99
-
100
  # ---------------------------------------------------------------------------
101
  # Audio I/O
102
  # ---------------------------------------------------------------------------
@@ -124,15 +67,17 @@ def save_audio(audio, sample_rate):
124
  # ---------------------------------------------------------------------------
125
 
126
  def master_audio(input_path, lows_db, mid_boost_db, highs_db, bass_boost_db,
127
- bass_freq_hz, comp_value, stereo_width, target_lufs):
128
  """Run the full mastering chain.
129
 
130
  Signal flow:
131
- 1. Pre-gain drop to -18 LUFS (internal headroom)
132
- 2. HPF 15 Hz (12 dB/oct) + EQ (bass boost, lows shelf, highs shelf, mid peak)
133
- 3. Multiband compression (3-band: <200 Hz, 200-4k Hz, >4k Hz; no makeup gain)
134
- 4. Stereo width (frequency-selective M/S processing)
135
- 5. Target LUFS normalization
 
 
136
 
137
  Returns:
138
  (output_path, original_audio, mastered_audio, sample_rate, stats)
@@ -144,27 +89,14 @@ def master_audio(input_path, lows_db, mid_boost_db, highs_db, bass_boost_db,
144
  orig_lufs = measure_loudness(original, sample_rate)
145
  orig_peak = measure_true_peak(original, sample_rate)
146
 
147
- # --- Stage 1: Pre-gain drop create internal headroom ---
148
- # Normalize hot inputs (e.g. Suno at -9 LUFS) down to -18 LUFS
149
- # so EQ boosts don't clip the working audio.
150
- internal_target = -18.0
151
- if not np.isinf(orig_lufs):
152
- processed = normalize_to_lufs(
153
- audio, sample_rate, orig_lufs, internal_target
154
- ).astype(np.float32)
155
- else:
156
- processed = audio.copy()
157
-
158
- # --- Stage 2: Filtering & EQ ---
159
  eq_effects = []
160
 
161
- # Single HPF at 15 Hz (12 dB/oct) — subsonic cleanup
162
- # -3 dB at 15 Hz, negligible loss above 35 Hz
163
- eq_effects.append(HighpassFilter(cutoff_frequency_hz=15.0))
164
 
165
- # LPF removed source audio is already band-limited by sample rate
166
- # (Nyquist), and the 20 kHz LPF was fighting the 10 kHz high shelf
167
- # in the air zone (10-20 kHz), muddying HF shaping.
168
 
169
  # Bass boost (user controls gain and frequency)
170
  if bass_boost_db > 0:
@@ -179,10 +111,10 @@ def master_audio(input_path, lows_db, mid_boost_db, highs_db, bass_boost_db,
179
  eq_effects.append(LowShelfFilter(
180
  cutoff_frequency_hz=200.0, gain_db=float(lows_db), q=1.0))
181
 
182
- # Highs shelf (10 kHz — targets "air" zone, avoids 6-10 kHz harshness)
183
  if highs_db != 0:
184
  eq_effects.append(HighShelfFilter(
185
- cutoff_frequency_hz=10000.0, gain_db=float(highs_db), q=0.7))
186
 
187
  # Mid boost/cut (Q=1.0 for wider, more musical presence)
188
  if mid_boost_db != 0:
@@ -193,295 +125,68 @@ def master_audio(input_path, lows_db, mid_boost_db, highs_db, bass_boost_db,
193
  ))
194
 
195
  eq_board = Pedalboard(eq_effects)
196
- processed = eq_board(processed.T, sample_rate).T
197
-
198
- # --- Stage 3: Multiband Compression (NO makeup gain LUFS normalize handles it) ---
199
- # Compression before stereo width so the compressor sees the EQ'd signal
200
- # without M/S side-channel energy affecting per-band behaviour.
201
- # 3-band split: Low (< 200 Hz), Mid (200 Hz – 4 kHz), High (> 4 kHz)
202
- # Per-band parameter curves tuned to industry mastering best practices.
203
- # TRUE BYPASS when slider = 0.
204
- if comp_value > 0:
205
- band_params = map_multiband_compression(comp_value)
206
-
207
- # Split into 3 bands using two LR4 crossovers
208
- low_band, upper = linkwitz_riley_crossover(
209
- processed, sample_rate, _MB_CROSS_LOW,
210
- )
211
- mid_band, high_band = linkwitz_riley_crossover(
212
- upper, sample_rate, _MB_CROSS_HIGH,
213
- )
214
-
215
- # Compress each band independently
216
- compressed_bands = []
217
- for band_audio, band_key in [
218
- (low_band, "low"), (mid_band, "mid"), (high_band, "high"),
219
- ]:
220
- threshold, ratio, attack, release = band_params[band_key]
221
- comp_board = Pedalboard([Compressor(
222
- threshold_db=threshold,
223
- ratio=ratio,
224
- attack_ms=attack,
225
- release_ms=release,
226
- )])
227
- compressed_bands.append(
228
- comp_board(band_audio.T, sample_rate).T
229
- )
230
-
231
- # Recombine (perfect reconstruction from LR4 subtraction method)
232
- processed = compressed_bands[0] + compressed_bands[1] + compressed_bands[2]
233
-
234
- # --- Stage 4: Stereo width (after dynamics — imaging on settled signal) ---
235
- processed = apply_stereo_width(processed, stereo_width, sample_rate)
236
-
237
- # --- Stage 5: Target LUFS push ---
238
- pre_norm_lufs = measure_loudness(processed, sample_rate)
239
- if not np.isinf(pre_norm_lufs):
240
- processed = normalize_to_lufs(
241
- processed, sample_rate, pre_norm_lufs, target_lufs
242
- ).astype(np.float32)
243
-
244
- # --- Stage 6: Soft clipper (piecewise tanh saturation) ---
245
- # Perfectly linear below the knee; only the tips of peaks above it are
246
- # shaped with a tanh curve that asymptotes to the ceiling. The knee
247
- # sits 2 dB below the ceiling so only the loudest transient tips are
248
- # affected — the vast majority of the signal passes through untouched.
249
- ceiling_lin = 10.0 ** (-0.1 / 20.0) # -0.1 dBTP in linear ≈ 0.9885
250
- peak_lin = np.max(np.abs(processed))
251
- if peak_lin > ceiling_lin:
252
- knee_lin = ceiling_lin * 10.0 ** (-2.0 / 20.0) # 2 dB below ceiling
253
- headroom = ceiling_lin - knee_lin
254
- abs_p = np.abs(processed)
255
- above = abs_p > knee_lin
256
- if np.any(above):
257
- excess = abs_p[above] - knee_lin
258
- clipped = knee_lin + headroom * np.tanh(excess / headroom)
259
- processed[above] = (np.sign(processed[above]) * clipped).astype(np.float32)
260
-
261
- # --- Stage 7: True peak ceiling (-0.1 dBTP) — safety net ---
262
- # The soft clipper handles 99% of cases; this catches any residual
263
- # inter-sample peaks that tanh didn't fully tame.
264
- ceiling_dbtp = -0.1
265
- tp = measure_true_peak(processed, sample_rate)
266
- if tp > ceiling_dbtp:
267
- overshoot_db = tp - ceiling_dbtp
268
- processed = (processed * 10.0 ** (-overshoot_db / 20.0)).astype(np.float32)
269
-
270
- # --- Final measurements ---
271
- mast_lufs = measure_loudness(processed, sample_rate)
272
- mast_peak = measure_true_peak(processed, sample_rate)
273
-
274
- is_mono = original.ndim == 1 or original.shape[1] == 1
275
-
276
- # Build actual DSP params used
277
- dsp_params = {
278
- "hpf_freq": 15.0,
279
- "eq": [],
280
- "stereo_width": stereo_width,
281
- }
282
- if bass_boost_db > 0:
283
- dsp_params["eq"].append({"type": "peak", "freq": float(bass_freq_hz),
284
- "gain_db": float(bass_boost_db), "q": 2.0})
285
- if lows_db != 0:
286
- dsp_params["eq"].append({"type": "low_shelf", "freq": 200.0,
287
- "gain_db": float(lows_db), "q": 1.0})
288
- if highs_db != 0:
289
- dsp_params["eq"].append({"type": "high_shelf", "freq": 10000.0,
290
- "gain_db": float(highs_db), "q": 0.7})
291
- if mid_boost_db != 0:
292
- dsp_params["eq"].append({"type": "peak", "freq": 1200.0,
293
- "gain_db": float(mid_boost_db), "q": 1.0})
294
- if comp_value > 0:
295
- bp = map_multiband_compression(comp_value)
296
- dsp_params["compression"] = {}
297
- for bk in ("low", "mid", "high"):
298
- t, r, a, rel = bp[bk]
299
- dsp_params["compression"][bk] = {
300
- "threshold": t, "ratio": r, "attack_ms": a, "release_ms": rel,
301
- }
302
- dsp_params["crossover_low"] = _MB_CROSS_LOW
303
- dsp_params["crossover_high"] = _MB_CROSS_HIGH
304
-
305
- stats = {
306
- "orig_lufs": orig_lufs,
307
- "orig_peak": orig_peak,
308
- "mast_lufs": mast_lufs,
309
- "mast_peak": mast_peak,
310
- "mono": is_mono,
311
- "dsp": dsp_params,
312
- }
313
-
314
- output_path = save_audio(processed, sample_rate)
315
- return output_path, original, processed, sample_rate, stats
316
-
317
-
318
- # ---------------------------------------------------------------------------
319
- # Full-parameter mastering pipeline (Super AI mode)
320
- # ---------------------------------------------------------------------------
321
-
322
- def master_audio_full(input_path, params, target_lufs):
323
- """Run the full mastering chain with granular per-band control.
324
-
325
- Unlike master_audio() which maps a single compression slider (0-100) to
326
- per-band parameters, this function accepts ALL parameters directly —
327
- including per-band EQ frequencies, Q values, and per-band compression
328
- settings. This enables the AI to fine-tune every knob.
329
-
330
- Args:
331
- input_path: path to audio file.
332
- params: dict with keys:
333
- hpf_freq: float — high-pass filter cutoff in Hz (default 15)
334
- eq: dict with band1..band6, each:
335
- type: "peak" | "low_shelf" | "high_shelf"
336
- freq: float Hz
337
- gain_db: float dB
338
- q: float Q factor
339
- compression: dict with "low", "mid", "high", each:
340
- threshold: float dB
341
- ratio: float
342
- attack_ms: float ms
343
- release_ms: float ms
344
- crossover_low: float Hz (default 200)
345
- crossover_high: float Hz (default 4000)
346
- stereo_width: int 80-150
347
- target_lufs: float
348
-
349
- Returns:
350
- (output_path, original_audio, mastered_audio, sample_rate, stats)
351
- """
352
- audio, sample_rate = load_audio(input_path)
353
- original = audio.copy()
354
-
355
- # --- Measure original loudness ---
356
- orig_lufs = measure_loudness(original, sample_rate)
357
- orig_peak = measure_true_peak(original, sample_rate)
358
-
359
- # --- Stage 1: Pre-gain drop — create internal headroom ---
360
- internal_target = -18.0
361
- if not np.isinf(orig_lufs):
362
- processed = normalize_to_lufs(
363
- audio, sample_rate, orig_lufs, internal_target
364
- ).astype(np.float32)
365
  else:
366
- processed = audio.copy()
367
-
368
- # --- Stage 2: Filtering & EQ (full parametric) ---
369
- eq_effects = []
370
-
371
- # HPF (AI-controllable frequency)
372
- hpf_freq = float(params.get("hpf_freq", 15.0))
373
- hpf_freq = max(10.0, min(80.0, hpf_freq)) # safety clamp
374
- eq_effects.append(HighpassFilter(cutoff_frequency_hz=hpf_freq))
375
-
376
- # 6-band parametric EQ — each band fully configurable
377
- eq_config = params.get("eq", {})
378
- for band_key in ("band1", "band2", "band3", "band4", "band5", "band6"):
379
- band = eq_config.get(band_key, {})
380
- gain = float(band.get("gain_db", 0))
381
- if abs(gain) < 0.01:
382
- continue # skip bands with no gain
383
- freq = float(band.get("freq", 1000))
384
- q = float(band.get("q", 1.0))
385
- btype = band.get("type", "peak")
386
-
387
- # Safety clamps
388
- freq = max(20.0, min(20000.0, freq))
389
- q = max(0.1, min(10.0, q))
390
- gain = max(-12.0, min(12.0, gain))
391
-
392
- if btype == "low_shelf":
393
- eq_effects.append(LowShelfFilter(
394
- cutoff_frequency_hz=freq, gain_db=gain, q=q))
395
- elif btype == "high_shelf":
396
- eq_effects.append(HighShelfFilter(
397
- cutoff_frequency_hz=freq, gain_db=gain, q=q))
398
- else: # "peak"
399
- eq_effects.append(PeakFilter(
400
- cutoff_frequency_hz=freq, gain_db=gain, q=q))
401
-
402
- eq_board = Pedalboard(eq_effects)
403
- processed = eq_board(processed.T, sample_rate).T
404
-
405
- # --- Stage 3: Multiband Compression (direct per-band params) ---
406
- comp_config = params.get("compression", {})
407
- has_comp = any(
408
- comp_config.get(k, {}).get("ratio", 1.0) > 1.0
409
- for k in ("low", "mid", "high")
410
- )
411
-
412
- if has_comp:
413
- xo_low = float(params.get("crossover_low", 200.0))
414
- xo_high = float(params.get("crossover_high", 4000.0))
415
-
416
- # Split into 3 bands
417
- low_band, upper = linkwitz_riley_crossover(
418
- processed, sample_rate, xo_low,
419
- )
420
- mid_band, high_band = linkwitz_riley_crossover(
421
- upper, sample_rate, xo_high,
422
- )
423
-
424
- compressed_bands = []
425
- for band_audio, band_key in [
426
- (low_band, "low"), (mid_band, "mid"), (high_band, "high"),
427
- ]:
428
- bp = comp_config.get(band_key, {})
429
- threshold = float(bp.get("threshold", -14.0))
430
- ratio = float(bp.get("ratio", 1.0))
431
- attack = float(bp.get("attack_ms", 30.0))
432
- release = float(bp.get("release_ms", 150.0))
433
-
434
- # Safety clamps
435
- threshold = max(-40.0, min(0.0, threshold))
436
- ratio = max(1.0, min(20.0, ratio))
437
- attack = max(0.1, min(200.0, attack))
438
- release = max(10.0, min(500.0, release))
439
-
440
- if ratio > 1.0:
441
- comp_board = Pedalboard([Compressor(
442
- threshold_db=threshold,
443
- ratio=ratio,
444
- attack_ms=attack,
445
- release_ms=release,
446
- )])
447
- compressed_bands.append(
448
- comp_board(band_audio.T, sample_rate).T
449
- )
450
- else:
451
- compressed_bands.append(band_audio)
452
-
453
- processed = compressed_bands[0] + compressed_bands[1] + compressed_bands[2]
454
-
455
- # --- Stage 4: Stereo width ---
456
- stereo_width = int(params.get("stereo_width", 100))
457
- processed = apply_stereo_width(processed, stereo_width, sample_rate)
458
-
459
- # --- Stage 5: Target LUFS push ---
460
  pre_norm_lufs = measure_loudness(processed, sample_rate)
461
  if not np.isinf(pre_norm_lufs):
462
  processed = normalize_to_lufs(
463
  processed, sample_rate, pre_norm_lufs, target_lufs
464
  ).astype(np.float32)
465
 
466
- # --- Stage 6: Soft clipper (piecewise tanh saturation) ---
467
- ceiling_lin = 10.0 ** (-0.1 / 20.0)
468
- peak_lin = np.max(np.abs(processed))
469
- if peak_lin > ceiling_lin:
470
- knee_lin = ceiling_lin * 10.0 ** (-2.0 / 20.0)
471
- headroom = ceiling_lin - knee_lin
472
- abs_p = np.abs(processed)
473
- above = abs_p > knee_lin
474
- if np.any(above):
475
- excess = abs_p[above] - knee_lin
476
- clipped = knee_lin + headroom * np.tanh(excess / headroom)
477
- processed[above] = (np.sign(processed[above]) * clipped).astype(np.float32)
478
-
479
- # --- Stage 7: True peak ceiling (-0.1 dBTP) — safety net ---
480
- ceiling_dbtp = -0.1
481
- tp = measure_true_peak(processed, sample_rate)
482
- if tp > ceiling_dbtp:
483
- overshoot_db = tp - ceiling_dbtp
484
- processed = (processed * 10.0 ** (-overshoot_db / 20.0)).astype(np.float32)
485
 
486
  # --- Final measurements ---
487
  mast_lufs = measure_loudness(processed, sample_rate)
@@ -489,13 +194,20 @@ def master_audio_full(input_path, params, target_lufs):
489
 
490
  is_mono = original.ndim == 1 or original.shape[1] == 1
491
 
 
 
 
 
 
492
  stats = {
493
  "orig_lufs": orig_lufs,
494
  "orig_peak": orig_peak,
495
  "mast_lufs": mast_lufs,
496
  "mast_peak": mast_peak,
497
  "mono": is_mono,
498
- "dsp": params,
 
 
499
  }
500
 
501
  output_path = save_audio(processed, sample_rate)
 
3
  import tempfile
4
  import numpy as np
5
  from pedalboard import (
6
+ Pedalboard, Compressor, Gain, Limiter,
7
  LowShelfFilter, HighShelfFilter, PeakFilter,
8
+ HighpassFilter, LowpassFilter,
9
  )
10
  from pedalboard.io import ReadableAudioFile
11
  import soundfile as sf
12
 
13
+ from stereo import apply_stereo_width
14
  from loudness import measure_loudness, measure_true_peak, normalize_to_lufs
15
 
16
 
 
18
  # Slider-to-parameter mappings
19
  # ---------------------------------------------------------------------------
20
 
 
21
  def map_compression(comp_value):
22
+ """Map compression slider (0 less to 100 more) to compressor params.
 
23
 
24
  Returns (threshold_db, ratio, attack_ms, release_ms).
25
  """
26
  t = comp_value / 100.0 # 0.0 (less) → 1.0 (more)
27
 
28
+ # Threshold: -12.0 dB (less) → -18.0 dB (more)
29
+ threshold_db = -12.0 + t * (-18.0 - (-12.0))
30
 
31
+ # Ratio: 1:1 (less) → 4:1 (more)
32
+ ratio = 1.0 + t * 3.0
33
 
34
+ # Attack: 15 ms (fixed)
35
+ attack_ms = 15.0
36
 
37
+ # Release: 120 ms (fixed)
38
+ release_ms = 120.0
39
 
40
  return threshold_db, ratio, attack_ms, release_ms
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  # ---------------------------------------------------------------------------
44
  # Audio I/O
45
  # ---------------------------------------------------------------------------
 
67
  # ---------------------------------------------------------------------------
68
 
69
  def master_audio(input_path, lows_db, mid_boost_db, highs_db, bass_boost_db,
70
+ bass_freq_hz, comp_value, limiter_ceiling, stereo_width, target_lufs):
71
  """Run the full mastering chain.
72
 
73
  Signal flow:
74
+ 1. HPF 20 Hz + LPF 20 kHz (12 dB/oct each)
75
+ 2. EQ (bass boost, lows shelf, highs shelf, mid peak)
76
+ 3. Stereo width (M/S processing before dynamics)
77
+ 4. Compression + makeup gain
78
+ 5. LUFS normalization (single gain adjustment)
79
+ 6. Auto-reduce if limiter would work > 4 dB
80
+ 7. Final limiter (single pass)
81
 
82
  Returns:
83
  (output_path, original_audio, mastered_audio, sample_rate, stats)
 
89
  orig_lufs = measure_loudness(original, sample_rate)
90
  orig_peak = measure_true_peak(original, sample_rate)
91
 
92
+ # --- Stage 1: Filtering & EQ ---
 
 
 
 
 
 
 
 
 
 
 
93
  eq_effects = []
94
 
95
+ # Single HPF at 20 Hz (12 dB/oct)
96
+ eq_effects.append(HighpassFilter(cutoff_frequency_hz=20.0))
 
97
 
98
+ # Single LPF at 20 kHz (12 dB/oct)
99
+ eq_effects.append(LowpassFilter(cutoff_frequency_hz=20000.0))
 
100
 
101
  # Bass boost (user controls gain and frequency)
102
  if bass_boost_db > 0:
 
111
  eq_effects.append(LowShelfFilter(
112
  cutoff_frequency_hz=200.0, gain_db=float(lows_db), q=1.0))
113
 
114
+ # Highs shelf
115
  if highs_db != 0:
116
  eq_effects.append(HighShelfFilter(
117
+ cutoff_frequency_hz=6000.0, gain_db=float(highs_db), q=0.7))
118
 
119
  # Mid boost/cut (Q=1.0 for wider, more musical presence)
120
  if mid_boost_db != 0:
 
125
  ))
126
 
127
  eq_board = Pedalboard(eq_effects)
128
+ processed = eq_board(audio.T, sample_rate).T
129
+
130
+ # --- Stage 2: Stereo width (before dynamics to catch peak changes) ---
131
+ processed = apply_stereo_width(processed, stereo_width)
132
+
133
+ # --- Stage 3: Compression + Makeup Gain ---
134
+ threshold, ratio, attack, release = map_compression(comp_value)
135
+
136
+ comp_effects = []
137
+ comp_effects.append(Compressor(
138
+ threshold_db=threshold,
139
+ ratio=ratio,
140
+ attack_ms=attack,
141
+ release_ms=release,
142
+ ))
143
+
144
+ # Makeup gain (estimated from compression amount)
145
+ if ratio > 1.0:
146
+ makeup_db = abs(threshold) * (1.0 - 1.0 / ratio) * 0.4
147
+ makeup_db = min(makeup_db, 12.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  else:
149
+ makeup_db = 0.0
150
+ comp_effects.append(Gain(gain_db=makeup_db))
151
+
152
+ comp_board = Pedalboard(comp_effects)
153
+ processed = comp_board(processed.T, sample_rate).T
154
+
155
+ # --- Stage 4: Auto-reduce gain to keep limiter under 4 dB reduction ---
156
+ pre_limiter_peak = measure_true_peak(processed, sample_rate)
157
+ would_reduce = max(pre_limiter_peak - limiter_ceiling, 0.0)
158
+ auto_gain_reduction = 0.0
159
+ max_limiter_reduction = 4.0
160
+
161
+ if would_reduce > max_limiter_reduction:
162
+ auto_gain_reduction = would_reduce - max_limiter_reduction
163
+ reduction_board = Pedalboard([Gain(gain_db=-auto_gain_reduction)])
164
+ processed = reduction_board(processed.T, sample_rate).T
165
+
166
+ # --- Stage 5: Limiter ---
167
+ # Measure actual peak going into limiter (after auto-reduce)
168
+ limiter_input_peak = measure_true_peak(processed, sample_rate)
169
+ limiter_gain_reduction = max(limiter_input_peak - limiter_ceiling, 0.0)
170
+
171
+ limiter_board = Pedalboard([
172
+ Limiter(threshold_db=limiter_ceiling, release_ms=100.0)
173
+ ])
174
+ processed = limiter_board(processed.T, sample_rate).T
175
+
176
+ # --- Stage 6: Strict LUFS normalization (post-processing) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  pre_norm_lufs = measure_loudness(processed, sample_rate)
178
  if not np.isinf(pre_norm_lufs):
179
  processed = normalize_to_lufs(
180
  processed, sample_rate, pre_norm_lufs, target_lufs
181
  ).astype(np.float32)
182
 
183
+ # --- Stage 7: Safety catch limiter for stray true peaks ---
184
+ post_norm_peak = measure_true_peak(processed, sample_rate)
185
+ if post_norm_peak > limiter_ceiling:
186
+ safety_limiter = Pedalboard([
187
+ Limiter(threshold_db=limiter_ceiling, release_ms=100.0)
188
+ ])
189
+ processed = safety_limiter(processed.T, sample_rate).T
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  # --- Final measurements ---
192
  mast_lufs = measure_loudness(processed, sample_rate)
 
194
 
195
  is_mono = original.ndim == 1 or original.shape[1] == 1
196
 
197
+ # Warning if auto gain reduction exceeded 2 dB
198
+ warning = ""
199
+ if auto_gain_reduction > 2.0:
200
+ warning = "Consider reducing EQ and Bass Boost settings and try again"
201
+
202
  stats = {
203
  "orig_lufs": orig_lufs,
204
  "orig_peak": orig_peak,
205
  "mast_lufs": mast_lufs,
206
  "mast_peak": mast_peak,
207
  "mono": is_mono,
208
+ "auto_gain_reduction": auto_gain_reduction,
209
+ "limiter_gain_reduction": limiter_gain_reduction,
210
+ "warning": warning,
211
  }
212
 
213
  output_path = save_audio(processed, sample_rate)
launch.bat DELETED
@@ -1,21 +0,0 @@
1
- @echo off
2
- title StudioAI Audio Mastering Suite - Local
3
- echo Starting StudioAI Audio Mastering Suite...
4
- echo.
5
-
6
- set GOOGLE_API_KEY=AIzaSyCgdP4-cAnLwbNikVz04u-al4px4LzTGFM
7
- set AI_ACCESS_KEY=StudioAI#!mastering41
8
- set "AI_ACCESS_KEY_2=OFiOIFJdoheio&474Fieu"
9
- set UNKEY_API_ID=api_pNZfzVKBwVEx
10
- set UNKEY_ROOT_KEY=unkey_3ZFmunwPRAvA5hrobow6UKGa
11
-
12
- cd /d "%~dp0"
13
-
14
- :loop
15
- echo.
16
- echo [%date% %time%] Launching...
17
- python app.py
18
- echo.
19
- echo [%date% %time%] Process exited. Restarting in 3 seconds...
20
- timeout /t 3 /nobreak >nul
21
- goto loop
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
presets.py CHANGED
@@ -4,142 +4,52 @@ PRESETS = {
4
  "-- None --": None,
5
 
6
  "Modern Pop (Bright & Wide)": {
7
- "lows_db": 0.0,
8
  "mid_boost_db": 1.0,
9
- "highs_db": 1.5,
10
  "bass_boost_db": 1.0,
11
  "bass_freq_hz": 60,
12
- "compression": 80, # Ratio ~2.2:1, Rel 130ms (Snappy, cohesive glue)
13
  "stereo_width": 115,
14
  },
15
 
16
  "Vintage Analog (Warm & Intimate)": {
17
- "lows_db": 1.0,
18
  "mid_boost_db": 0.0,
19
  "highs_db": -1.5,
20
- "bass_boost_db": 1.0,
21
- "bass_freq_hz": 80, # 80Hz = warmth/body (not 50Hz sub-rumble)
22
- "compression": 50, # Ratio 1.8:1, Rel 175ms (Smooth, optical feel)
23
  "stereo_width": 95,
24
  },
25
 
26
  "Heavy Trap / EDM (Aggressive Bass)": {
27
- "lows_db": 0.0,
28
  "mid_boost_db": -0.5,
29
  "highs_db": 1.0,
30
- "bass_boost_db": 2.0,
31
- "bass_freq_hz": 50,
32
- "compression": 100, # Ratio 2.5:1, Rel 100ms (Max safe bounce)
33
  "stereo_width": 125,
34
  },
35
 
36
  "Hard Rock / Metal (Punch & Bite)": {
37
  "lows_db": -0.5,
38
- "mid_boost_db": 1.0,
39
- "highs_db": 0.5, # Reduced — 6-10kHz is the harshness/fizz zone for high-gain guitars
40
  "bass_boost_db": 1.5,
41
- "bass_freq_hz": 60, # 60Hz = kick thump sweet spot
42
- "compression": 90, # Ratio ~2.3:1, Rel 115ms (Tight transient recovery)
43
  "stereo_width": 110,
44
  },
45
 
46
- "Acoustic & Vocal (Clear & Present)": {
47
  "lows_db": -0.5,
48
- "mid_boost_db": 1.0, # Reduced from 1.5 — less coloration, more transparent
49
  "highs_db": 1.0,
50
  "bass_boost_db": 0.0,
51
  "bass_freq_hz": 55,
52
- "compression": 30, # Ratio 1.5:1, Rel 205ms (Invisible leveling)
53
- "stereo_width": 100,
54
- },
55
-
56
- "Deep Ambient (Immersive & Sustained)": {
57
- "lows_db": -1.0, # Cut the mud to let the sub-bass breathe
58
- "mid_boost_db": 0.0, # Flat mids to keep the soundscape distant
59
- "highs_db": 1.5, # Boost the 'Air' for shimmering reverb tails
60
- "bass_boost_db": 1.0, # Gentle lift to the existing heavy pad
61
- "bass_freq_hz": 50, # Focus the weight low (Dread Zone/Gravity)
62
- "compression": 20, # Low ratio, slow release. Invisible leveling, no pumping.
63
- "stereo_width": 125, # Extra wide to wrap the sound around the listener
64
- },
65
-
66
- "Hip-Hop / Boom Bap (Punch & Grit)": {
67
- "lows_db": -0.5, # Cut low-mids for clarity — boom bap dips around 150Hz
68
- "mid_boost_db": 1.0, # Bring the rapper's vocal right to the front
69
- "highs_db": -0.5, # Rolled-off highs for signature lo-fi/vinyl grit
70
- "bass_boost_db": 1.5, # Add weight...
71
- "bass_freq_hz": 60, # ...but at 60Hz to emphasize the 'thump' of the kick, not the sub-rumble
72
- "compression": 75, # Snappy compression to make the drums knock
73
- "stereo_width": 100, # Keep the beat dead center for maximum impact
74
- },
75
-
76
- "Cinematic / Orchestral (Dynamic & Wide)": {
77
- "lows_db": 0.5, # Gentle weight for cellos/basses (0.5 dB — orchestral moves are subtle)
78
- "mid_boost_db": -0.5, # Scoop slightly to push the orchestra "back" into the room
79
- "highs_db": 0.5, # Subtle air for the strings and brass
80
- "bass_boost_db": 0.0, # Let the natural low-end breathe without artificial boosting
81
- "bass_freq_hz": 50,
82
- "compression": 0, # ZERO compression. Preserve all natural dynamic swells and drops.
83
- "stereo_width": 110, # Subtle widening — research warns against aggressive wideners on orchestral
84
- },
85
-
86
- "Indie / Alt-Rock (Organic & Glued)": {
87
- "lows_db": 0.5, # Gentle body for the bass guitar
88
- "mid_boost_db": 0.5, # Subtle push for guitars and vocal presence
89
- "highs_db": 0.5, # Just a kiss of air for the cymbals
90
- "bass_boost_db": 0.5, # A very light touch on the kick fundamental
91
- "bass_freq_hz": 60,
92
- "compression": 45, # Moderate glue. Holds the band together without crushing dynamics.
93
- "stereo_width": 105, # Keeps the band feeling "in the room"
94
- },
95
-
96
- "R&B / Soul (Silky & Smooth)": {
97
- "lows_db": 0.5, # Warm low-mid body without mud
98
- "mid_boost_db": 1.0, # Smooth, intimate vocal presence
99
- "highs_db": 0.5, # Silky top end, not overly bright or harsh
100
- "bass_boost_db": 1.0, # Round, deep, controlled bass
101
- "bass_freq_hz": 60,
102
- "compression": 55, # Medium glue, smooth recovery for vocals
103
- "stereo_width": 105, # Natural, immersive width
104
- },
105
-
106
- "Lo-Fi / Chillhop (Dusty & Narrow)": {
107
- "lows_db": 1.5, # Thick, warm midrange for sample texture
108
- "mid_boost_db": 0.0,
109
- "highs_db": -2.5, # Aggressively rolled off for tape/vinyl aesthetic
110
- "bass_boost_db": 1.0, # Warm, blunt kick thud
111
- "bass_freq_hz": 60,
112
- "compression": 35, # Gentle, optical-style leveling
113
- "stereo_width": 90, # Narrow width emulating vintage samplers
114
- },
115
-
116
- "Jazz (Live & Dynamic)": {
117
- "lows_db": -0.5, # Keep mud away from upright bass/piano overlap
118
- "mid_boost_db": 1.0, # Bring horns and brushwork forward into the room
119
- "highs_db": 0.5, # Natural acoustic air
120
- "bass_boost_db": 0.0, # Zero artificial sub-bass; rely on the recording
121
- "bass_freq_hz": 60,
122
- "compression": 10, # Almost zero compression; absolute dynamic preservation
123
- "stereo_width": 100, # True to the physical room
124
- },
125
-
126
- "Reggae / Dub (Heavy Sound System)": {
127
- "lows_db": 1.0, # Thick low-mids for the rhythm section
128
- "mid_boost_db": -1.0, # Scooped mids to make room for massive bass and tape delays
129
- "highs_db": 0.5, # Slice through for hi-hats and echoes
130
- "bass_boost_db": 2.5, # Massive sound-system weight
131
- "bass_freq_hz": 80, # Targeted directly at the classic reggae bassline fundamental
132
- "compression": 85, # Heavy pumping to lock in the off-beat groove
133
- "stereo_width": 115, # Wide space for panning delays
134
- },
135
-
136
- "Synthwave / Retrowave (Analog & Driving)": {
137
- "lows_db": 1.0, # Thick low-mids for analog synth body
138
- "mid_boost_db": -0.5, # Slight scoop to carve out room for the kick and snare
139
- "highs_db": 1.5, # Crisp, shimmering highs for 80s synth leads and hi-hats
140
- "bass_boost_db": 1.5, # Driving, relentless synth-bass weight
141
- "bass_freq_hz": 60, # Targeted at the punch of the Linndrum/707 kick
142
- "compression": 75, # Heavy, driving glue to keep the grid tight and relentless
143
- "stereo_width": 120, # Wide and cinematic, like a neon highway
144
  },
145
- }
 
4
  "-- None --": None,
5
 
6
  "Modern Pop (Bright & Wide)": {
7
+ "lows_db": 0.5,
8
  "mid_boost_db": 1.0,
9
+ "highs_db": 2.0,
10
  "bass_boost_db": 1.0,
11
  "bass_freq_hz": 60,
12
+ "compression": 60,
13
  "stereo_width": 115,
14
  },
15
 
16
  "Vintage Analog (Warm & Intimate)": {
17
+ "lows_db": 2.0,
18
  "mid_boost_db": 0.0,
19
  "highs_db": -1.5,
20
+ "bass_boost_db": 1.5,
21
+ "bass_freq_hz": 50,
22
+ "compression": 40,
23
  "stereo_width": 95,
24
  },
25
 
26
  "Heavy Trap / EDM (Aggressive Bass)": {
27
+ "lows_db": 1.0,
28
  "mid_boost_db": -0.5,
29
  "highs_db": 1.0,
30
+ "bass_boost_db": 3.0,
31
+ "bass_freq_hz": 55,
32
+ "compression": 85,
33
  "stereo_width": 125,
34
  },
35
 
36
  "Hard Rock / Metal (Punch & Bite)": {
37
  "lows_db": -0.5,
38
+ "mid_boost_db": 1.5,
39
+ "highs_db": 1.0,
40
  "bass_boost_db": 1.5,
41
+ "bass_freq_hz": 60,
42
+ "compression": 70,
43
  "stereo_width": 110,
44
  },
45
 
46
+ "Acoustic & Vocal (Transparent)": {
47
  "lows_db": -0.5,
48
+ "mid_boost_db": 1.5,
49
  "highs_db": 1.0,
50
  "bass_boost_db": 0.0,
51
  "bass_freq_hz": 55,
52
+ "compression": 15,
53
+ "stereo_width": 105,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  },
55
+ }
sAI.png ADDED

Git LFS Details

  • SHA256: 53095b344b27b88764febc4ec2ca864dd4879d529e755b7674594001a2fd03fd
  • Pointer size: 131 Bytes
  • Size of remote file: 908 kB
stereo.py CHANGED
@@ -1,44 +1,14 @@
1
- """Mid/Side stereo width processing with frequency-selective crossover."""
2
 
3
  import numpy as np
4
- from scipy.signal import butter, sosfiltfilt
5
 
6
 
7
- # Crossover frequency: bass below this stays mono, width applies above
8
- _CROSSOVER_HZ = 200.0
9
-
10
-
11
- def linkwitz_riley_crossover(audio, sample_rate, crossover_hz):
12
- """Split audio into low and high bands using a Linkwitz-Riley 4th-order
13
- crossover (two cascaded 2nd-order Butterworth filters).
14
-
15
- Returns (low_band, high_band) each with the same shape as audio.
16
- """
17
- nyquist = sample_rate / 2.0
18
- # Clamp to valid range for the filter
19
- freq = min(crossover_hz, nyquist * 0.95)
20
-
21
- sos = butter(2, freq, btype='low', fs=sample_rate, output='sos')
22
-
23
- # Linkwitz-Riley = two passes of Butterworth (sosfiltfilt = forward+backward
24
- # = zero-phase, effectively 4th-order LR behaviour)
25
- low = sosfiltfilt(sos, audio, axis=0).astype(np.float32)
26
- high = (audio - low).astype(np.float32)
27
-
28
- return low, high
29
-
30
-
31
- def apply_stereo_width(audio, width_percent, sample_rate=44100):
32
- """Apply stereo width adjustment using frequency-selective M/S encoding.
33
-
34
- Bass below 200 Hz stays mono-preserving (no width change) to keep
35
- the low end tight and phase-coherent on club/mono systems.
36
- Width adjustment applies only to frequencies above the crossover.
37
 
38
  Args:
39
  audio: numpy array of shape (samples, 2), float32.
40
  width_percent: 80 to 150. 100 = no change.
41
- sample_rate: int, sample rate for crossover filter.
42
 
43
  Returns:
44
  numpy array of shape (samples, 2), float32.
@@ -48,19 +18,12 @@ def apply_stereo_width(audio, width_percent, sample_rate=44100):
48
 
49
  width_factor = width_percent / 100.0
50
 
51
- # If width is unity, skip all processing
52
- if width_factor == 1.0:
53
- return audio
54
 
55
- # Split into low (< 200 Hz) and high (>= 200 Hz) bands
56
- low, high = linkwitz_riley_crossover(audio, sample_rate, _CROSSOVER_HZ)
57
-
58
- # Apply M/S width to HIGH band only
59
- left_h = high[:, 0]
60
- right_h = high[:, 1]
61
-
62
- mid = (left_h + right_h) / 2.0
63
- side = (left_h - right_h) / 2.0
64
 
65
  # Energy-preserving scaling
66
  mid_scale = np.sqrt(2.0 / (1.0 + width_factor ** 2))
@@ -69,13 +32,11 @@ def apply_stereo_width(audio, width_percent, sample_rate=44100):
69
  mid_out = mid * mid_scale
70
  side_out = side * side_scale
71
 
 
72
  left_out = mid_out + side_out
73
  right_out = mid_out - side_out
74
 
75
- high_widened = np.column_stack([left_out, right_out])
76
-
77
- # Recombine: untouched low band + widened high band
78
- result = low + high_widened
79
 
80
  # Prevent clipping from width expansion
81
  peak = np.max(np.abs(result))
 
1
+ """Mid/Side stereo width processing."""
2
 
3
  import numpy as np
 
4
 
5
 
6
+ def apply_stereo_width(audio, width_percent):
7
+ """Apply stereo width adjustment using M/S encoding.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  Args:
10
  audio: numpy array of shape (samples, 2), float32.
11
  width_percent: 80 to 150. 100 = no change.
 
12
 
13
  Returns:
14
  numpy array of shape (samples, 2), float32.
 
18
 
19
  width_factor = width_percent / 100.0
20
 
21
+ left = audio[:, 0]
22
+ right = audio[:, 1]
 
23
 
24
+ # Encode to M/S
25
+ mid = (left + right) / 2.0
26
+ side = (left - right) / 2.0
 
 
 
 
 
 
27
 
28
  # Energy-preserving scaling
29
  mid_scale = np.sqrt(2.0 / (1.0 + width_factor ** 2))
 
32
  mid_out = mid * mid_scale
33
  side_out = side * side_scale
34
 
35
+ # Decode back to L/R
36
  left_out = mid_out + side_out
37
  right_out = mid_out - side_out
38
 
39
+ result = np.column_stack([left_out, right_out])
 
 
 
40
 
41
  # Prevent clipping from width expansion
42
  peak = np.max(np.abs(result))
visualization.py CHANGED
@@ -54,17 +54,11 @@ def plot_waveform_comparison(original, mastered, sample_rate):
54
 
55
 
56
  def plot_spectrum_comparison(original, mastered, sample_rate):
57
- """Create a frequency spectrum comparison with shape-normalized overlay
58
- and a difference trace showing the processing's spectral impact.
59
-
60
- The mastered spectrum is level-aligned to the original so the plot
61
- compares spectral *shape*, not overall loudness (LUFS stats handle that).
62
 
63
  Returns a matplotlib Figure.
64
  """
65
- fig, (ax_spec, ax_diff) = plt.subplots(
66
- 2, 1, figsize=(8, 5), height_ratios=[3, 1], sharex=True,
67
- )
68
 
69
  orig_mono = _to_mono(original)
70
  mast_mono = _to_mono(mastered)
@@ -88,38 +82,15 @@ def plot_spectrum_comparison(original, mastered, sample_rate):
88
  freqs_o, spec_o = avg_spectrum(orig_mono, n_fft, sample_rate)
89
  freqs_m, spec_m = avg_spectrum(mast_mono, n_fft, sample_rate)
90
 
91
- # --- Level-align mastered to original (remove overall loudness diff) ---
92
- # Use only the passband (100 Hz 10 kHz) for alignment so the HPF/LPF
93
- # roll-offs at the extremes don't skew the offset.
94
- passband = (freqs_o >= 100) & (freqs_o <= 10000)
95
- level_offset = np.mean(spec_o[passband]) - np.mean(spec_m[passband])
96
- spec_m_aligned = spec_m + level_offset
97
-
98
- # --- Top: overlaid spectra (shape comparison) ---
99
- ax_spec.plot(freqs_o, spec_o, color="#4a90d9", alpha=0.7, linewidth=1,
100
- label="Original")
101
- ax_spec.plot(freqs_m, spec_m_aligned, color="#d94a4a", alpha=0.7,
102
- linewidth=1, label="Mastered (level-aligned)")
103
- ax_spec.set_ylabel("Magnitude (dB)")
104
- ax_spec.set_title("Spectral Shape Comparison")
105
- ax_spec.legend(loc="upper right", fontsize=8)
106
- ax_spec.grid(True, alpha=0.3)
107
-
108
- # --- Bottom: difference (mastered − original) ---
109
- diff = spec_m_aligned - spec_o
110
- ax_diff.plot(freqs_o, diff, color="#2ca02c", linewidth=1)
111
- ax_diff.axhline(0, color="gray", linewidth=0.5, linestyle="--")
112
- ax_diff.set_ylabel("Δ dB")
113
- ax_diff.set_xlabel("Frequency")
114
- ax_diff.set_title("Processing Difference (Mastered − Original)", fontsize=9)
115
- ax_diff.set_ylim(-6, 6)
116
- ax_diff.grid(True, alpha=0.3)
117
-
118
- # Shared x-axis settings
119
- ax_diff.set_xscale("log")
120
- ax_diff.set_xlim(20, sample_rate / 2)
121
- ax_diff.set_xticks([10, 100, 1000, 10000])
122
- ax_diff.set_xticklabels(["10 Hz", "100 Hz", "1 kHz", "10 kHz"])
123
 
124
  plt.tight_layout()
125
  return fig
 
54
 
55
 
56
  def plot_spectrum_comparison(original, mastered, sample_rate):
57
+ """Create an overlaid frequency spectrum comparison.
 
 
 
 
58
 
59
  Returns a matplotlib Figure.
60
  """
61
+ fig, ax = plt.subplots(1, 1, figsize=(8, 3))
 
 
62
 
63
  orig_mono = _to_mono(original)
64
  mast_mono = _to_mono(mastered)
 
82
  freqs_o, spec_o = avg_spectrum(orig_mono, n_fft, sample_rate)
83
  freqs_m, spec_m = avg_spectrum(mast_mono, n_fft, sample_rate)
84
 
85
+ ax.plot(freqs_o, spec_o, color="#4a90d9", alpha=0.7, linewidth=1, label="Original")
86
+ ax.plot(freqs_m, spec_m, color="#d94a4a", alpha=0.7, linewidth=1, label="Mastered")
87
+ ax.set_xscale("log")
88
+ ax.set_xlim(20, sample_rate / 2)
89
+ ax.set_xlabel("Frequency (Hz)")
90
+ ax.set_ylabel("Magnitude (dB)")
91
+ ax.set_title("Frequency Spectrum Comparison")
92
+ ax.legend()
93
+ ax.grid(True, alpha=0.3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  plt.tight_layout()
96
  return fig