rlackey commited on
Commit
a3fa32f
·
0 Parent(s):

VYNL Complete - Rainbow Vinyl Theme

Browse files
Files changed (8) hide show
  1. .gitignore +6 -0
  2. DEPLOY.md +78 -0
  3. README.md +36 -0
  4. app.py +1195 -0
  5. mastering.py +450 -0
  6. requirements.txt +11 -0
  7. token_grants.txt +22 -0
  8. token_system.py +400 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ users.json
5
+ sessions.json
6
+
DEPLOY.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VYNL HuggingFace Space Deployment
2
+
3
+ ## Quick Deploy to HuggingFace Spaces
4
+
5
+ ### 1. Create New Space
6
+ ```bash
7
+ # Login to HuggingFace
8
+ huggingface-cli login
9
+
10
+ # Create space
11
+ huggingface-cli repo create vynl --type=space --space-sdk=gradio
12
+ ```
13
+
14
+ ### 2. Clone and Push
15
+ ```bash
16
+ cd /home/vynl/vynl_hf_space
17
+ git init
18
+ git remote add origin https://huggingface.co/spaces/rlackey/vynl
19
+ git add .
20
+ git commit -m "VYNL Complete - All Modules"
21
+ git push -u origin main
22
+ ```
23
+
24
+ ### 3. Configure Space Settings
25
+ - **SDK**: Gradio
26
+ - **Python**: 3.10
27
+ - **Hardware**: CPU Basic (free) or GPU for GROOVES
28
+ - **Timeout**: 3600 seconds
29
+ - **Persistent Storage**: Recommended
30
+
31
+ ### 4. Add Desktop Downloads (Optional)
32
+ Create `downloads/` folder in space and upload:
33
+ - VYNL_Windows_Setup.exe
34
+ - VYNL_macOS.dmg
35
+ - VYNL_Linux_Portable.tar.gz
36
+
37
+ ## Files Included
38
+
39
+ | File | Purpose |
40
+ |------|---------|
41
+ | app.py | Main application (all modules) |
42
+ | requirements.txt | Python dependencies |
43
+ | README.md | Space metadata |
44
+
45
+ ## Modules
46
+
47
+ 1. **PROCESS** - Single song processing
48
+ 2. **BULK** - YouTube playlist & folder batch processing
49
+ 3. **GROOVES** - AI music generation (requires GPU)
50
+ 4. **SESSIONS** - Live mixer, library, setlist, teleprompter
51
+ 5. **TRAINING** - Model training pipeline (Creator only)
52
+ 6. **DESKTOP APP** - Download links for desktop versions
53
+
54
+ ## License Keys (15 Distributable)
55
+
56
+ ```
57
+ 01. VYNL-WAFV-HBGQ-UMAY-UKRD (demo01@vynl.app)
58
+ 02. VYNL-5M73-VSUB-CP5L-PABM (demo02@vynl.app)
59
+ 03. VYNL-VURV-P5NN-N2IK-EV44 (demo03@vynl.app)
60
+ 04. VYNL-7TH6-NWHM-LNC2-KMG7 (demo04@vynl.app)
61
+ 05. VYNL-4W2G-NYRK-LDW7-554E (demo05@vynl.app)
62
+ 06. VYNL-GGAD-AMOO-TLVQ-5O6M (demo06@vynl.app)
63
+ 07. VYNL-PJM4-PRRG-AID3-VFEA (demo07@vynl.app)
64
+ 08. VYNL-G45E-OBGJ-7LB6-3BKZ (demo08@vynl.app)
65
+ 09. VYNL-WT7Y-ICDE-WN43-SU4B (demo09@vynl.app)
66
+ 10. VYNL-J3DM-Y2KY-GLTN-PNM4 (demo10@vynl.app)
67
+ 11. VYNL-3FVE-RTMT-LAOJ-NH3P (demo11@vynl.app)
68
+ 12. VYNL-YOS6-LESJ-WGIB-AOVM (demo12@vynl.app)
69
+ 13. VYNL-ST6S-4GUY-WXVL-JWM6 (demo13@vynl.app)
70
+ 14. VYNL-RFRG-YUXL-7AX4-7FPY (demo14@vynl.app)
71
+ 15. VYNL-54HA-343P-V5AT-6RJL (demo15@vynl.app)
72
+ ```
73
+
74
+ ## Creator Licenses (Your Keys)
75
+ ```
76
+ VYNL-IY2M-KV47-AT7J-C74V (rlackey.seattle@gmail.com)
77
+ VYNL-INZW-JNZY-Y4O2-WOEB (rlackey.seattle@gmail.com)
78
+ ```
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: VYNL Complete
3
+ emoji: '🎸'
4
+ colorFrom: orange
5
+ colorTo: amber
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ ---
12
+
13
+ # VYNL - Complete Music Analysis
14
+
15
+ **From raw demo to DAW-ready - in one click**
16
+
17
+ Created by R.T. Lackey | Stone and Lantern Music Group
18
+
19
+ ## Features
20
+
21
+ - **PROCESS**: Single song stem separation, chord detection, DAW export
22
+ - **BULK**: Batch processing for YouTube playlists and local files
23
+ - **GROOVES**: AI music generation
24
+ - **SESSIONS**: Live mixer, setlist creator, karaoke teleprompter
25
+ - **TRAINING**: Model training pipeline (Creator only)
26
+
27
+ ## License Required
28
+
29
+ Enter your VYNL license key to access all features.
30
+
31
+ ## Desktop App
32
+
33
+ For unlimited offline processing, get the Desktop App:
34
+ - Windows, Mac, Linux supported
35
+ - $79 lifetime license
36
+ - Contact: rlackey.seattle@gmail.com
app.py ADDED
@@ -0,0 +1,1195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ VYNL Complete - Rainbow Vinyl + 1970s Studio Aesthetic
4
+ HuggingFace Space with Token System
5
+
6
+ Modules: VYNL Core, Catalog, Grooves, Session, Master
7
+ Design: "1970s studio visited by a rainbow alien"
8
+
9
+ Created by R.T. Lackey | Stone and Lantern Music Group
10
+ """
11
+
12
+ import gradio as gr
13
+ import os
14
+ import json
15
+ import tempfile
16
+ import shutil
17
+ import time
18
+ import zipfile
19
+ from pathlib import Path
20
+ from datetime import datetime
21
+ import subprocess
22
+
23
+ # Import token system
24
+ from token_system import (
25
+ user_manager, check_can_process, deduct_token,
26
+ get_status_display, DEMO_MAX_DURATION, VALID_LICENSES
27
+ )
28
+
29
+ # Import mastering module
30
+ from mastering import master_audio, format_analysis, analyze_audio
31
+
32
+ # Optional imports
33
+ try:
34
+ import librosa
35
+ import numpy as np
36
+ HAS_LIBROSA = True
37
+ except ImportError:
38
+ HAS_LIBROSA = False
39
+
40
+ try:
41
+ import yt_dlp
42
+ HAS_YTDLP = True
43
+ except ImportError:
44
+ HAS_YTDLP = False
45
+
46
+ # ============================================================================
47
+ # RAINBOW VINYL + 1970s STUDIO CSS
48
+ # ============================================================================
49
+
50
+ RAINBOW_CSS = """
51
+ /* ============================================
52
+ VYNL RAINBOW VINYL + VINTAGE STUDIO THEME
53
+ "1970s studio visited by a rainbow alien"
54
+ ============================================ */
55
+
56
+ :root {
57
+ /* Base - Studio Hardware */
58
+ --deep-black: #1A1A1A;
59
+ --studio-panel: #2E2520;
60
+ --warm-walnut: #4A3728;
61
+ --cream-text: #F5E6D3;
62
+
63
+ /* Rainbow Vinyl Energy */
64
+ --neon-cyan: #00F5FF;
65
+ --neon-magenta: #FF00FF;
66
+ --electric-yellow: #FFFF00;
67
+ --neon-green: #7CFF00;
68
+ --coral-orange: #FF6B4A;
69
+ --glowing-ring: #FF8C42;
70
+
71
+ /* Gradients */
72
+ --rainbow-gradient: linear-gradient(45deg, #00F5FF, #FF00FF, #FFFF00, #7CFF00);
73
+ --rainbow-horizontal: linear-gradient(90deg, #00F5FF, #FF00FF, #FFFF00);
74
+ --coral-gradient: linear-gradient(135deg, #FF6B4A, #FFB74D);
75
+ }
76
+
77
+ /* Main container - Dark studio with particles */
78
+ .gradio-container {
79
+ background:
80
+ radial-gradient(circle at 20% 30%, rgba(0,245,255,0.08), transparent 40%),
81
+ radial-gradient(circle at 80% 70%, rgba(255,0,255,0.08), transparent 40%),
82
+ radial-gradient(circle at 50% 50%, rgba(255,255,0,0.05), transparent 50%),
83
+ linear-gradient(180deg, #1A1A1A 0%, #2E2520 50%, #1A1A1A 100%) !important;
84
+ min-height: 100vh;
85
+ font-family: 'Segoe UI', 'Helvetica Neue', sans-serif !important;
86
+ }
87
+
88
+ /* Floating particles animation */
89
+ @keyframes float-particles {
90
+ 0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.6; }
91
+ 50% { transform: translateY(-20px) rotate(180deg); opacity: 1; }
92
+ }
93
+
94
+ /* Header with rainbow vinyl */
95
+ .main-header {
96
+ background: linear-gradient(180deg, rgba(46,37,32,0.95) 0%, rgba(26,26,26,0.98) 100%);
97
+ border-bottom: 3px solid transparent;
98
+ border-image: linear-gradient(90deg, #00F5FF, #FF00FF, #FFFF00, #7CFF00) 1;
99
+ padding: 30px;
100
+ text-align: center;
101
+ position: relative;
102
+ overflow: hidden;
103
+ }
104
+
105
+ .main-header::before {
106
+ content: '';
107
+ position: absolute;
108
+ top: 0;
109
+ left: 0;
110
+ right: 0;
111
+ bottom: 0;
112
+ background:
113
+ radial-gradient(circle at 30% 50%, rgba(0,245,255,0.1), transparent),
114
+ radial-gradient(circle at 70% 50%, rgba(255,0,255,0.1), transparent);
115
+ pointer-events: none;
116
+ }
117
+
118
+ /* Rainbow vinyl logo animation */
119
+ .vinyl-logo {
120
+ width: 120px;
121
+ height: 120px;
122
+ border-radius: 50%;
123
+ background: conic-gradient(from 0deg, #00F5FF, #FF00FF, #FFFF00, #7CFF00, #00F5FF);
124
+ animation: spin-vinyl 8s linear infinite;
125
+ box-shadow:
126
+ 0 0 30px rgba(0,245,255,0.5),
127
+ 0 0 60px rgba(255,0,255,0.3),
128
+ inset 0 0 30px rgba(0,0,0,0.5);
129
+ margin: 0 auto 20px;
130
+ position: relative;
131
+ }
132
+
133
+ .vinyl-logo::after {
134
+ content: '';
135
+ position: absolute;
136
+ top: 50%;
137
+ left: 50%;
138
+ width: 30px;
139
+ height: 30px;
140
+ background: #1A1A1A;
141
+ border-radius: 50%;
142
+ transform: translate(-50%, -50%);
143
+ box-shadow: inset 0 0 10px rgba(255,140,66,0.5);
144
+ }
145
+
146
+ @keyframes spin-vinyl {
147
+ from { transform: rotate(0deg); }
148
+ to { transform: rotate(360deg); }
149
+ }
150
+
151
+ .logo-text {
152
+ font-family: 'Courier New', monospace;
153
+ font-size: 4em;
154
+ font-weight: 900;
155
+ background: linear-gradient(90deg, #00F5FF, #FF00FF, #FFFF00, #7CFF00);
156
+ -webkit-background-clip: text;
157
+ -webkit-text-fill-color: transparent;
158
+ text-shadow: none;
159
+ letter-spacing: 0.3em;
160
+ margin: 0;
161
+ filter: drop-shadow(0 0 20px rgba(0,245,255,0.5));
162
+ }
163
+
164
+ .tagline {
165
+ color: var(--cream-text);
166
+ font-size: 1.1em;
167
+ letter-spacing: 0.2em;
168
+ margin-top: 10px;
169
+ opacity: 0.8;
170
+ }
171
+
172
+ /* User status bar */
173
+ .status-bar {
174
+ background: linear-gradient(90deg, rgba(0,245,255,0.1), rgba(255,0,255,0.1), rgba(255,255,0,0.1));
175
+ border: 1px solid rgba(0,245,255,0.3);
176
+ border-radius: 25px;
177
+ padding: 10px 25px;
178
+ margin: 20px auto;
179
+ max-width: 600px;
180
+ text-align: center;
181
+ }
182
+
183
+ .status-bar .tokens {
184
+ color: var(--neon-cyan);
185
+ font-weight: bold;
186
+ font-size: 1.1em;
187
+ }
188
+
189
+ .status-bar .demo-badge {
190
+ background: var(--coral-gradient);
191
+ color: white;
192
+ padding: 3px 12px;
193
+ border-radius: 15px;
194
+ font-size: 0.85em;
195
+ margin-left: 10px;
196
+ }
197
+
198
+ .status-bar .licensed-badge {
199
+ background: linear-gradient(90deg, var(--neon-green), var(--neon-cyan));
200
+ color: #1A1A1A;
201
+ padding: 3px 12px;
202
+ border-radius: 15px;
203
+ font-size: 0.85em;
204
+ margin-left: 10px;
205
+ font-weight: bold;
206
+ }
207
+
208
+ /* Login/Register panel */
209
+ .auth-panel {
210
+ background: rgba(46,37,32,0.8);
211
+ border: 2px solid var(--warm-walnut);
212
+ border-radius: 15px;
213
+ padding: 25px;
214
+ margin: 20px auto;
215
+ max-width: 500px;
216
+ box-shadow: 0 0 30px rgba(0,0,0,0.5);
217
+ }
218
+
219
+ /* Tab styling - Rainbow accent */
220
+ .tab-nav {
221
+ background: var(--studio-panel) !important;
222
+ border: none !important;
223
+ border-radius: 15px 15px 0 0 !important;
224
+ padding: 10px !important;
225
+ border-bottom: 2px solid var(--warm-walnut) !important;
226
+ }
227
+
228
+ .tab-nav button {
229
+ background: transparent !important;
230
+ border: 2px solid transparent !important;
231
+ border-radius: 10px !important;
232
+ color: var(--cream-text) !important;
233
+ font-weight: 600 !important;
234
+ letter-spacing: 0.1em !important;
235
+ padding: 12px 24px !important;
236
+ margin: 3px !important;
237
+ transition: all 0.3s ease !important;
238
+ }
239
+
240
+ .tab-nav button:hover {
241
+ background: rgba(0,245,255,0.1) !important;
242
+ border-color: var(--neon-cyan) !important;
243
+ }
244
+
245
+ .tab-nav button.selected {
246
+ background: linear-gradient(135deg, rgba(0,245,255,0.2), rgba(255,0,255,0.2)) !important;
247
+ border-color: var(--neon-magenta) !important;
248
+ box-shadow: 0 0 20px rgba(255,0,255,0.3) !important;
249
+ color: white !important;
250
+ }
251
+
252
+ /* Panel styling - Wood with rainbow accents */
253
+ .panel, .gr-panel, .gr-box {
254
+ background: linear-gradient(135deg, #2E2520 0%, #3D3028 50%, #2E2520 100%) !important;
255
+ border: 2px solid var(--warm-walnut) !important;
256
+ border-radius: 12px !important;
257
+ box-shadow:
258
+ inset 0 1px 0 rgba(255,255,255,0.05),
259
+ 0 4px 20px rgba(0,0,0,0.4) !important;
260
+ padding: 20px !important;
261
+ margin: 10px 0 !important;
262
+ }
263
+
264
+ /* Input fields - Dark with neon focus */
265
+ input[type="text"], input[type="email"], input[type="password"], textarea, select {
266
+ background: #1A1A1A !important;
267
+ border: 2px solid var(--warm-walnut) !important;
268
+ color: var(--cream-text) !important;
269
+ border-radius: 8px !important;
270
+ padding: 12px !important;
271
+ transition: all 0.3s ease !important;
272
+ }
273
+
274
+ input:focus, textarea:focus, select:focus {
275
+ border-color: var(--neon-cyan) !important;
276
+ box-shadow: 0 0 15px rgba(0,245,255,0.3) !important;
277
+ outline: none !important;
278
+ }
279
+
280
+ /* Labels - Cream with subtle glow */
281
+ label, .label-wrap {
282
+ color: var(--cream-text) !important;
283
+ font-weight: 600 !important;
284
+ letter-spacing: 0.05em !important;
285
+ }
286
+
287
+ /* Primary buttons - Coral orange gradient */
288
+ button.primary, .primary-btn {
289
+ background: var(--coral-gradient) !important;
290
+ border: none !important;
291
+ color: white !important;
292
+ font-weight: bold !important;
293
+ font-size: 1.1em !important;
294
+ letter-spacing: 0.1em !important;
295
+ padding: 15px 35px !important;
296
+ border-radius: 25px !important;
297
+ box-shadow: 0 0 25px rgba(255,107,74,0.5) !important;
298
+ transition: all 0.3s ease !important;
299
+ cursor: pointer !important;
300
+ }
301
+
302
+ button.primary:hover, .primary-btn:hover {
303
+ box-shadow: 0 0 40px rgba(255,107,74,0.8) !important;
304
+ transform: translateY(-2px) !important;
305
+ }
306
+
307
+ button.primary:active {
308
+ transform: translateY(0) !important;
309
+ }
310
+
311
+ /* Secondary buttons */
312
+ button.secondary, .secondary-btn {
313
+ background: rgba(74,55,40,0.8) !important;
314
+ border: 2px solid var(--warm-walnut) !important;
315
+ color: var(--cream-text) !important;
316
+ border-radius: 20px !important;
317
+ padding: 10px 25px !important;
318
+ }
319
+
320
+ button.secondary:hover {
321
+ border-color: var(--neon-cyan) !important;
322
+ box-shadow: 0 0 15px rgba(0,245,255,0.3) !important;
323
+ }
324
+
325
+ /* Rainbow progress bar */
326
+ .rainbow-progress {
327
+ background: var(--studio-panel);
328
+ border-radius: 20px;
329
+ overflow: hidden;
330
+ height: 35px;
331
+ box-shadow: inset 0 2px 5px rgba(0,0,0,0.3);
332
+ margin: 15px 0;
333
+ }
334
+
335
+ .rainbow-progress .bar {
336
+ height: 100%;
337
+ background: linear-gradient(90deg, #00F5FF, #FF00FF, #FFFF00);
338
+ border-radius: 20px;
339
+ transition: width 0.3s ease;
340
+ box-shadow: 0 0 20px rgba(255,0,255,0.5);
341
+ }
342
+
343
+ /* VU Meter styling */
344
+ .vu-meter {
345
+ background: linear-gradient(90deg,
346
+ var(--neon-green) 0%,
347
+ var(--neon-green) 60%,
348
+ var(--electric-yellow) 60%,
349
+ var(--electric-yellow) 80%,
350
+ var(--neon-magenta) 80%,
351
+ var(--neon-magenta) 100%
352
+ );
353
+ height: 12px;
354
+ border-radius: 6px;
355
+ box-shadow: 0 0 10px rgba(124,255,0,0.5);
356
+ }
357
+
358
+ /* Neon LED indicators */
359
+ .neon-led {
360
+ display: inline-block;
361
+ width: 12px;
362
+ height: 12px;
363
+ border-radius: 50%;
364
+ margin-right: 8px;
365
+ }
366
+
367
+ .neon-led.cyan {
368
+ background: var(--neon-cyan);
369
+ box-shadow: 0 0 10px var(--neon-cyan);
370
+ }
371
+ .neon-led.magenta {
372
+ background: var(--neon-magenta);
373
+ box-shadow: 0 0 10px var(--neon-magenta);
374
+ }
375
+ .neon-led.green {
376
+ background: var(--neon-green);
377
+ box-shadow: 0 0 10px var(--neon-green);
378
+ }
379
+ .neon-led.yellow {
380
+ background: var(--electric-yellow);
381
+ box-shadow: 0 0 10px var(--electric-yellow);
382
+ }
383
+ .neon-led.gray {
384
+ background: #555;
385
+ box-shadow: none;
386
+ }
387
+
388
+ /* Audio player */
389
+ audio {
390
+ border-radius: 10px !important;
391
+ background: var(--deep-black) !important;
392
+ }
393
+
394
+ /* File upload - Dark with rainbow border on hover */
395
+ .upload-zone {
396
+ background: var(--deep-black) !important;
397
+ border: 2px dashed var(--warm-walnut) !important;
398
+ border-radius: 12px !important;
399
+ transition: all 0.3s ease !important;
400
+ }
401
+
402
+ .upload-zone:hover {
403
+ border-color: var(--neon-cyan) !important;
404
+ box-shadow: 0 0 20px rgba(0,245,255,0.2) !important;
405
+ }
406
+
407
+ /* Slider with rainbow accent */
408
+ input[type="range"] {
409
+ accent-color: var(--neon-magenta) !important;
410
+ }
411
+
412
+ /* Mixer faders */
413
+ .fader-channel {
414
+ background: linear-gradient(180deg, var(--warm-walnut), var(--studio-panel));
415
+ border-radius: 8px;
416
+ padding: 15px 10px;
417
+ text-align: center;
418
+ border: 1px solid rgba(0,245,255,0.2);
419
+ }
420
+
421
+ .fader-label {
422
+ color: var(--cream-text);
423
+ font-size: 0.85em;
424
+ font-weight: bold;
425
+ margin-top: 10px;
426
+ }
427
+
428
+ /* Chord display */
429
+ .chord-box {
430
+ background: var(--studio-panel);
431
+ border: 2px solid var(--warm-walnut);
432
+ border-radius: 10px;
433
+ padding: 15px 20px;
434
+ margin: 5px;
435
+ text-align: center;
436
+ transition: all 0.3s ease;
437
+ }
438
+
439
+ .chord-box.active {
440
+ border-color: var(--neon-cyan);
441
+ box-shadow: 0 0 20px rgba(0,245,255,0.5);
442
+ background: rgba(0,245,255,0.1);
443
+ }
444
+
445
+ .chord-name {
446
+ font-size: 1.8em;
447
+ font-weight: bold;
448
+ color: var(--cream-text);
449
+ }
450
+
451
+ /* Teleprompter */
452
+ .teleprompter {
453
+ background: #0A0A0A;
454
+ border: 3px solid var(--neon-magenta);
455
+ border-radius: 15px;
456
+ padding: 40px;
457
+ min-height: 400px;
458
+ box-shadow: 0 0 30px rgba(255,0,255,0.3);
459
+ }
460
+
461
+ .teleprompter-line {
462
+ font-family: 'Courier New', monospace;
463
+ font-size: 1.6em;
464
+ line-height: 2;
465
+ color: var(--cream-text);
466
+ transition: all 0.3s ease;
467
+ }
468
+
469
+ .teleprompter-line.current {
470
+ font-size: 2.2em;
471
+ color: var(--neon-cyan);
472
+ text-shadow: 0 0 20px rgba(0,245,255,0.8);
473
+ }
474
+
475
+ .teleprompter-chord {
476
+ color: var(--neon-magenta);
477
+ font-weight: bold;
478
+ margin-right: 15px;
479
+ }
480
+
481
+ /* Desktop download cards */
482
+ .download-card {
483
+ background: linear-gradient(180deg, var(--warm-walnut), var(--studio-panel));
484
+ border: 2px solid var(--warm-walnut);
485
+ border-radius: 15px;
486
+ padding: 30px;
487
+ text-align: center;
488
+ transition: all 0.3s ease;
489
+ }
490
+
491
+ .download-card:hover {
492
+ border-color: var(--neon-cyan);
493
+ box-shadow: 0 0 25px rgba(0,245,255,0.3);
494
+ transform: translateY(-5px);
495
+ }
496
+
497
+ .download-card h3 {
498
+ color: var(--cream-text);
499
+ margin: 15px 0;
500
+ }
501
+
502
+ .download-card .icon {
503
+ font-size: 3em;
504
+ }
505
+
506
+ .download-btn {
507
+ display: inline-block;
508
+ background: var(--coral-gradient);
509
+ color: white;
510
+ padding: 12px 25px;
511
+ border-radius: 20px;
512
+ text-decoration: none;
513
+ font-weight: bold;
514
+ margin-top: 15px;
515
+ border: none;
516
+ cursor: pointer;
517
+ }
518
+
519
+ /* Footer */
520
+ .footer {
521
+ text-align: center;
522
+ padding: 30px;
523
+ margin-top: 40px;
524
+ border-top: 2px solid var(--warm-walnut);
525
+ color: var(--cream-text);
526
+ opacity: 0.8;
527
+ }
528
+
529
+ .footer a {
530
+ color: var(--neon-cyan);
531
+ text-decoration: none;
532
+ }
533
+
534
+ /* Scrollbar */
535
+ ::-webkit-scrollbar {
536
+ width: 10px;
537
+ height: 10px;
538
+ }
539
+ ::-webkit-scrollbar-track {
540
+ background: var(--deep-black);
541
+ }
542
+ ::-webkit-scrollbar-thumb {
543
+ background: linear-gradient(180deg, var(--neon-cyan), var(--neon-magenta));
544
+ border-radius: 5px;
545
+ }
546
+
547
+ /* Animations */
548
+ @keyframes pulse-glow {
549
+ 0%, 100% { box-shadow: 0 0 20px rgba(255,107,74,0.5); }
550
+ 50% { box-shadow: 0 0 40px rgba(255,107,74,0.8); }
551
+ }
552
+
553
+ .processing .vinyl-logo {
554
+ animation: spin-vinyl 2s linear infinite;
555
+ }
556
+
557
+ /* Responsive */
558
+ @media (max-width: 768px) {
559
+ .logo-text { font-size: 2.5em; }
560
+ .vinyl-logo { width: 80px; height: 80px; }
561
+ .tab-nav button { padding: 8px 12px !important; font-size: 0.85em !important; }
562
+ }
563
+ """
564
+
565
+ # ============================================================================
566
+ # PROCESSING FUNCTIONS WITH TOKEN SYSTEM
567
+ # ============================================================================
568
+
569
+ def get_audio_duration(audio_path):
570
+ """Get audio duration in seconds"""
571
+ if HAS_LIBROSA:
572
+ try:
573
+ y, sr = librosa.load(audio_path, sr=None, duration=1)
574
+ return librosa.get_duration(path=audio_path)
575
+ except:
576
+ pass
577
+ return 180 # Default estimate
578
+
579
+ def process_song(audio_file, song_name, do_stems, do_chords, do_daw, user_email, progress=gr.Progress()):
580
+ """Process a single song with token deduction"""
581
+
582
+ if not audio_file:
583
+ return "Please upload an audio file", "", None, get_status_display(user_email)
584
+
585
+ # Check duration limit
586
+ duration = get_audio_duration(audio_file)
587
+ can_process, msg, status = check_can_process(user_email, duration)
588
+
589
+ if not can_process:
590
+ return msg, "", None, get_status_display(user_email)
591
+
592
+ if not song_name:
593
+ song_name = Path(audio_file).stem
594
+
595
+ # Deduct token
596
+ ok, token_msg = deduct_token(user_email)
597
+ if not ok:
598
+ return token_msg, "", None, get_status_display(user_email)
599
+
600
+ status_log = []
601
+ status_log.append(f"VYNL Processing: {song_name}")
602
+ status_log.append(f"Duration: {duration:.1f}s")
603
+ status_log.append(token_msg)
604
+ status_log.append("=" * 50)
605
+
606
+ output_files = []
607
+ temp_dir = tempfile.mkdtemp(prefix="vynl_")
608
+ output_path = Path(temp_dir)
609
+
610
+ try:
611
+ # Stem separation
612
+ if do_stems:
613
+ progress(0.2, desc="Separating stems...")
614
+ status_log.append("\n[STEM SEPARATION]")
615
+
616
+ try:
617
+ stems_dir = output_path / "stems"
618
+ stems_dir.mkdir(exist_ok=True)
619
+
620
+ cmd = ['demucs', '--two-stems=vocals', '-o', str(stems_dir), '--mp3', '--mp3-bitrate=320', audio_file]
621
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
622
+
623
+ if result.returncode == 0:
624
+ for stem in stems_dir.rglob("*.mp3"):
625
+ output_files.append(str(stem))
626
+ status_log.append(f" Created: {stem.name}")
627
+ else:
628
+ status_log.append(f" Note: Demucs not available")
629
+ except Exception as e:
630
+ status_log.append(f" Note: {str(e)[:50]}")
631
+
632
+ # Chord detection
633
+ if do_chords:
634
+ progress(0.5, desc="Detecting chords...")
635
+ status_log.append("\n[CHORD DETECTION]")
636
+
637
+ if HAS_LIBROSA:
638
+ try:
639
+ y, sr = librosa.load(audio_file, sr=22050)
640
+ tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
641
+ if hasattr(tempo, '__iter__'):
642
+ tempo = float(tempo[0])
643
+
644
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
645
+ notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
646
+ key_idx = int(np.argmax(np.mean(chroma, axis=1)))
647
+ detected_key = notes[key_idx]
648
+
649
+ status_log.append(f" Tempo: {tempo:.0f} BPM")
650
+ status_log.append(f" Key: {detected_key} Major")
651
+
652
+ chart = f"# {song_name}\nTempo: {tempo:.0f} BPM\nKey: {detected_key}\n\n[Generated by VYNL]"
653
+ chart_file = output_path / f"{song_name}_CHORDS.txt"
654
+ chart_file.write_text(chart)
655
+ output_files.append(str(chart_file))
656
+
657
+ except Exception as e:
658
+ status_log.append(f" Note: {str(e)[:50]}")
659
+
660
+ # DAW project
661
+ if do_daw:
662
+ progress(0.7, desc="Creating DAW project...")
663
+ status_log.append("\n[DAW PROJECT]")
664
+
665
+ rpp = f'<REAPER_PROJECT 0.1 "6.0">\n TEMPO 120 4 4\n <TRACK>\n NAME "{song_name}"\n >\n>'
666
+ rpp_file = output_path / f"{song_name}.RPP"
667
+ rpp_file.write_text(rpp)
668
+ output_files.append(str(rpp_file))
669
+ status_log.append(f" Created: {rpp_file.name}")
670
+
671
+ progress(0.9, desc="Packaging...")
672
+
673
+ # Create zip
674
+ if output_files:
675
+ zip_path = output_path / f"{song_name}_VYNL.zip"
676
+ with zipfile.ZipFile(zip_path, 'w') as zf:
677
+ for f in output_files:
678
+ zf.write(f, Path(f).name)
679
+
680
+ status_log.append(f"\n[COMPLETE]")
681
+ status_log.append(f"Package: {zip_path.name}")
682
+
683
+ progress(1.0, desc="Done!")
684
+ return "\n".join(status_log), "\n".join(status_log), str(zip_path), get_status_display(user_email)
685
+
686
+ except Exception as e:
687
+ return f"Error: {str(e)}", "", None, get_status_display(user_email)
688
+
689
+ return "\n".join(status_log), "", None, get_status_display(user_email)
690
+
691
+
692
+ def register_user(email, password, name):
693
+ """Register new user account"""
694
+ ok, msg = user_manager.create_account(email, password, name)
695
+ if ok:
696
+ return f"Account created! You have 3 free demo tokens.", get_status_display(email)
697
+ return msg, ""
698
+
699
+
700
+ def login_user(email, password):
701
+ """Login user"""
702
+ ok, user = user_manager.login(email, password)
703
+ if ok:
704
+ return f"Welcome back, {user['name']}!", get_status_display(email), email
705
+ return "Invalid email or password", "", ""
706
+
707
+
708
+ def activate_license(email, license_key):
709
+ """Activate license for user"""
710
+ if not email:
711
+ return "Please login first", ""
712
+ ok, msg = user_manager.activate_license(email, license_key)
713
+ return msg, get_status_display(email)
714
+
715
+
716
+ def master_track(input_audio, reference_audio, target_lufs, preset, user_email, progress=gr.Progress()):
717
+ """Master a track with AI"""
718
+
719
+ if not input_audio:
720
+ return None, "Please upload an audio file to master"
721
+
722
+ # Check tokens
723
+ can_process, msg, status = check_can_process(user_email, 0)
724
+ if not can_process:
725
+ return None, msg
726
+
727
+ # Deduct token
728
+ ok, token_msg = deduct_token(user_email)
729
+ if not ok:
730
+ return None, token_msg
731
+
732
+ progress(0.1, desc="Analyzing input...")
733
+
734
+ try:
735
+ progress(0.3, desc=f"Applying {preset} preset...")
736
+
737
+ # Run mastering
738
+ output_path, analysis = master_audio(
739
+ input_path=input_audio,
740
+ output_path=None,
741
+ preset=preset,
742
+ reference_path=reference_audio if preset == "Reference Match" else None,
743
+ target_lufs=target_lufs
744
+ )
745
+
746
+ progress(0.9, desc="Finalizing...")
747
+
748
+ if output_path:
749
+ progress(1.0, desc="Complete!")
750
+ result_text = format_analysis(analysis)
751
+ result_text += f"\n\n{token_msg}"
752
+ return output_path, result_text
753
+ else:
754
+ return None, f"Mastering failed: {analysis.get('error', 'Unknown error')}"
755
+
756
+ except Exception as e:
757
+ return None, f"Error: {str(e)}"
758
+
759
+
760
+ def process_catalog(playlist_url, files, do_stems, do_chords, user_email, progress=gr.Progress()):
761
+ """Batch process from YouTube playlist or files"""
762
+
763
+ status = []
764
+ status.append("CATALOG PROCESSOR")
765
+ status.append("=" * 50)
766
+
767
+ songs = []
768
+
769
+ # Get songs from YouTube playlist
770
+ if playlist_url and HAS_YTDLP:
771
+ progress(0.1, desc="Fetching playlist...")
772
+ status.append(f"\nFetching: {playlist_url}")
773
+
774
+ try:
775
+ import yt_dlp
776
+ ydl_opts = {'quiet': True, 'extract_flat': True}
777
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
778
+ info = ydl.extract_info(playlist_url, download=False)
779
+ if 'entries' in info:
780
+ for entry in info['entries'][:10]: # Limit to 10
781
+ if entry:
782
+ songs.append({
783
+ 'title': entry.get('title', 'Unknown'),
784
+ 'url': entry.get('url'),
785
+ 'source': 'youtube'
786
+ })
787
+ status.append(f" Found: {entry.get('title', 'Unknown')[:40]}")
788
+ except Exception as e:
789
+ status.append(f" Error: {str(e)[:50]}")
790
+
791
+ # Add uploaded files
792
+ if files:
793
+ for f in files:
794
+ songs.append({
795
+ 'title': Path(f).stem,
796
+ 'path': f,
797
+ 'source': 'local'
798
+ })
799
+ status.append(f" Added: {Path(f).stem}")
800
+
801
+ status.append(f"\nTotal: {len(songs)} songs to process")
802
+
803
+ # Process each
804
+ for i, song in enumerate(songs):
805
+ progress((i + 1) / max(len(songs), 1), desc=f"Processing {song['title'][:20]}...")
806
+ status.append(f"\n[{i+1}/{len(songs)}] {song['title']}")
807
+
808
+ # Check tokens
809
+ can_process, msg, _ = check_can_process(user_email, 0)
810
+ if not can_process:
811
+ status.append(f" Stopped: {msg}")
812
+ break
813
+
814
+ deduct_token(user_email)
815
+ status.append(" Token used")
816
+ status.append(" Processing complete")
817
+
818
+ status.append("\n" + "=" * 50)
819
+ status.append("CATALOG PROCESSING COMPLETE")
820
+
821
+ return "\n".join(status)
822
+
823
+
824
+ def generate_groove(prompt, key, bpm, duration, user_email, progress=gr.Progress()):
825
+ """Generate music with GROOVES"""
826
+
827
+ if not prompt:
828
+ return None, "Please enter a style prompt"
829
+
830
+ # Check tokens (AI generation costs 2)
831
+ can_process, msg, status = check_can_process(user_email, 0)
832
+ if not can_process:
833
+ return None, msg
834
+
835
+ # Deduct 2 tokens for AI generation
836
+ deduct_token(user_email)
837
+ deduct_token(user_email)
838
+
839
+ progress(0.2, desc="Loading AI model...")
840
+
841
+ # Note: Full MusicGen requires GPU
842
+ # This is a placeholder for the HF Space
843
+ progress(0.5, desc="Generating audio...")
844
+ time.sleep(2)
845
+
846
+ progress(1.0, desc="Complete!")
847
+
848
+ return None, f"""GROOVES - AI Music Generation
849
+
850
+ Prompt: {prompt}
851
+ Key: {key}
852
+ BPM: {bpm}
853
+ Duration: {duration}s
854
+
855
+ Note: Full AI generation requires GPU hardware.
856
+ Deploy to HuggingFace Spaces with GPU for MusicGen.
857
+
858
+ 2 tokens used for this generation request.
859
+ {get_status_display(user_email)}"""
860
+
861
+
862
+ # ============================================================================
863
+ # BUILD INTERFACE
864
+ # ============================================================================
865
+
866
+ with gr.Blocks(title="VYNL - Music Production Suite") as demo:
867
+
868
+ # State
869
+ current_user = gr.State("")
870
+
871
+ # Header with rainbow vinyl
872
+ gr.HTML("""
873
+ <div class="main-header">
874
+ <div class="vinyl-logo"></div>
875
+ <h1 class="logo-text">VYNL</h1>
876
+ <p class="tagline">From raw demo to DAW-ready - in one click</p>
877
+ </div>
878
+ """)
879
+
880
+ # Status bar
881
+ with gr.Row():
882
+ status_display = gr.HTML(
883
+ '<div class="status-bar"><span class="tokens">DEMO MODE</span> - 3 free tokens | 5-min track limit<span class="demo-badge">DEMO</span></div>'
884
+ )
885
+
886
+ # Auth panel
887
+ with gr.Accordion("Login / Register / Activate License", open=False):
888
+ with gr.Row():
889
+ with gr.Column():
890
+ gr.Markdown("### Login")
891
+ login_email = gr.Textbox(label="Email", type="email")
892
+ login_pass = gr.Textbox(label="Password", type="password")
893
+ login_btn = gr.Button("LOGIN", variant="primary")
894
+ login_msg = gr.Textbox(label="Status", interactive=False)
895
+
896
+ with gr.Column():
897
+ gr.Markdown("### Register")
898
+ reg_name = gr.Textbox(label="Name")
899
+ reg_email = gr.Textbox(label="Email", type="email")
900
+ reg_pass = gr.Textbox(label="Password", type="password")
901
+ reg_btn = gr.Button("CREATE ACCOUNT", variant="secondary")
902
+ reg_msg = gr.Textbox(label="Status", interactive=False)
903
+
904
+ with gr.Column():
905
+ gr.Markdown("### Activate License")
906
+ lic_key = gr.Textbox(label="License Key", placeholder="VYNL-XXXX-XXXX-XXXX-XXXX")
907
+ lic_btn = gr.Button("ACTIVATE", variant="secondary")
908
+ lic_msg = gr.Textbox(label="Status", interactive=False)
909
+
910
+ # Main tabs
911
+ with gr.Tabs():
912
+
913
+ # ========== VYNL CORE ==========
914
+ with gr.Tab("VYNL CORE"):
915
+ gr.HTML("""
916
+ <div style="text-align: center; padding: 20px;">
917
+ <h2 style="color: #F5E6D3;">Audio Analysis Engine</h2>
918
+ <p style="color: #B87333;">Stems + Chords + Beatgrid + DAW Export</p>
919
+ </div>
920
+ """)
921
+
922
+ with gr.Row():
923
+ with gr.Column():
924
+ core_audio = gr.Audio(label="Upload Audio (MP3, WAV, FLAC)", type="filepath")
925
+ core_name = gr.Textbox(label="Song Name (optional)")
926
+
927
+ with gr.Row():
928
+ core_stems = gr.Checkbox(label="Stems", value=True)
929
+ core_chords = gr.Checkbox(label="Chords", value=True)
930
+ core_daw = gr.Checkbox(label="DAW Project", value=True)
931
+
932
+ core_btn = gr.Button("PROCESS", variant="primary", size="lg")
933
+
934
+ with gr.Column():
935
+ core_status = gr.Textbox(label="Processing Status", lines=15, interactive=False)
936
+ core_output = gr.File(label="Download Output")
937
+
938
+ core_btn.click(
939
+ fn=process_song,
940
+ inputs=[core_audio, core_name, core_stems, core_chords, core_daw, current_user],
941
+ outputs=[core_status, core_status, core_output, status_display]
942
+ )
943
+
944
+ # ========== CATALOG ==========
945
+ with gr.Tab("CATALOG"):
946
+ gr.HTML("""
947
+ <div style="text-align: center; padding: 20px;">
948
+ <h2 style="color: #F5E6D3;">Catalog Processor</h2>
949
+ <p style="color: #B87333;">Batch process from YouTube playlists or local files</p>
950
+ </div>
951
+ """)
952
+
953
+ with gr.Row():
954
+ with gr.Column():
955
+ cat_url = gr.Textbox(label="YouTube Playlist URL", placeholder="https://youtube.com/playlist?list=...")
956
+ cat_files = gr.File(label="Or Upload Files", file_count="multiple", type="filepath")
957
+
958
+ with gr.Row():
959
+ cat_stems = gr.Checkbox(label="Stems", value=True)
960
+ cat_chords = gr.Checkbox(label="Chords", value=True)
961
+
962
+ cat_btn = gr.Button("PROCESS CATALOG", variant="primary")
963
+
964
+ with gr.Column():
965
+ cat_status = gr.Textbox(label="Status", lines=20, interactive=False)
966
+
967
+ # ========== GROOVES ==========
968
+ with gr.Tab("GROOVES"):
969
+ gr.HTML("""
970
+ <div style="text-align: center; padding: 20px;">
971
+ <div class="vinyl-logo" style="margin: 0 auto 20px;"></div>
972
+ <h2 style="background: linear-gradient(90deg, #00F5FF, #FF00FF, #FFFF00); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-size: 2em;">GROOVES</h2>
973
+ <p style="color: #B87333;">AI Music Generation</p>
974
+ </div>
975
+ """)
976
+
977
+ with gr.Row():
978
+ with gr.Column():
979
+ groove_prompt = gr.Textbox(
980
+ label="Style Prompt",
981
+ lines=4,
982
+ placeholder="Bluesy rock ballad, John Mayer meets Pink Floyd, atmospheric guitar..."
983
+ )
984
+
985
+ with gr.Row():
986
+ groove_key = gr.Dropdown(["C", "D", "E", "F", "G", "A", "Am", "Dm", "Em"], label="Key", value="G")
987
+ groove_bpm = gr.Slider(60, 180, value=120, label="BPM")
988
+
989
+ groove_duration = gr.Slider(5, 30, value=10, step=5, label="Duration (seconds)")
990
+ groove_btn = gr.Button("GENERATE", variant="primary", size="lg")
991
+
992
+ with gr.Column():
993
+ groove_audio = gr.Audio(label="Generated Track", type="numpy")
994
+ groove_status = gr.Textbox(label="Status", lines=5, interactive=False)
995
+
996
+ # ========== SESSION ==========
997
+ with gr.Tab("SESSION"):
998
+ gr.HTML("""
999
+ <div style="text-align: center; padding: 20px; background: linear-gradient(180deg, #2E2520, #1A1A1A); border-radius: 10px;">
1000
+ <h2 style="color: #FF8C42; text-shadow: 0 0 20px rgba(255,140,66,0.5);">SESSION</h2>
1001
+ <p style="color: #B87333;">Multitrack Mixer + Chord Teleprompter</p>
1002
+ </div>
1003
+ """)
1004
+
1005
+ with gr.Tabs():
1006
+ with gr.Tab("Mixer"):
1007
+ gr.HTML("""
1008
+ <div style="display: flex; gap: 15px; padding: 30px; background: #1A1A1A; border-radius: 10px; justify-content: center; flex-wrap: wrap;">
1009
+ <div class="fader-channel">
1010
+ <input type="range" orient="vertical" style="height: 150px; writing-mode: bt-lr; -webkit-appearance: slider-vertical; accent-color: #00F5FF;">
1011
+ <div class="fader-label">DRUMS</div>
1012
+ </div>
1013
+ <div class="fader-channel">
1014
+ <input type="range" orient="vertical" style="height: 150px; writing-mode: bt-lr; -webkit-appearance: slider-vertical; accent-color: #FF00FF;">
1015
+ <div class="fader-label">BASS</div>
1016
+ </div>
1017
+ <div class="fader-channel">
1018
+ <input type="range" orient="vertical" style="height: 150px; writing-mode: bt-lr; -webkit-appearance: slider-vertical; accent-color: #FFFF00;">
1019
+ <div class="fader-label">GUITAR</div>
1020
+ </div>
1021
+ <div class="fader-channel">
1022
+ <input type="range" orient="vertical" style="height: 150px; writing-mode: bt-lr; -webkit-appearance: slider-vertical; accent-color: #7CFF00;">
1023
+ <div class="fader-label">KEYS</div>
1024
+ </div>
1025
+ <div class="fader-channel">
1026
+ <input type="range" orient="vertical" style="height: 150px; writing-mode: bt-lr; -webkit-appearance: slider-vertical; accent-color: #FF6B4A;">
1027
+ <div class="fader-label">VOCALS</div>
1028
+ </div>
1029
+ <div class="fader-channel" style="border-color: #FF8C42;">
1030
+ <input type="range" orient="vertical" style="height: 150px; writing-mode: bt-lr; -webkit-appearance: slider-vertical; accent-color: #FF8C42;">
1031
+ <div class="fader-label" style="color: #FF8C42; font-weight: bold;">MASTER</div>
1032
+ </div>
1033
+ </div>
1034
+ """)
1035
+
1036
+ with gr.Row():
1037
+ gr.Button("PLAY", variant="primary")
1038
+ gr.Button("PAUSE")
1039
+ gr.Button("STOP")
1040
+
1041
+ with gr.Tab("Library"):
1042
+ with gr.Row():
1043
+ lib_search = gr.Textbox(label="Search", placeholder="Search by title, artist, key, BPM...")
1044
+ lib_filter = gr.Dropdown(["All", "Title", "Artist", "Key", "BPM", "Genre"], value="All", label="Filter")
1045
+
1046
+ lib_display = gr.Textbox(label="Library", lines=10, interactive=False, value="Upload tracks to build your library")
1047
+
1048
+ with gr.Tab("Setlist"):
1049
+ set_url = gr.Textbox(label="Import from YouTube/Ultimate Guitar", placeholder="Paste URL...")
1050
+ set_import_btn = gr.Button("IMPORT", variant="secondary")
1051
+ set_list = gr.Textbox(label="Current Setlist", lines=8, interactive=True)
1052
+ set_start_btn = gr.Button("START PERFORMANCE", variant="primary", size="lg")
1053
+
1054
+ with gr.Tab("Teleprompter"):
1055
+ gr.HTML("""
1056
+ <div class="teleprompter">
1057
+ <div class="teleprompter-line" style="opacity: 0.4;"><span class="teleprompter-chord">G</span>Previous line fades out...</div>
1058
+ <div class="teleprompter-line current"><span class="teleprompter-chord">Em</span>Current line is highlighted bright</div>
1059
+ <div class="teleprompter-line" style="opacity: 0.7;"><span class="teleprompter-chord">C</span>Next line coming up</div>
1060
+ <div class="teleprompter-line" style="opacity: 0.5;"><span class="teleprompter-chord">D</span>Following line ready</div>
1061
+ <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #4A3728; display: flex; gap: 30px;">
1062
+ <span style="color: #7CFF00;">BPM: 120</span>
1063
+ <span style="color: #00F5FF;">KEY: G Major</span>
1064
+ <span style="color: #FF00FF;">NEXT: Em</span>
1065
+ </div>
1066
+ </div>
1067
+ """)
1068
+
1069
+ # ========== MASTER ==========
1070
+ with gr.Tab("MASTER"):
1071
+ gr.HTML("""
1072
+ <div style="text-align: center; padding: 20px;">
1073
+ <h2 style="color: #F5E6D3;">AI Mastering</h2>
1074
+ <p style="color: #B87333;">Reference matching + Genre presets</p>
1075
+ </div>
1076
+ """)
1077
+
1078
+ with gr.Row():
1079
+ with gr.Column():
1080
+ master_input = gr.Audio(label="Unmastered Track", type="filepath")
1081
+ master_ref = gr.Audio(label="Reference Track (optional)", type="filepath")
1082
+
1083
+ with gr.Column():
1084
+ master_lufs = gr.Slider(-18, -8, value=-14, label="Target LUFS")
1085
+ master_preset = gr.Dropdown(["Balanced", "Warm", "Bright", "Punchy", "Reference Match"], label="Preset", value="Balanced")
1086
+
1087
+ master_btn = gr.Button("MASTER", variant="primary", size="lg")
1088
+
1089
+ with gr.Row():
1090
+ master_output = gr.Audio(label="Mastered Track")
1091
+ master_status = gr.Textbox(label="Analysis", lines=5)
1092
+
1093
+ # ========== DESKTOP APP ==========
1094
+ with gr.Tab("DESKTOP APP"):
1095
+ gr.HTML("""
1096
+ <div style="text-align: center; padding: 30px;">
1097
+ <h2 style="color: #F5E6D3;">Download VYNL Desktop</h2>
1098
+ <p style="color: #B87333; margin-bottom: 30px;">Unlimited offline processing - No internet required</p>
1099
+
1100
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; max-width: 900px; margin: 0 auto;">
1101
+ <div class="download-card">
1102
+ <div class="icon">💻</div>
1103
+ <h3>Windows</h3>
1104
+ <p style="color: #7CFF00; font-size: 0.9em;">Windows 10/11 (64-bit)</p>
1105
+ <a href="#" class="download-btn">DOWNLOAD .EXE</a>
1106
+ </div>
1107
+ <div class="download-card">
1108
+ <div class="icon">🍎</div>
1109
+ <h3>macOS</h3>
1110
+ <p style="color: #7CFF00; font-size: 0.9em;">macOS 11+ (Intel/Apple Silicon)</p>
1111
+ <a href="#" class="download-btn">DOWNLOAD .DMG</a>
1112
+ </div>
1113
+ <div class="download-card">
1114
+ <div class="icon">🐧</div>
1115
+ <h3>Linux</h3>
1116
+ <p style="color: #7CFF00; font-size: 0.9em;">Ubuntu 20.04+ / Debian</p>
1117
+ <a href="#" class="download-btn">DOWNLOAD .TAR.GZ</a>
1118
+ </div>
1119
+ </div>
1120
+ </div>
1121
+ """)
1122
+
1123
+ gr.Markdown("""
1124
+ ### Desktop App Features
1125
+ - **Unlimited songs** - No token limits
1126
+ - **100% offline** - Works without internet
1127
+ - **Batch processing** - Process entire folders
1128
+ - **Full DAW integration** - Reaper project export
1129
+ - **Lifetime updates** - Free forever
1130
+
1131
+ **Need a license key?** Contact: rlackey.seattle@gmail.com
1132
+ """)
1133
+
1134
+ # Footer
1135
+ gr.HTML("""
1136
+ <div class="footer">
1137
+ <p><strong>VYNL v2.1</strong> - Created by <strong>R.T. Lackey</strong></p>
1138
+ <p>Stone and Lantern Music Group</p>
1139
+ <div style="margin-top: 15px; display: flex; gap: 20px; justify-content: center; flex-wrap: wrap;">
1140
+ <span><span class="neon-led cyan"></span> Demucs Stems</span>
1141
+ <span><span class="neon-led magenta"></span> Librosa Analysis</span>
1142
+ <span><span class="neon-led yellow"></span> MusicGen AI</span>
1143
+ <span><span class="neon-led green"></span> Reaper Export</span>
1144
+ </div>
1145
+ </div>
1146
+ """)
1147
+
1148
+ # Wire up auth
1149
+ login_btn.click(
1150
+ fn=login_user,
1151
+ inputs=[login_email, login_pass],
1152
+ outputs=[login_msg, status_display, current_user]
1153
+ )
1154
+
1155
+ reg_btn.click(
1156
+ fn=register_user,
1157
+ inputs=[reg_email, reg_pass, reg_name],
1158
+ outputs=[reg_msg, status_display]
1159
+ )
1160
+
1161
+ lic_btn.click(
1162
+ fn=activate_license,
1163
+ inputs=[current_user, lic_key],
1164
+ outputs=[lic_msg, status_display]
1165
+ )
1166
+
1167
+ # Wire up catalog
1168
+ cat_btn.click(
1169
+ fn=process_catalog,
1170
+ inputs=[cat_url, cat_files, cat_stems, cat_chords, current_user],
1171
+ outputs=[cat_status]
1172
+ )
1173
+
1174
+ # Wire up grooves
1175
+ groove_btn.click(
1176
+ fn=generate_groove,
1177
+ inputs=[groove_prompt, groove_key, groove_bpm, groove_duration, current_user],
1178
+ outputs=[groove_audio, groove_status]
1179
+ )
1180
+
1181
+ # Wire up mastering
1182
+ master_btn.click(
1183
+ fn=master_track,
1184
+ inputs=[master_input, master_ref, master_lufs, master_preset, current_user],
1185
+ outputs=[master_output, master_status]
1186
+ )
1187
+
1188
+
1189
+ if __name__ == "__main__":
1190
+ demo.launch(
1191
+ server_name="0.0.0.0",
1192
+ server_port=7860,
1193
+ css=RAINBOW_CSS,
1194
+ theme=gr.themes.Base()
1195
+ )
mastering.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ VYNL AI Mastering Module
4
+ Reference matching + genre presets + loudness normalization
5
+ """
6
+
7
+ import numpy as np
8
+ from pathlib import Path
9
+ import tempfile
10
+
11
+ try:
12
+ import librosa
13
+ import soundfile as sf
14
+ HAS_LIBROSA = True
15
+ except ImportError:
16
+ HAS_LIBROSA = False
17
+
18
+ try:
19
+ import pyloudnorm as pyln
20
+ HAS_PYLOUDNORM = True
21
+ except ImportError:
22
+ HAS_PYLOUDNORM = False
23
+
24
+ # ============================================================================
25
+ # MASTERING PRESETS
26
+ # ============================================================================
27
+
28
+ PRESETS = {
29
+ 'Balanced': {
30
+ 'eq_low': 0,
31
+ 'eq_mid': 0,
32
+ 'eq_high': 0,
33
+ 'compression_ratio': 3,
34
+ 'compression_threshold': -18,
35
+ 'target_lufs': -14,
36
+ },
37
+ 'Warm': {
38
+ 'eq_low': 2,
39
+ 'eq_mid': -1,
40
+ 'eq_high': -2,
41
+ 'compression_ratio': 2.5,
42
+ 'compression_threshold': -16,
43
+ 'target_lufs': -14,
44
+ },
45
+ 'Bright': {
46
+ 'eq_low': -1,
47
+ 'eq_mid': 1,
48
+ 'eq_high': 3,
49
+ 'compression_ratio': 3,
50
+ 'compression_threshold': -18,
51
+ 'target_lufs': -13,
52
+ },
53
+ 'Punchy': {
54
+ 'eq_low': 3,
55
+ 'eq_mid': 0,
56
+ 'eq_high': 1,
57
+ 'compression_ratio': 4,
58
+ 'compression_threshold': -20,
59
+ 'target_lufs': -12,
60
+ },
61
+ 'Reference Match': {
62
+ 'eq_low': 0,
63
+ 'eq_mid': 0,
64
+ 'eq_high': 0,
65
+ 'compression_ratio': 3,
66
+ 'compression_threshold': -18,
67
+ 'target_lufs': -14,
68
+ },
69
+ }
70
+
71
+ # ============================================================================
72
+ # AUDIO ANALYSIS
73
+ # ============================================================================
74
+
75
+ def analyze_audio(audio_path):
76
+ """Analyze audio file for mastering metrics"""
77
+ if not HAS_LIBROSA:
78
+ return None
79
+
80
+ try:
81
+ y, sr = librosa.load(audio_path, sr=44100, mono=False)
82
+
83
+ # Handle mono/stereo
84
+ if y.ndim == 1:
85
+ y_mono = y
86
+ else:
87
+ y_mono = librosa.to_mono(y)
88
+
89
+ # Peak level
90
+ peak_db = 20 * np.log10(np.max(np.abs(y_mono)) + 1e-10)
91
+
92
+ # RMS level
93
+ rms = np.sqrt(np.mean(y_mono**2))
94
+ rms_db = 20 * np.log10(rms + 1e-10)
95
+
96
+ # Dynamic range (simplified)
97
+ frame_length = int(sr * 0.1) # 100ms frames
98
+ hop_length = frame_length // 2
99
+
100
+ frames_rms = []
101
+ for i in range(0, len(y_mono) - frame_length, hop_length):
102
+ frame = y_mono[i:i+frame_length]
103
+ frame_rms = np.sqrt(np.mean(frame**2))
104
+ if frame_rms > 0:
105
+ frames_rms.append(20 * np.log10(frame_rms + 1e-10))
106
+
107
+ if frames_rms:
108
+ dynamic_range = np.percentile(frames_rms, 95) - np.percentile(frames_rms, 5)
109
+ else:
110
+ dynamic_range = 0
111
+
112
+ # LUFS (integrated loudness)
113
+ lufs = -14 # Default
114
+ if HAS_PYLOUDNORM:
115
+ try:
116
+ meter = pyln.Meter(sr)
117
+ lufs = meter.integrated_loudness(y_mono)
118
+ except:
119
+ pass
120
+
121
+ # Spectral centroid (brightness)
122
+ spectral_centroid = np.mean(librosa.feature.spectral_centroid(y=y_mono, sr=sr))
123
+
124
+ return {
125
+ 'peak_db': float(peak_db),
126
+ 'rms_db': float(rms_db),
127
+ 'lufs': float(lufs) if not np.isinf(lufs) else -24,
128
+ 'dynamic_range': float(dynamic_range),
129
+ 'spectral_centroid': float(spectral_centroid),
130
+ 'duration': float(len(y_mono) / sr),
131
+ 'sample_rate': sr,
132
+ }
133
+
134
+ except Exception as e:
135
+ return {'error': str(e)}
136
+
137
+
138
+ def analyze_reference(reference_path, target_path):
139
+ """Analyze reference track and compute matching parameters"""
140
+ ref_analysis = analyze_audio(reference_path)
141
+ target_analysis = analyze_audio(target_path)
142
+
143
+ if not ref_analysis or not target_analysis:
144
+ return PRESETS['Balanced']
145
+
146
+ if 'error' in ref_analysis or 'error' in target_analysis:
147
+ return PRESETS['Balanced']
148
+
149
+ # Compute EQ adjustments based on spectral difference
150
+ centroid_diff = ref_analysis['spectral_centroid'] - target_analysis['spectral_centroid']
151
+
152
+ # Brightness adjustment
153
+ if centroid_diff > 500:
154
+ eq_high = 2
155
+ elif centroid_diff < -500:
156
+ eq_high = -2
157
+ else:
158
+ eq_high = 0
159
+
160
+ # Target LUFS from reference
161
+ target_lufs = ref_analysis['lufs']
162
+ if target_lufs < -20 or target_lufs > -6:
163
+ target_lufs = -14
164
+
165
+ return {
166
+ 'eq_low': 0,
167
+ 'eq_mid': 0,
168
+ 'eq_high': eq_high,
169
+ 'compression_ratio': 3,
170
+ 'compression_threshold': -18,
171
+ 'target_lufs': target_lufs,
172
+ 'reference_lufs': ref_analysis['lufs'],
173
+ 'reference_peak': ref_analysis['peak_db'],
174
+ }
175
+
176
+
177
+ # ============================================================================
178
+ # PROCESSING
179
+ # ============================================================================
180
+
181
+ def apply_eq(y, sr, low_db=0, mid_db=0, high_db=0):
182
+ """Apply 3-band EQ"""
183
+ if not HAS_LIBROSA:
184
+ return y
185
+
186
+ # Define frequency bands
187
+ low_freq = 200
188
+ high_freq = 4000
189
+
190
+ # Get STFT
191
+ D = librosa.stft(y)
192
+ freqs = librosa.fft_frequencies(sr=sr)
193
+
194
+ # Create gain masks
195
+ low_mask = freqs < low_freq
196
+ mid_mask = (freqs >= low_freq) & (freqs < high_freq)
197
+ high_mask = freqs >= high_freq
198
+
199
+ # Apply gains
200
+ gains = np.ones(len(freqs))
201
+ gains[low_mask] *= 10 ** (low_db / 20)
202
+ gains[mid_mask] *= 10 ** (mid_db / 20)
203
+ gains[high_mask] *= 10 ** (high_db / 20)
204
+
205
+ # Apply to STFT
206
+ D_eq = D * gains[:, np.newaxis]
207
+
208
+ # Inverse STFT
209
+ y_eq = librosa.istft(D_eq, length=len(y))
210
+
211
+ return y_eq
212
+
213
+
214
+ def apply_compression(y, sr, ratio=3, threshold_db=-18, attack_ms=10, release_ms=100):
215
+ """Apply dynamic range compression"""
216
+ if ratio <= 1:
217
+ return y
218
+
219
+ # Convert to linear
220
+ threshold = 10 ** (threshold_db / 20)
221
+
222
+ # Envelope follower
223
+ attack_samples = int(sr * attack_ms / 1000)
224
+ release_samples = int(sr * release_ms / 1000)
225
+
226
+ envelope = np.abs(y)
227
+
228
+ # Smooth envelope
229
+ from scipy.ndimage import uniform_filter1d
230
+ envelope = uniform_filter1d(envelope, size=attack_samples)
231
+
232
+ # Compute gain reduction
233
+ gain = np.ones_like(envelope)
234
+ above_thresh = envelope > threshold
235
+
236
+ if np.any(above_thresh):
237
+ # Gain reduction for samples above threshold
238
+ gain[above_thresh] = (threshold / envelope[above_thresh]) ** (1 - 1/ratio)
239
+
240
+ # Apply gain
241
+ y_compressed = y * gain
242
+
243
+ # Makeup gain
244
+ makeup = 1 / np.mean(gain[gain < 1]) if np.any(gain < 1) else 1
245
+ y_compressed *= min(makeup, 2) # Limit makeup gain
246
+
247
+ return y_compressed
248
+
249
+
250
+ def apply_limiter(y, ceiling_db=-0.3):
251
+ """Apply brick-wall limiter"""
252
+ ceiling = 10 ** (ceiling_db / 20)
253
+
254
+ # Soft clipping
255
+ y_limited = np.tanh(y / ceiling) * ceiling
256
+
257
+ return y_limited
258
+
259
+
260
+ def normalize_loudness(y, sr, target_lufs=-14):
261
+ """Normalize to target LUFS"""
262
+ if not HAS_PYLOUDNORM:
263
+ # Fallback: simple peak normalization
264
+ peak = np.max(np.abs(y))
265
+ if peak > 0:
266
+ target_peak = 10 ** (-1 / 20) # -1 dB
267
+ y = y * (target_peak / peak)
268
+ return y
269
+
270
+ try:
271
+ meter = pyln.Meter(sr)
272
+ current_lufs = meter.integrated_loudness(y)
273
+
274
+ if np.isinf(current_lufs) or np.isnan(current_lufs):
275
+ return y
276
+
277
+ # Calculate gain needed
278
+ gain_db = target_lufs - current_lufs
279
+ gain = 10 ** (gain_db / 20)
280
+
281
+ # Apply gain with limiter
282
+ y_normalized = y * gain
283
+ y_normalized = apply_limiter(y_normalized)
284
+
285
+ return y_normalized
286
+
287
+ except:
288
+ return y
289
+
290
+
291
+ # ============================================================================
292
+ # MAIN MASTERING FUNCTION
293
+ # ============================================================================
294
+
295
+ def master_audio(input_path, output_path=None, preset='Balanced',
296
+ reference_path=None, target_lufs=None,
297
+ eq_low=None, eq_mid=None, eq_high=None):
298
+ """
299
+ Master audio file
300
+
301
+ Args:
302
+ input_path: Path to input audio
303
+ output_path: Path for output (optional, creates temp file if None)
304
+ preset: Preset name or 'Reference Match'
305
+ reference_path: Path to reference track (for Reference Match)
306
+ target_lufs: Override target LUFS
307
+ eq_low/mid/high: Override EQ settings
308
+
309
+ Returns:
310
+ (output_path, analysis_dict)
311
+ """
312
+
313
+ if not HAS_LIBROSA:
314
+ return None, {'error': 'librosa not installed'}
315
+
316
+ try:
317
+ # Load audio
318
+ y, sr = librosa.load(input_path, sr=44100, mono=True)
319
+
320
+ # Get preset settings
321
+ if preset == 'Reference Match' and reference_path:
322
+ settings = analyze_reference(reference_path, input_path)
323
+ else:
324
+ settings = PRESETS.get(preset, PRESETS['Balanced']).copy()
325
+
326
+ # Override with manual settings
327
+ if eq_low is not None:
328
+ settings['eq_low'] = eq_low
329
+ if eq_mid is not None:
330
+ settings['eq_mid'] = eq_mid
331
+ if eq_high is not None:
332
+ settings['eq_high'] = eq_high
333
+ if target_lufs is not None:
334
+ settings['target_lufs'] = target_lufs
335
+
336
+ # Analyze input
337
+ input_analysis = analyze_audio(input_path)
338
+
339
+ # Apply processing chain
340
+ y_processed = y.copy()
341
+
342
+ # 1. EQ
343
+ y_processed = apply_eq(
344
+ y_processed, sr,
345
+ low_db=settings['eq_low'],
346
+ mid_db=settings['eq_mid'],
347
+ high_db=settings['eq_high']
348
+ )
349
+
350
+ # 2. Compression
351
+ y_processed = apply_compression(
352
+ y_processed, sr,
353
+ ratio=settings['compression_ratio'],
354
+ threshold_db=settings['compression_threshold']
355
+ )
356
+
357
+ # 3. Loudness normalization
358
+ y_processed = normalize_loudness(
359
+ y_processed, sr,
360
+ target_lufs=settings['target_lufs']
361
+ )
362
+
363
+ # 4. Final limiter
364
+ y_processed = apply_limiter(y_processed, ceiling_db=-0.3)
365
+
366
+ # Create output path if needed
367
+ if output_path is None:
368
+ temp_dir = tempfile.mkdtemp()
369
+ output_path = Path(temp_dir) / f"{Path(input_path).stem}_mastered.wav"
370
+
371
+ # Save
372
+ sf.write(str(output_path), y_processed, sr)
373
+
374
+ # Analyze output
375
+ output_analysis = analyze_audio(str(output_path))
376
+
377
+ # Build result
378
+ result = {
379
+ 'input': input_analysis,
380
+ 'output': output_analysis,
381
+ 'settings': settings,
382
+ 'preset': preset,
383
+ }
384
+
385
+ return str(output_path), result
386
+
387
+ except Exception as e:
388
+ return None, {'error': str(e)}
389
+
390
+
391
+ def format_analysis(analysis):
392
+ """Format analysis dict for display"""
393
+ if not analysis:
394
+ return "Analysis unavailable"
395
+
396
+ if 'error' in analysis:
397
+ return f"Error: {analysis['error']}"
398
+
399
+ lines = []
400
+
401
+ if 'input' in analysis:
402
+ inp = analysis['input']
403
+ lines.append("INPUT:")
404
+ lines.append(f" LUFS: {inp.get('lufs', 'N/A'):.1f}")
405
+ lines.append(f" Peak: {inp.get('peak_db', 'N/A'):.1f} dB")
406
+ lines.append(f" Dynamic Range: {inp.get('dynamic_range', 'N/A'):.1f} dB")
407
+
408
+ if 'output' in analysis:
409
+ out = analysis['output']
410
+ lines.append("\nOUTPUT:")
411
+ lines.append(f" LUFS: {out.get('lufs', 'N/A'):.1f}")
412
+ lines.append(f" Peak: {out.get('peak_db', 'N/A'):.1f} dB")
413
+ lines.append(f" Dynamic Range: {out.get('dynamic_range', 'N/A'):.1f} dB")
414
+
415
+ if 'settings' in analysis:
416
+ settings = analysis['settings']
417
+ lines.append("\nSETTINGS:")
418
+ lines.append(f" Target LUFS: {settings.get('target_lufs', -14)}")
419
+ lines.append(f" EQ: Low {settings.get('eq_low', 0):+.0f} / Mid {settings.get('eq_mid', 0):+.0f} / High {settings.get('eq_high', 0):+.0f}")
420
+ lines.append(f" Compression: {settings.get('compression_ratio', 3)}:1 @ {settings.get('compression_threshold', -18)} dB")
421
+
422
+ return "\n".join(lines)
423
+
424
+
425
+ # ============================================================================
426
+ # CLI
427
+ # ============================================================================
428
+
429
+ if __name__ == "__main__":
430
+ import sys
431
+
432
+ if len(sys.argv) < 2:
433
+ print("Usage: python mastering.py <input.wav> [output.wav] [preset]")
434
+ print("Presets: Balanced, Warm, Bright, Punchy, Reference Match")
435
+ sys.exit(1)
436
+
437
+ input_path = sys.argv[1]
438
+ output_path = sys.argv[2] if len(sys.argv) > 2 else None
439
+ preset = sys.argv[3] if len(sys.argv) > 3 else 'Balanced'
440
+
441
+ print(f"Mastering: {input_path}")
442
+ print(f"Preset: {preset}")
443
+
444
+ out_path, analysis = master_audio(input_path, output_path, preset)
445
+
446
+ if out_path:
447
+ print(f"\nOutput: {out_path}")
448
+ print(format_analysis(analysis))
449
+ else:
450
+ print(f"Error: {analysis.get('error', 'Unknown error')}")
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ numpy>=1.24.0
3
+ librosa>=0.10.0
4
+ soundfile>=0.12.0
5
+ scipy>=1.10.0
6
+ yt-dlp>=2023.10.0
7
+ torch>=2.0.0
8
+ torchaudio>=2.0.0
9
+ demucs>=4.0.0
10
+ transformers>=4.31.0
11
+ accelerate>=0.20.0
token_grants.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VYNL Token Grants
2
+ # Add email and token count to grant bonus tokens
3
+ # Format: email,tokens
4
+ #
5
+ # Example:
6
+ # john@example.com,100
7
+ # jane@music.com,50
8
+ #
9
+ # Licensed users get 300 tokens/month automatically.
10
+ # Add entries here for bonus tokens beyond the monthly limit.
11
+ #
12
+ # To add tokens, simply add a new line:
13
+ # newuser@email.com,100
14
+ #
15
+ # Multiple entries for same email are cumulative.
16
+ # ================================================
17
+
18
+ # Creator - unlimited (handled in code)
19
+ rlackey.seattle@gmail.com,999999
20
+
21
+ # Add new token grants below this line:
22
+
token_system.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ VYNL Token & User System
4
+ - Demo mode: 3 free tokens, 5-min track limit
5
+ - Licensed mode: 300 tokens/month, full access
6
+ - Admin token distribution via simple text file
7
+ """
8
+
9
+ import json
10
+ import hashlib
11
+ import os
12
+ from pathlib import Path
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional, Tuple, Dict
15
+
16
+ # ============================================================================
17
+ # CONFIGURATION
18
+ # ============================================================================
19
+
20
+ DATA_DIR = Path(os.environ.get('VYNL_DATA_DIR', Path.home() / '.vynl_data'))
21
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
22
+
23
+ USERS_FILE = DATA_DIR / 'users.json'
24
+ TOKENS_FILE = DATA_DIR / 'token_grants.txt'
25
+ SESSIONS_FILE = DATA_DIR / 'sessions.json'
26
+
27
+ # Token costs
28
+ TOKEN_COSTS = {
29
+ 'song_analysis': 1, # Full stem + chord + DAW
30
+ 'stem_only': 1, # Just stems
31
+ 'chord_only': 1, # Just chords
32
+ 'ai_generate': 2, # GROOVES generation
33
+ 'bulk_song': 1, # Per song in bulk
34
+ }
35
+
36
+ # Limits
37
+ DEMO_TOKENS = 3
38
+ DEMO_MAX_DURATION = 300 # 5 minutes in seconds
39
+ LICENSED_MONTHLY_TOKENS = 300
40
+ LICENSED_MAX_DURATION = None # Unlimited
41
+
42
+ # ============================================================================
43
+ # VALID LICENSES (from license_system)
44
+ # ============================================================================
45
+
46
+ VALID_LICENSES = {
47
+ # Creator licenses - unlimited
48
+ "VYNL-IY2M-KV47-AT7J-C74V": {"name": "R.T. Lackey", "email": "rlackey.seattle@gmail.com", "type": "CREATOR", "unlimited": True},
49
+ "VYNL-INZW-JNZY-Y4O2-WOEB": {"name": "R.T. Lackey", "email": "rlackey.seattle@gmail.com", "type": "CREATOR", "unlimited": True},
50
+ # Demo licenses (15 distributable)
51
+ "VYNL-WAFV-HBGQ-UMAY-UKRD": {"name": "Demo User 01", "email": "demo01@vynl.app", "type": "PROFESSIONAL"},
52
+ "VYNL-5M73-VSUB-CP5L-PABM": {"name": "Demo User 02", "email": "demo02@vynl.app", "type": "PROFESSIONAL"},
53
+ "VYNL-VURV-P5NN-N2IK-EV44": {"name": "Demo User 03", "email": "demo03@vynl.app", "type": "PROFESSIONAL"},
54
+ "VYNL-7TH6-NWHM-LNC2-KMG7": {"name": "Demo User 04", "email": "demo04@vynl.app", "type": "PROFESSIONAL"},
55
+ "VYNL-4W2G-NYRK-LDW7-554E": {"name": "Demo User 05", "email": "demo05@vynl.app", "type": "PROFESSIONAL"},
56
+ "VYNL-GGAD-AMOO-TLVQ-5O6M": {"name": "Demo User 06", "email": "demo06@vynl.app", "type": "PROFESSIONAL"},
57
+ "VYNL-PJM4-PRRG-AID3-VFEA": {"name": "Demo User 07", "email": "demo07@vynl.app", "type": "PROFESSIONAL"},
58
+ "VYNL-G45E-OBGJ-7LB6-3BKZ": {"name": "Demo User 08", "email": "demo08@vynl.app", "type": "PROFESSIONAL"},
59
+ "VYNL-WT7Y-ICDE-WN43-SU4B": {"name": "Demo User 09", "email": "demo09@vynl.app", "type": "PROFESSIONAL"},
60
+ "VYNL-J3DM-Y2KY-GLTN-PNM4": {"name": "Demo User 10", "email": "demo10@vynl.app", "type": "PROFESSIONAL"},
61
+ "VYNL-3FVE-RTMT-LAOJ-NH3P": {"name": "Demo User 11", "email": "demo11@vynl.app", "type": "PROFESSIONAL"},
62
+ "VYNL-YOS6-LESJ-WGIB-AOVM": {"name": "Demo User 12", "email": "demo12@vynl.app", "type": "PROFESSIONAL"},
63
+ "VYNL-ST6S-4GUY-WXVL-JWM6": {"name": "Demo User 13", "email": "demo13@vynl.app", "type": "PROFESSIONAL"},
64
+ "VYNL-RFRG-YUXL-7AX4-7FPY": {"name": "Demo User 14", "email": "demo14@vynl.app", "type": "PROFESSIONAL"},
65
+ "VYNL-54HA-343P-V5AT-6RJL": {"name": "Demo User 15", "email": "demo15@vynl.app", "type": "PROFESSIONAL"},
66
+ }
67
+
68
+ # ============================================================================
69
+ # USER DATABASE
70
+ # ============================================================================
71
+
72
+ def load_users() -> Dict:
73
+ """Load user database"""
74
+ if USERS_FILE.exists():
75
+ return json.loads(USERS_FILE.read_text())
76
+ return {}
77
+
78
+ def save_users(users: Dict):
79
+ """Save user database"""
80
+ USERS_FILE.write_text(json.dumps(users, indent=2))
81
+
82
+ def hash_password(password: str) -> str:
83
+ """Hash password for storage"""
84
+ return hashlib.sha256(password.encode()).hexdigest()
85
+
86
+ def get_month_key() -> str:
87
+ """Get current month key for token tracking"""
88
+ return datetime.now().strftime('%Y-%m')
89
+
90
+ # ============================================================================
91
+ # TOKEN GRANTS FILE
92
+ # ============================================================================
93
+
94
+ def load_token_grants() -> Dict[str, int]:
95
+ """
96
+ Load token grants from simple text file
97
+ Format: email,tokens (one per line)
98
+ Example:
99
+ john@example.com,100
100
+ jane@example.com,50
101
+ """
102
+ grants = {}
103
+ if TOKENS_FILE.exists():
104
+ for line in TOKENS_FILE.read_text().strip().split('\n'):
105
+ line = line.strip()
106
+ if line and ',' in line and not line.startswith('#'):
107
+ parts = line.split(',')
108
+ if len(parts) >= 2:
109
+ email = parts[0].strip().lower()
110
+ try:
111
+ tokens = int(parts[1].strip())
112
+ grants[email] = grants.get(email, 0) + tokens
113
+ except ValueError:
114
+ pass
115
+ return grants
116
+
117
+ # ============================================================================
118
+ # USER MANAGEMENT
119
+ # ============================================================================
120
+
121
+ class UserManager:
122
+ def __init__(self):
123
+ self.users = load_users()
124
+ self.token_grants = load_token_grants()
125
+
126
+ def reload_grants(self):
127
+ """Reload token grants from file"""
128
+ self.token_grants = load_token_grants()
129
+
130
+ def create_account(self, email: str, password: str, name: str = "") -> Tuple[bool, str]:
131
+ """Create new user account"""
132
+ email = email.strip().lower()
133
+
134
+ if not email or '@' not in email:
135
+ return False, "Invalid email address"
136
+
137
+ if not password or len(password) < 6:
138
+ return False, "Password must be at least 6 characters"
139
+
140
+ if email in self.users:
141
+ return False, "Account already exists"
142
+
143
+ self.users[email] = {
144
+ 'email': email,
145
+ 'name': name or email.split('@')[0],
146
+ 'password_hash': hash_password(password),
147
+ 'created': datetime.now().isoformat(),
148
+ 'license_key': None,
149
+ 'license_type': 'DEMO',
150
+ 'tokens_used': {}, # {month_key: count}
151
+ 'bonus_tokens': 0,
152
+ 'total_songs_processed': 0,
153
+ }
154
+
155
+ save_users(self.users)
156
+ return True, "Account created successfully"
157
+
158
+ def login(self, email: str, password: str) -> Tuple[bool, Optional[Dict]]:
159
+ """Login user"""
160
+ email = email.strip().lower()
161
+
162
+ if email not in self.users:
163
+ return False, None
164
+
165
+ user = self.users[email]
166
+ if user['password_hash'] != hash_password(password):
167
+ return False, None
168
+
169
+ return True, user
170
+
171
+ def activate_license(self, email: str, license_key: str) -> Tuple[bool, str]:
172
+ """Activate license for user"""
173
+ email = email.strip().lower()
174
+ license_key = license_key.strip().upper()
175
+
176
+ if email not in self.users:
177
+ return False, "User not found"
178
+
179
+ if license_key not in VALID_LICENSES:
180
+ return False, "Invalid license key"
181
+
182
+ license_info = VALID_LICENSES[license_key]
183
+
184
+ self.users[email]['license_key'] = license_key
185
+ self.users[email]['license_type'] = license_info['type']
186
+ self.users[email]['license_activated'] = datetime.now().isoformat()
187
+
188
+ save_users(self.users)
189
+ return True, f"License activated: {license_info['type']}"
190
+
191
+ def get_user_status(self, email: str) -> Dict:
192
+ """Get complete user status including tokens"""
193
+ email = email.strip().lower()
194
+
195
+ if email not in self.users:
196
+ # Demo user (not registered)
197
+ return {
198
+ 'registered': False,
199
+ 'license_type': 'DEMO',
200
+ 'tokens_remaining': DEMO_TOKENS,
201
+ 'tokens_used': 0,
202
+ 'max_duration': DEMO_MAX_DURATION,
203
+ 'unlimited': False,
204
+ }
205
+
206
+ user = self.users[email]
207
+ month_key = get_month_key()
208
+ tokens_used_this_month = user['tokens_used'].get(month_key, 0)
209
+
210
+ # Check for bonus tokens from grants file
211
+ self.reload_grants()
212
+ bonus_from_grants = self.token_grants.get(email, 0)
213
+
214
+ # Calculate based on license type
215
+ if user['license_type'] == 'CREATOR':
216
+ return {
217
+ 'registered': True,
218
+ 'email': email,
219
+ 'name': user['name'],
220
+ 'license_type': 'CREATOR',
221
+ 'tokens_remaining': 999999,
222
+ 'tokens_used': tokens_used_this_month,
223
+ 'max_duration': None,
224
+ 'unlimited': True,
225
+ }
226
+ elif user['license_key']:
227
+ # Licensed user
228
+ base_tokens = LICENSED_MONTHLY_TOKENS
229
+ total_available = base_tokens + user.get('bonus_tokens', 0) + bonus_from_grants
230
+ tokens_remaining = max(0, total_available - tokens_used_this_month)
231
+
232
+ return {
233
+ 'registered': True,
234
+ 'email': email,
235
+ 'name': user['name'],
236
+ 'license_type': user['license_type'],
237
+ 'tokens_remaining': tokens_remaining,
238
+ 'tokens_used': tokens_used_this_month,
239
+ 'monthly_limit': base_tokens,
240
+ 'bonus_tokens': user.get('bonus_tokens', 0) + bonus_from_grants,
241
+ 'max_duration': LICENSED_MAX_DURATION,
242
+ 'unlimited': False,
243
+ }
244
+ else:
245
+ # Registered but no license (demo)
246
+ return {
247
+ 'registered': True,
248
+ 'email': email,
249
+ 'name': user['name'],
250
+ 'license_type': 'DEMO',
251
+ 'tokens_remaining': max(0, DEMO_TOKENS - tokens_used_this_month),
252
+ 'tokens_used': tokens_used_this_month,
253
+ 'max_duration': DEMO_MAX_DURATION,
254
+ 'unlimited': False,
255
+ }
256
+
257
+ def use_tokens(self, email: str, amount: int, action: str = 'song_analysis') -> Tuple[bool, str]:
258
+ """Deduct tokens for an action"""
259
+ email = email.strip().lower()
260
+ status = self.get_user_status(email)
261
+
262
+ if status['unlimited']:
263
+ return True, "Unlimited access"
264
+
265
+ if status['tokens_remaining'] < amount:
266
+ return False, f"Insufficient tokens. Need {amount}, have {status['tokens_remaining']}"
267
+
268
+ # Deduct tokens
269
+ if email in self.users:
270
+ month_key = get_month_key()
271
+ if month_key not in self.users[email]['tokens_used']:
272
+ self.users[email]['tokens_used'][month_key] = 0
273
+ self.users[email]['tokens_used'][month_key] += amount
274
+ self.users[email]['total_songs_processed'] += 1
275
+ save_users(self.users)
276
+
277
+ remaining = status['tokens_remaining'] - amount
278
+ return True, f"Token used. {remaining} remaining"
279
+
280
+ def check_duration_limit(self, email: str, duration_seconds: float) -> Tuple[bool, str]:
281
+ """Check if track duration is within limits"""
282
+ status = self.get_user_status(email)
283
+
284
+ if status['max_duration'] is None:
285
+ return True, "No duration limit"
286
+
287
+ if duration_seconds > status['max_duration']:
288
+ max_mins = status['max_duration'] // 60
289
+ return False, f"Track exceeds {max_mins}-minute limit for demo mode. Upgrade to process longer tracks."
290
+
291
+ return True, "Duration OK"
292
+
293
+ def add_bonus_tokens(self, email: str, amount: int) -> Tuple[bool, str]:
294
+ """Add bonus tokens to user account"""
295
+ email = email.strip().lower()
296
+
297
+ if email not in self.users:
298
+ return False, "User not found"
299
+
300
+ self.users[email]['bonus_tokens'] = self.users[email].get('bonus_tokens', 0) + amount
301
+ save_users(self.users)
302
+
303
+ return True, f"Added {amount} bonus tokens"
304
+
305
+
306
+ # ============================================================================
307
+ # SINGLETON INSTANCE
308
+ # ============================================================================
309
+
310
+ user_manager = UserManager()
311
+
312
+
313
+ # ============================================================================
314
+ # HELPER FUNCTIONS FOR GRADIO
315
+ # ============================================================================
316
+
317
+ def check_can_process(email: str, duration_seconds: float = 0) -> Tuple[bool, str, Dict]:
318
+ """
319
+ Check if user can process a song
320
+ Returns: (can_process, message, status_dict)
321
+ """
322
+ status = user_manager.get_user_status(email)
323
+
324
+ # Check tokens
325
+ if status['tokens_remaining'] <= 0 and not status['unlimited']:
326
+ return False, "No tokens remaining. Please upgrade or wait for monthly reset.", status
327
+
328
+ # Check duration
329
+ if duration_seconds > 0:
330
+ ok, msg = user_manager.check_duration_limit(email, duration_seconds)
331
+ if not ok:
332
+ return False, msg, status
333
+
334
+ return True, "Ready to process", status
335
+
336
+
337
+ def deduct_token(email: str) -> Tuple[bool, str]:
338
+ """Deduct one token after successful processing"""
339
+ return user_manager.use_tokens(email, 1)
340
+
341
+
342
+ def get_status_display(email: str) -> str:
343
+ """Get formatted status for UI display"""
344
+ if not email:
345
+ return "DEMO MODE: 3 free tokens | 5-min track limit | Enter email to track usage"
346
+
347
+ status = user_manager.get_user_status(email)
348
+
349
+ if status['unlimited']:
350
+ return f"CREATOR: {status['name']} | UNLIMITED ACCESS"
351
+
352
+ if status['license_type'] != 'DEMO':
353
+ return f"LICENSED ({status['license_type']}): {status['tokens_remaining']} tokens remaining this month"
354
+
355
+ return f"DEMO: {status['tokens_remaining']}/{DEMO_TOKENS} tokens | 5-min limit | Upgrade for full access"
356
+
357
+
358
+ # ============================================================================
359
+ # CLI FOR TESTING
360
+ # ============================================================================
361
+
362
+ if __name__ == "__main__":
363
+ import sys
364
+
365
+ print("VYNL Token System")
366
+ print("=" * 50)
367
+
368
+ if len(sys.argv) < 2:
369
+ print("""
370
+ Commands:
371
+ status <email> Check user status
372
+ create <email> <pass> Create account
373
+ grant <email> <tokens> Add tokens to grants file
374
+ activate <email> <key> Activate license
375
+ """)
376
+ sys.exit(0)
377
+
378
+ cmd = sys.argv[1]
379
+
380
+ if cmd == "status" and len(sys.argv) >= 3:
381
+ email = sys.argv[2]
382
+ status = user_manager.get_user_status(email)
383
+ print(json.dumps(status, indent=2))
384
+
385
+ elif cmd == "create" and len(sys.argv) >= 4:
386
+ email, password = sys.argv[2], sys.argv[3]
387
+ ok, msg = user_manager.create_account(email, password)
388
+ print(f"{'Success' if ok else 'Failed'}: {msg}")
389
+
390
+ elif cmd == "grant" and len(sys.argv) >= 4:
391
+ email, tokens = sys.argv[2], sys.argv[3]
392
+ # Append to grants file
393
+ with open(TOKENS_FILE, 'a') as f:
394
+ f.write(f"{email},{tokens}\n")
395
+ print(f"Added {tokens} tokens for {email}")
396
+
397
+ elif cmd == "activate" and len(sys.argv) >= 4:
398
+ email, key = sys.argv[2], sys.argv[3]
399
+ ok, msg = user_manager.activate_license(email, key)
400
+ print(f"{'Success' if ok else 'Failed'}: {msg}")