blackboxanalytics commited on
Commit
91a6faa
·
1 Parent(s): bf5fece

first pass - analysis + UI scaffold

Browse files
Files changed (9) hide show
  1. .gitignore +21 -0
  2. README.md +16 -6
  3. analyze.py +99 -0
  4. app.py +235 -0
  5. continue_music.py +56 -0
  6. poster.py +79 -0
  7. requirements.txt +12 -0
  8. transcribe.py +73 -0
  9. write_lyrics.py +66 -0
.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.wav
5
+ *.mp3
6
+ *.flac
7
+ *.ogg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .env
12
+ .venv/
13
+ *.egg
14
+ .eggs/
15
+ _stems/
16
+ flagged/
17
+ *.pt
18
+ *.bin
19
+ *.safetensors
20
+ .DS_Store
21
+ Thumbs.db
README.md CHANGED
@@ -1,15 +1,25 @@
1
  ---
2
- title: Coda
3
- emoji: 👀
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.16.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: Finish what you started. A local AI that continues the songs
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CODA
3
+ emoji: 🎵
4
+ colorFrom: yellow
5
+ colorTo: orange
6
  sdk: gradio
7
  sdk_version: 6.16.0
8
  python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: Finish what you started. A local AI that continues your songs.
13
  ---
14
 
15
+ # CODA
16
+
17
+ You have an unfinished song. Maybe you ran out of ideas at the bridge, maybe the groove just stops at 0:47. Upload it. CODA listens, figures out what key you're in and where the beat lands, then continues the music from where you stopped. Vocals, instruments, lyrics -- it picks up all of it.
18
+
19
+ Everything runs locally. No audio leaves your machine, no cloud APIs, no subscriptions. The whole stack fits under 13B parameters: MusicGen Large handles the music continuation, Whisper + Demucs pull and transcribe vocals, Qwen3 writes new lyrics that match your style, and librosa does the key/tempo detection without any ML at all.
20
+
21
+ Upload a WAV, MP3, or FLAC (under 60 seconds). You'll see the detected key, tempo, and duration right away. Hit continue and CODA generates what comes next.
22
+
23
+ ---
24
+
25
+ Built by Tony Winslow for the Build Small Hackathon 2025. MIT license.
analyze.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import librosa
2
+ import numpy as np
3
+
4
+
5
+ # krumhansl-schmuckler key profiles
6
+ # major and minor correlation vectors for pitch class distribution
7
+ MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09,
8
+ 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
9
+
10
+ MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53,
11
+ 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
12
+
13
+ PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F',
14
+ 'F#', 'G', 'G#', 'A', 'A#', 'B']
15
+
16
+
17
+ def find_key(path):
18
+ """chroma-based key detection using krumhansl-schmuckler profiles"""
19
+ track, sr = librosa.load(path, sr=None)
20
+
21
+ # pull chroma energy, average across time
22
+ chroma = librosa.feature.chroma_cqt(y=track, sr=sr)
23
+ pitch_dist = np.mean(chroma, axis=1)
24
+
25
+ # normalize
26
+ pitch_dist = (pitch_dist - pitch_dist.mean()) / (pitch_dist.std() + 1e-8)
27
+
28
+ best_corr = -2
29
+ best_key = 'C major'
30
+
31
+ for shift in range(12):
32
+ rolled = np.roll(pitch_dist, -shift)
33
+
34
+ # check major
35
+ major_norm = (MAJOR_PROFILE - MAJOR_PROFILE.mean()) / MAJOR_PROFILE.std()
36
+ corr_major = np.corrcoef(rolled, major_norm)[0, 1]
37
+ if corr_major > best_corr:
38
+ best_corr = corr_major
39
+ best_key = f'{PITCH_CLASSES[shift]} major'
40
+
41
+ # check minor
42
+ minor_norm = (MINOR_PROFILE - MINOR_PROFILE.mean()) / MINOR_PROFILE.std()
43
+ corr_minor = np.corrcoef(rolled, minor_norm)[0, 1]
44
+ if corr_minor > best_corr:
45
+ best_corr = corr_minor
46
+ best_key = f'{PITCH_CLASSES[shift]} minor'
47
+
48
+ return best_key
49
+
50
+
51
+ def get_tempo(path):
52
+ track, sr = librosa.load(path, sr=None)
53
+ tempo, _ = librosa.beat.beat_track(y=track, sr=sr)
54
+ # librosa sometimes returns an array
55
+ if hasattr(tempo, '__len__'):
56
+ return float(tempo[0])
57
+ return float(tempo)
58
+
59
+
60
+ def get_duration(path):
61
+ return librosa.get_duration(path=path)
62
+
63
+
64
+ def fingerprint(path):
65
+ track, sr = librosa.load(path, sr=None, mono=False)
66
+
67
+ channels = 1 if track.ndim == 1 else track.shape[0]
68
+
69
+ # reload mono for analysis
70
+ if channels > 1:
71
+ mono = librosa.to_mono(track)
72
+ else:
73
+ mono = track
74
+
75
+ key_sig = find_key(path)
76
+ bpm = get_tempo(path)
77
+ length = librosa.get_duration(y=mono, sr=sr)
78
+
79
+ return {
80
+ 'key': key_sig,
81
+ 'bpm': round(bpm, 1),
82
+ 'duration': round(length, 2),
83
+ 'sample_rate': sr,
84
+ 'channels': channels
85
+ }
86
+
87
+
88
+ if __name__ == '__main__':
89
+ import sys
90
+ if len(sys.argv) < 2:
91
+ print('usage: python analyze.py <audio_file>')
92
+ sys.exit(1)
93
+
94
+ info = fingerprint(sys.argv[1])
95
+ print(f"key: {info['key']}")
96
+ print(f"bpm: {info['bpm']}")
97
+ print(f"duration: {info['duration']}s")
98
+ print(f"sample rate: {info['sample_rate']}Hz")
99
+ print(f"channels: {info['channels']}")
app.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import os
3
+ import tempfile
4
+ from analyze import fingerprint, find_key, get_tempo, get_duration
5
+
6
+ try:
7
+ import spaces
8
+ except ImportError:
9
+ class _FakeSpaces:
10
+ def GPU(self, fn=None, **kw):
11
+ if fn: return fn
12
+ return lambda f: f
13
+ spaces = _FakeSpaces()
14
+
15
+
16
+ CUSTOM_CSS = """
17
+ /* ---- base ---- */
18
+ .gradio-container {
19
+ background: #1a1714 !important;
20
+ font-family: 'Georgia', 'Times New Roman', serif !important;
21
+ max-width: 760px !important;
22
+ margin: 0 auto !important;
23
+ }
24
+ .app-header {
25
+ text-align: center;
26
+ padding: 40px 20px 10px 20px;
27
+ }
28
+ .app-header h1 {
29
+ color: #f0ece4 !important;
30
+ font-size: 3.2em !important;
31
+ font-weight: 400 !important;
32
+ letter-spacing: 0.15em !important;
33
+ margin-bottom: 4px !important;
34
+ font-family: 'Georgia', serif !important;
35
+ }
36
+ .app-header p {
37
+ color: #8a7e6e !important;
38
+ font-size: 1em !important;
39
+ font-style: italic !important;
40
+ margin-top: 0 !important;
41
+ }
42
+ .upload-panel, .results-panel, .continue-panel {
43
+ background: #211e19 !important;
44
+ border: 1px solid #3a332a !important;
45
+ border-radius: 12px !important;
46
+ padding: 24px !important;
47
+ margin-bottom: 16px !important;
48
+ }
49
+ .gradio-container label, .gradio-container .label-wrap span {
50
+ color: #c8956c !important;
51
+ font-family: 'Georgia', serif !important;
52
+ font-size: 0.95em !important;
53
+ }
54
+ .gradio-container .prose, .gradio-container p, .gradio-container span {
55
+ color: #f0ece4 !important;
56
+ }
57
+ .gradio-container .upload-button {
58
+ background: #2a2520 !important;
59
+ border: 2px dashed #4a4035 !important;
60
+ color: #c8956c !important;
61
+ border-radius: 8px !important;
62
+ }
63
+ .gradio-container .upload-button:hover {
64
+ border-color: #c8956c !important;
65
+ background: #302a22 !important;
66
+ }
67
+ .stat-card {
68
+ background: #2a2520 !important;
69
+ border: 1px solid #3a332a !important;
70
+ border-radius: 10px !important;
71
+ padding: 20px !important;
72
+ text-align: center !important;
73
+ }
74
+ .stat-label {
75
+ color: #8a7e6e !important;
76
+ font-size: 0.8em !important;
77
+ text-transform: uppercase !important;
78
+ letter-spacing: 0.12em !important;
79
+ margin-bottom: 6px !important;
80
+ }
81
+ .stat-value {
82
+ color: #c8956c !important;
83
+ font-size: 1.8em !important;
84
+ font-weight: 400 !important;
85
+ font-family: 'Georgia', serif !important;
86
+ }
87
+ .gradio-container button.primary {
88
+ background: #c8956c !important;
89
+ color: #1a1714 !important;
90
+ border: none !important;
91
+ border-radius: 8px !important;
92
+ font-family: 'Georgia', serif !important;
93
+ font-size: 1em !important;
94
+ padding: 12px 32px !important;
95
+ letter-spacing: 0.06em !important;
96
+ transition: all 0.2s ease !important;
97
+ }
98
+ .gradio-container button.primary:hover {
99
+ background: #d4a57c !important;
100
+ }
101
+ .gradio-container button.secondary {
102
+ background: transparent !important;
103
+ color: #c8956c !important;
104
+ border: 1px solid #4a4035 !important;
105
+ border-radius: 8px !important;
106
+ font-family: 'Georgia', serif !important;
107
+ }
108
+ .gradio-container button.secondary:hover {
109
+ border-color: #c8956c !important;
110
+ }
111
+ footer { display: none !important; }
112
+ .gradio-container .tab-nav button {
113
+ color: #8a7e6e !important;
114
+ border: none !important;
115
+ background: transparent !important;
116
+ font-family: 'Georgia', serif !important;
117
+ }
118
+ .gradio-container .tab-nav button.selected {
119
+ color: #c8956c !important;
120
+ border-bottom: 2px solid #c8956c !important;
121
+ }
122
+ .gradio-container input, .gradio-container textarea {
123
+ background: #2a2520 !important;
124
+ color: #f0ece4 !important;
125
+ border-color: #3a332a !important;
126
+ }
127
+ .gradio-container .audio-player {
128
+ background: #2a2520 !important;
129
+ border-radius: 8px !important;
130
+ }
131
+ .gradio-container .progress-bar {
132
+ background: #c8956c !important;
133
+ }
134
+ .tape-deco {
135
+ text-align: center;
136
+ padding: 8px 0;
137
+ color: #3a332a;
138
+ font-size: 0.85em;
139
+ letter-spacing: 0.3em;
140
+ }
141
+ """
142
+
143
+
144
+ def make_stat_html(label, value):
145
+ return (
146
+ '<div class="stat-card">'
147
+ '<div class="stat-label">' + label + '</div>'
148
+ '<div class="stat-value">' + str(value) + '</div>'
149
+ '</div>'
150
+ )
151
+
152
+
153
+ def analyze_track(audio):
154
+ if audio is None:
155
+ return (
156
+ make_stat_html("key", "---"),
157
+ make_stat_html("tempo", "---"),
158
+ make_stat_html("duration", "---"),
159
+ gr.update(visible=False),
160
+ None
161
+ )
162
+
163
+ try:
164
+ info = fingerprint(audio)
165
+ key_html = make_stat_html("key", info["key"])
166
+ bpm_html = make_stat_html("tempo", str(info["bpm"]) + " bpm")
167
+ dur_html = make_stat_html("duration", str(info["duration"]) + "s")
168
+ return (key_html, bpm_html, dur_html, gr.update(visible=True), audio)
169
+ except Exception as e:
170
+ err = make_stat_html("error", str(e)[:50])
171
+ return (err, make_stat_html("tempo", "---"), make_stat_html("duration", "---"), gr.update(visible=False), None)
172
+
173
+
174
+ def placeholder_continue(audio_path):
175
+ return "continuation coming soon. MusicGen pipeline lands on Day 2."
176
+
177
+
178
+ HEADER_HTML = (
179
+ '<div class="app-header">'
180
+ '<h1>CODA</h1>'
181
+ '<p>upload an unfinished song. it picks up where you left off.</p>'
182
+ '</div>'
183
+ '<div class="tape-deco">- - - - - - - - - - - -</div>'
184
+ )
185
+
186
+ FOOTER_HTML = (
187
+ '<div class="tape-deco" style="margin-top: 20px;">- - - - - - - - - - - -</div>'
188
+ '<div style="text-align:center; padding:16px 0; color:#4a4035; font-size:0.8em; font-family:Georgia,serif;">'
189
+ 'CODA // 100% local // no cloud APIs // built for Build Small 2025'
190
+ '</div>'
191
+ )
192
+
193
+
194
+ with gr.Blocks(title="CODA") as app:
195
+
196
+ current_track = gr.State(None)
197
+
198
+ gr.HTML(HEADER_HTML)
199
+
200
+ with gr.Group(elem_classes="upload-panel"):
201
+ audio_input = gr.Audio(
202
+ label="drop a track",
203
+ type="filepath",
204
+ sources=["upload", "microphone"],
205
+ )
206
+
207
+ with gr.Group(elem_classes="results-panel"):
208
+ gr.HTML('<div class="tape-deco" style="margin-bottom:12px;">analysis</div>')
209
+ with gr.Row():
210
+ key_display = gr.HTML(make_stat_html("key", "---"))
211
+ bpm_display = gr.HTML(make_stat_html("tempo", "---"))
212
+ dur_display = gr.HTML(make_stat_html("duration", "---"))
213
+
214
+ with gr.Group(elem_classes="continue-panel", visible=False) as continue_section:
215
+ gr.HTML('<div class="tape-deco" style="margin-bottom:12px;">continue</div>')
216
+ continue_btn = gr.Button("continue this track", variant="primary")
217
+ continue_output = gr.Textbox(label="status", interactive=False, lines=2)
218
+
219
+ audio_input.change(
220
+ fn=analyze_track,
221
+ inputs=[audio_input],
222
+ outputs=[key_display, bpm_display, dur_display, continue_section, current_track]
223
+ )
224
+
225
+ continue_btn.click(
226
+ fn=placeholder_continue,
227
+ inputs=[current_track],
228
+ outputs=[continue_output]
229
+ )
230
+
231
+ gr.HTML(FOOTER_HTML)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ app.launch(css=CUSTOM_CSS, theme=gr.themes.Base())
continue_music.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torchaudio
3
+ from audiocraft.models import MusicGen
4
+
5
+
6
+ _model = None
7
+
8
+
9
+ def _load_model():
10
+ global _model
11
+ if _model is None:
12
+ _model = MusicGen.get_pretrained('facebook/musicgen-large')
13
+ return _model
14
+
15
+
16
+ def continue_track(path, prompt_duration=10, gen_duration=15, key=None, bpm=None):
17
+ """
18
+ takes the last `prompt_duration` seconds of the input track
19
+ and generates `gen_duration` seconds of continuation.
20
+ key and bpm are hints for the text prompt.
21
+ """
22
+ model = _load_model()
23
+ model.set_generation_params(duration=gen_duration)
24
+
25
+ track, sr = torchaudio.load(path)
26
+
27
+ # grab the tail end as context
28
+ tail_samples = int(prompt_duration * sr)
29
+ if track.shape[1] > tail_samples:
30
+ tail = track[:, -tail_samples:]
31
+ else:
32
+ tail = track
33
+
34
+ # resample to 32kHz if needed (musicgen expects this)
35
+ if sr != 32000:
36
+ resampler = torchaudio.transforms.Resample(sr, 32000)
37
+ tail = resampler(tail)
38
+
39
+ # mono
40
+ if tail.shape[0] > 1:
41
+ tail = tail.mean(dim=0, keepdim=True)
42
+
43
+ tail = tail.unsqueeze(0) # batch dim
44
+
45
+ # build a natural description
46
+ desc = "continue this song"
47
+ if key and bpm:
48
+ desc = f"continue this song in {key} at {bpm} bpm"
49
+ elif key:
50
+ desc = f"continue this song in {key}"
51
+
52
+ with torch.no_grad():
53
+ output = model.generate_continuation(tail, 32000, [desc])
54
+
55
+ result = output[0].cpu()
56
+ return result, 32000
poster.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from PIL import Image, ImageDraw, ImageFont
2
+ import os
3
+
4
+ # warm palette matching the app
5
+ BG_COLOR = (26, 23, 20)
6
+ AMBER = (200, 149, 108)
7
+ CREAM = (240, 236, 228)
8
+ DARK_ACCENT = (60, 50, 40)
9
+
10
+
11
+ def make_poster(title, key_sig, bpm, duration, out_path='poster.png'):
12
+ """
13
+ generates a simple album-art-style card for the analyzed track.
14
+ no ML here, just pillow.
15
+ """
16
+ w, h = 800, 800
17
+ img = Image.new('RGB', (w, h), BG_COLOR)
18
+ draw = ImageDraw.Draw(img)
19
+
20
+ # big warm circle as a vinyl record silhouette
21
+ cx, cy = w // 2, h // 2 - 40
22
+ radius = 240
23
+ draw.ellipse(
24
+ [cx - radius, cy - radius, cx + radius, cy + radius],
25
+ fill=DARK_ACCENT,
26
+ outline=AMBER,
27
+ width=3
28
+ )
29
+
30
+ # inner circle (label area)
31
+ inner_r = 80
32
+ draw.ellipse(
33
+ [cx - inner_r, cy - inner_r, cx + inner_r, cy + inner_r],
34
+ fill=BG_COLOR,
35
+ outline=AMBER,
36
+ width=2
37
+ )
38
+
39
+ # spindle dot
40
+ draw.ellipse([cx - 6, cy - 6, cx + 6, cy + 6], fill=AMBER)
41
+
42
+ # grooves (concentric rings)
43
+ for r in range(inner_r + 20, radius, 16):
44
+ draw.ellipse(
45
+ [cx - r, cy - r, cx + r, cy + r],
46
+ outline=(50, 42, 34),
47
+ width=1
48
+ )
49
+
50
+ # text below the record
51
+ try:
52
+ title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
53
+ detail_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 22)
54
+ small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
55
+ except OSError:
56
+ title_font = ImageFont.load_default()
57
+ detail_font = title_font
58
+ small_font = title_font
59
+
60
+ # track title
61
+ title_text = title if len(title) < 40 else title[:37] + '...'
62
+ bbox = draw.textbbox((0, 0), title_text, font=title_font)
63
+ tw = bbox[2] - bbox[0]
64
+ draw.text(((w - tw) // 2, h - 200), title_text, fill=CREAM, font=title_font)
65
+
66
+ # key + bpm line
67
+ info_text = f"{key_sig} · {bpm} BPM · {duration:.1f}s"
68
+ bbox = draw.textbbox((0, 0), info_text, font=detail_font)
69
+ tw = bbox[2] - bbox[0]
70
+ draw.text(((w - tw) // 2, h - 150), info_text, fill=AMBER, font=detail_font)
71
+
72
+ # coda branding
73
+ brand = "CODA"
74
+ bbox = draw.textbbox((0, 0), brand, font=small_font)
75
+ tw = bbox[2] - bbox[0]
76
+ draw.text(((w - tw) // 2, h - 60), brand, fill=(100, 85, 70), font=small_font)
77
+
78
+ img.save(out_path)
79
+ return out_path
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ torch>=2.1.0
3
+ torchaudio>=2.1.0
4
+ librosa>=0.10.2
5
+ numpy>=1.24.0
6
+ soundfile>=0.12.1
7
+ Pillow>=10.0.0
8
+ transformers>=4.51.0
9
+ accelerate>=0.26.0
10
+ audiocraft
11
+ demucs
12
+ spaces
transcribe.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torchaudio
3
+ from transformers import WhisperProcessor, WhisperForConditionalGeneration
4
+
5
+
6
+ _processor = None
7
+ _model = None
8
+
9
+
10
+ def _load_whisper():
11
+ global _processor, _model
12
+ if _model is None:
13
+ _processor = WhisperProcessor.from_pretrained("openai/whisper-large-v3")
14
+ _model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-large-v3")
15
+ return _processor, _model
16
+
17
+
18
+ def isolate_vocals(path):
19
+ """
20
+ run demucs to split stems, return path to vocals.
21
+ expects demucs CLI installed via pip.
22
+ """
23
+ import subprocess
24
+ import os
25
+
26
+ out_dir = os.path.join(os.path.dirname(path), '_stems')
27
+ cmd = ['python', '-m', 'demucs', '--two-stems', 'vocals',
28
+ '-o', out_dir, path]
29
+ subprocess.run(cmd, check=True, capture_output=True)
30
+
31
+ # demucs outputs to out_dir/htdemucs/<trackname>/vocals.wav
32
+ track_name = os.path.splitext(os.path.basename(path))[0]
33
+ vocals_path = os.path.join(out_dir, 'htdemucs', track_name, 'vocals.wav')
34
+
35
+ if not os.path.exists(vocals_path):
36
+ raise FileNotFoundError(f"demucs didn't produce vocals at {vocals_path}")
37
+
38
+ return vocals_path
39
+
40
+
41
+ def transcribe(path, isolate=True):
42
+ """
43
+ extract lyrics from audio.
44
+ if isolate=True, runs demucs first to pull vocals.
45
+ """
46
+ if isolate:
47
+ try:
48
+ vocal_path = isolate_vocals(path)
49
+ except Exception:
50
+ # fall back to raw audio if stem separation fails
51
+ vocal_path = path
52
+ else:
53
+ vocal_path = path
54
+
55
+ processor, model = _load_whisper()
56
+
57
+ track, sr = torchaudio.load(vocal_path)
58
+
59
+ # whisper wants 16kHz mono
60
+ if sr != 16000:
61
+ track = torchaudio.transforms.Resample(sr, 16000)(track)
62
+ if track.shape[0] > 1:
63
+ track = track.mean(dim=0, keepdim=True)
64
+
65
+ track = track.squeeze()
66
+
67
+ inputs = processor(track.numpy(), sampling_rate=16000, return_tensors="pt")
68
+
69
+ with torch.no_grad():
70
+ predicted_ids = model.generate(inputs.input_features)
71
+
72
+ lyrics = processor.batch_decode(predicted_ids, skip_special_tokens=True)[0]
73
+ return lyrics.strip()
write_lyrics.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformers import AutoModelForCausalLM, AutoTokenizer
2
+
3
+
4
+ _model = None
5
+ _tokenizer = None
6
+
7
+
8
+ def _load_qwen():
9
+ global _model, _tokenizer
10
+ if _model is None:
11
+ model_id = "Qwen/Qwen3-8B"
12
+ _tokenizer = AutoTokenizer.from_pretrained(model_id)
13
+ _model = AutoModelForCausalLM.from_pretrained(
14
+ model_id,
15
+ torch_dtype="auto",
16
+ device_map="auto"
17
+ )
18
+ return _model, _tokenizer
19
+
20
+
21
+ def continue_lyrics(existing_lyrics, key=None, bpm=None, style_hint=None, num_lines=8):
22
+ """
23
+ takes existing lyrics and writes more in the same style.
24
+ key/bpm/style_hint give the model musical context.
25
+ """
26
+ model, tokenizer = _load_qwen()
27
+
28
+ context_parts = []
29
+ if key:
30
+ context_parts.append(f"The song is in {key}")
31
+ if bpm:
32
+ context_parts.append(f"at {bpm} BPM")
33
+ if style_hint:
34
+ context_parts.append(f"with a {style_hint} feel")
35
+
36
+ context = ", ".join(context_parts) + "." if context_parts else ""
37
+
38
+ prompt = f"""You are a songwriter. Continue the following lyrics naturally,
39
+ matching the tone, rhythm, and imagery. Write exactly {num_lines} new lines.
40
+ Do not repeat existing lines. Do not add commentary or explanations.
41
+ {context}
42
+
43
+ Existing lyrics:
44
+ {existing_lyrics}
45
+
46
+ Continuation:"""
47
+
48
+ messages = [{"role": "user", "content": prompt}]
49
+ text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
50
+
51
+ inputs = tokenizer([text], return_tensors="pt").to(model.device)
52
+
53
+ output = model.generate(
54
+ **inputs,
55
+ max_new_tokens=256,
56
+ temperature=0.8,
57
+ top_p=0.9,
58
+ do_sample=True
59
+ )
60
+
61
+ generated = output[0][inputs.input_ids.shape[1]:]
62
+ result = tokenizer.decode(generated, skip_special_tokens=True)
63
+
64
+ # trim to requested line count
65
+ lines = [l for l in result.strip().split('\n') if l.strip()]
66
+ return '\n'.join(lines[:num_lines])