danceflow-stemlab 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- danceflow_stemlab-1.0.0.dist-info/METADATA +347 -0
- danceflow_stemlab-1.0.0.dist-info/RECORD +56 -0
- danceflow_stemlab-1.0.0.dist-info/WHEEL +5 -0
- danceflow_stemlab-1.0.0.dist-info/entry_points.txt +2 -0
- danceflow_stemlab-1.0.0.dist-info/licenses/LICENSE +21 -0
- danceflow_stemlab-1.0.0.dist-info/top_level.txt +1 -0
- stemlab/__init__.py +10 -0
- stemlab/__main__.py +4 -0
- stemlab/analysis/__init__.py +5 -0
- stemlab/analysis/harmony.py +375 -0
- stemlab/analysis/lyrics.py +293 -0
- stemlab/analysis/registry.py +111 -0
- stemlab/analysis/rhythm.py +235 -0
- stemlab/analysis/runner.py +187 -0
- stemlab/analysis/semantic_audio.py +119 -0
- stemlab/analysis/semantic_text.py +117 -0
- stemlab/analysis/song_map.py +220 -0
- stemlab/analysis/sonic.py +192 -0
- stemlab/analysis/structure.py +104 -0
- stemlab/analysis/transcription.py +98 -0
- stemlab/audio.py +161 -0
- stemlab/beats/__init__.py +5 -0
- stemlab/beats/base.py +12 -0
- stemlab/beats/beatnet.py +103 -0
- stemlab/beats/beatthis.py +25 -0
- stemlab/beats/beattransformer.py +157 -0
- stemlab/beats/common.py +123 -0
- stemlab/bootstrap.py +133 -0
- stemlab/branding.py +57 -0
- stemlab/canonical.py +188 -0
- stemlab/cli.py +188 -0
- stemlab/manifest.py +35 -0
- stemlab/models.py +114 -0
- stemlab/pipeline.py +398 -0
- stemlab/separators/__init__.py +18 -0
- stemlab/separators/base.py +12 -0
- stemlab/separators/bsroformer.py +71 -0
- stemlab/separators/common.py +30 -0
- stemlab/separators/demucs.py +46 -0
- stemlab/separators/openunmix.py +46 -0
- stemlab/separators/scnet.py +57 -0
- stemlab/server.py +1162 -0
- stemlab/sonic_visualiser.py +1125 -0
- stemlab/spectrogram.py +92 -0
- stemlab/speech.py +109 -0
- stemlab/types.py +71 -0
- stemlab/util.py +62 -0
- stemlab/vamp.py +411 -0
- stemlab/vamp_runtime.py +253 -0
- stemlab/web/app.js +844 -0
- stemlab/web/arcadians-cover.jpg +0 -0
- stemlab/web/arcadians-reference.json +338 -0
- stemlab/web/canonical.js +423 -0
- stemlab/web/index.html +148 -0
- stemlab/web/style.css +272 -0
- stemlab/webui.py +409 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from stemlab.types import BeatResult
|
|
12
|
+
from stemlab.util import write_json
|
|
13
|
+
|
|
14
|
+
PITCHES_C = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
|
|
15
|
+
PITCH_TO_PC = {name: i for i, name in enumerate(PITCHES_C)}
|
|
16
|
+
PITCH_TO_PC.update({"Db": 1, "D#": 3, "Gb": 6, "G#": 8, "A#": 10})
|
|
17
|
+
|
|
18
|
+
# Krumhansl-Schmuckler profiles, C-rooted.
|
|
19
|
+
MAJOR_PROFILE = np.asarray([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88], dtype=float)
|
|
20
|
+
MINOR_PROFILE = np.asarray([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], dtype=float)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_analysis(vamp_result: dict[str, Any] | None, slug: str) -> dict[str, Any] | None:
|
|
24
|
+
if not vamp_result:
|
|
25
|
+
return None
|
|
26
|
+
for item in vamp_result.get("analyses", []):
|
|
27
|
+
if item.get("slug") == slug:
|
|
28
|
+
return item
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalise_chord(label: str) -> str:
|
|
33
|
+
label = (label or "").strip()
|
|
34
|
+
if not label:
|
|
35
|
+
return "N"
|
|
36
|
+
if label.lower() in {"n", "no chord", "none"}:
|
|
37
|
+
return "N"
|
|
38
|
+
return re.sub(r"\s+", "", label)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _chord_root(label: str) -> str | None:
|
|
42
|
+
m = re.match(r"^([A-Ga-g])([#b]?)", label)
|
|
43
|
+
if not m:
|
|
44
|
+
return None
|
|
45
|
+
return m.group(1).upper() + m.group(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _chord_quality(label: str) -> str:
|
|
49
|
+
lower = label.lower()
|
|
50
|
+
tail = lower[len(_chord_root(label) or "") :]
|
|
51
|
+
if "dim" in tail or "°" in tail:
|
|
52
|
+
return "diminished"
|
|
53
|
+
if "aug" in tail or "+" in tail:
|
|
54
|
+
return "augmented"
|
|
55
|
+
if "sus" in tail:
|
|
56
|
+
return "suspended"
|
|
57
|
+
if ":min" in tail or tail.startswith("m") and not tail.startswith("maj"):
|
|
58
|
+
return "minor"
|
|
59
|
+
if ":maj" in tail or "maj" in tail:
|
|
60
|
+
return "major"
|
|
61
|
+
return "major"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_key(label: Any) -> tuple[int, str] | None:
|
|
65
|
+
if label is None:
|
|
66
|
+
return None
|
|
67
|
+
text = str(label).strip().replace(":", " ")
|
|
68
|
+
match = re.match(r"^([A-Ga-g])([#b]?)(?:\s+)?(major|minor|maj|min|m)?", text, re.I)
|
|
69
|
+
if not match:
|
|
70
|
+
return None
|
|
71
|
+
root_name = match.group(1).upper() + match.group(2)
|
|
72
|
+
pc = PITCH_TO_PC.get(root_name)
|
|
73
|
+
if pc is None:
|
|
74
|
+
return None
|
|
75
|
+
mode_raw = (match.group(3) or "major").lower()
|
|
76
|
+
mode = "minor" if mode_raw in {"minor", "min", "m"} else "major"
|
|
77
|
+
return pc, mode
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _degree_name(interval: int, quality: str) -> str:
|
|
81
|
+
# Chromatic scale-degree spelling optimized for readable MIR summaries.
|
|
82
|
+
degree = {
|
|
83
|
+
0: "I", 1: "bII", 2: "II", 3: "bIII", 4: "III", 5: "IV",
|
|
84
|
+
6: "#IV", 7: "V", 8: "bVI", 9: "VI", 10: "bVII", 11: "VII",
|
|
85
|
+
}[interval % 12]
|
|
86
|
+
if quality == "minor":
|
|
87
|
+
return degree.lower()
|
|
88
|
+
if quality == "diminished":
|
|
89
|
+
return degree.lower() + "°"
|
|
90
|
+
if quality == "augmented":
|
|
91
|
+
return degree + "+"
|
|
92
|
+
if quality == "suspended":
|
|
93
|
+
return degree + "sus"
|
|
94
|
+
return degree
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _functional_harmony(chords: list[dict[str, Any]], key_label: Any) -> dict[str, Any] | None:
|
|
98
|
+
parsed = _parse_key(key_label)
|
|
99
|
+
if not parsed:
|
|
100
|
+
return None
|
|
101
|
+
tonic, mode = parsed
|
|
102
|
+
diatonic_intervals = {0, 2, 4, 5, 7, 9, 11} if mode == "major" else {0, 2, 3, 5, 7, 8, 10}
|
|
103
|
+
progression = []
|
|
104
|
+
duration_by_degree: Counter[str] = Counter()
|
|
105
|
+
roots: list[int] = []
|
|
106
|
+
for chord in chords:
|
|
107
|
+
root_name = _chord_root(chord["label"])
|
|
108
|
+
root = PITCH_TO_PC.get(root_name) if root_name else None
|
|
109
|
+
if root is None:
|
|
110
|
+
progression.append({**chord, "degree": None, "diatonic_root": None})
|
|
111
|
+
continue
|
|
112
|
+
interval = (root - tonic) % 12
|
|
113
|
+
quality = _chord_quality(chord["label"])
|
|
114
|
+
degree = _degree_name(interval, quality)
|
|
115
|
+
duration_by_degree[degree] += float(chord.get("duration") or 0.0)
|
|
116
|
+
roots.append(root)
|
|
117
|
+
progression.append(
|
|
118
|
+
{
|
|
119
|
+
**chord,
|
|
120
|
+
"root": root_name,
|
|
121
|
+
"quality": quality,
|
|
122
|
+
"degree": degree,
|
|
123
|
+
"diatonic_root": interval in diatonic_intervals,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
motions = Counter()
|
|
128
|
+
for a, b in zip(roots, roots[1:]):
|
|
129
|
+
motions[(b - a) % 12] += 1
|
|
130
|
+
motion_names = {
|
|
131
|
+
0: "same root", 1: "up semitone", 2: "up whole tone", 3: "up minor third",
|
|
132
|
+
4: "up major third", 5: "up fourth / down fifth", 6: "tritone",
|
|
133
|
+
7: "up fifth / down fourth", 8: "down major third", 9: "down minor third",
|
|
134
|
+
10: "down whole tone", 11: "down semitone",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
cadences = []
|
|
138
|
+
for prev, cur in zip(progression, progression[1:]):
|
|
139
|
+
a, b = prev.get("degree"), cur.get("degree")
|
|
140
|
+
if not a or not b:
|
|
141
|
+
continue
|
|
142
|
+
a_base = a.replace("°", "").replace("+", "").replace("sus", "").upper()
|
|
143
|
+
b_base = b.replace("°", "").replace("+", "").replace("sus", "").upper()
|
|
144
|
+
kind = None
|
|
145
|
+
if a_base == "V" and b_base == "I":
|
|
146
|
+
kind = "dominant-tonic"
|
|
147
|
+
elif a_base == "IV" and b_base == "I":
|
|
148
|
+
kind = "plagal"
|
|
149
|
+
elif a_base == "II" and b_base == "V":
|
|
150
|
+
kind = "predominant-dominant"
|
|
151
|
+
elif a_base == "BVII" and b_base == "I":
|
|
152
|
+
kind = "backdoor/modal bVII-I"
|
|
153
|
+
if kind:
|
|
154
|
+
cadences.append(
|
|
155
|
+
{
|
|
156
|
+
"type": kind,
|
|
157
|
+
"from": prev["label"],
|
|
158
|
+
"to": cur["label"],
|
|
159
|
+
"time_seconds": float(cur["start"]),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
total = sum(float(x.get("duration") or 0.0) for x in progression)
|
|
164
|
+
tonic_duration = sum(float(x.get("duration") or 0.0) for x in progression if (x.get("root") and PITCH_TO_PC.get(x["root"]) == tonic))
|
|
165
|
+
dominant_pc = (tonic + 7) % 12
|
|
166
|
+
dominant_duration = sum(float(x.get("duration") or 0.0) for x in progression if (x.get("root") and PITCH_TO_PC.get(x["root"]) == dominant_pc))
|
|
167
|
+
valid = [x for x in progression if x.get("diatonic_root") is not None]
|
|
168
|
+
chromatic = [x for x in valid if not x["diatonic_root"]]
|
|
169
|
+
return {
|
|
170
|
+
"key_basis": str(key_label),
|
|
171
|
+
"mode": mode,
|
|
172
|
+
"progression": progression,
|
|
173
|
+
"diatonic_root_share": float((len(valid) - len(chromatic)) / len(valid)) if valid else None,
|
|
174
|
+
"chromatic_root_events": len(chromatic),
|
|
175
|
+
"tonic_duration_share": float(tonic_duration / total) if total else None,
|
|
176
|
+
"dominant_duration_share": float(dominant_duration / total) if total else None,
|
|
177
|
+
"degree_duration_histogram": [
|
|
178
|
+
{"degree": degree, "seconds": float(seconds)}
|
|
179
|
+
for degree, seconds in duration_by_degree.most_common()
|
|
180
|
+
],
|
|
181
|
+
"root_motion": [
|
|
182
|
+
{"semitones_up_mod12": int(interval), "name": motion_names[interval], "count": int(count)}
|
|
183
|
+
for interval, count in motions.most_common()
|
|
184
|
+
],
|
|
185
|
+
"cadence_candidates": cadences,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _duration(event: dict[str, Any]) -> float:
|
|
190
|
+
start = float(event.get("start") or 0.0)
|
|
191
|
+
end = event.get("end")
|
|
192
|
+
if end is None:
|
|
193
|
+
return 0.0
|
|
194
|
+
return max(0.0, float(end) - start)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _collapse_progression(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
198
|
+
out: list[dict[str, Any]] = []
|
|
199
|
+
for event in events:
|
|
200
|
+
label = _normalise_chord(str(event.get("label") or ""))
|
|
201
|
+
start = float(event.get("start") or 0.0)
|
|
202
|
+
end = float(event.get("end") or start)
|
|
203
|
+
if out and out[-1]["label"] == label and abs(out[-1]["end"] - start) < 0.25:
|
|
204
|
+
out[-1]["end"] = end
|
|
205
|
+
out[-1]["duration"] = out[-1]["end"] - out[-1]["start"]
|
|
206
|
+
else:
|
|
207
|
+
out.append({"label": label, "start": start, "end": end, "duration": max(0.0, end - start)})
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ngram_counts(labels: list[str], n: int) -> list[dict[str, Any]]:
|
|
212
|
+
if len(labels) < n:
|
|
213
|
+
return []
|
|
214
|
+
counts = Counter(tuple(labels[i : i + n]) for i in range(len(labels) - n + 1))
|
|
215
|
+
return [
|
|
216
|
+
{"progression": list(items), "count": int(count)}
|
|
217
|
+
for items, count in counts.most_common(12)
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _beats_per_chord(chords: list[dict[str, Any]], beat_results: list[BeatResult]) -> float | None:
|
|
222
|
+
grid = next((x for x in beat_results if x.model == "consensus"), None)
|
|
223
|
+
if grid is None and beat_results:
|
|
224
|
+
grid = max(beat_results, key=lambda x: len(x.beats))
|
|
225
|
+
if not grid or len(grid.beats) < 4 or not chords:
|
|
226
|
+
return None
|
|
227
|
+
b = np.asarray(grid.beats, dtype=float)
|
|
228
|
+
counts = []
|
|
229
|
+
for chord in chords:
|
|
230
|
+
if chord["label"] == "N":
|
|
231
|
+
continue
|
|
232
|
+
c = int(np.sum((b >= chord["start"]) & (b < chord["end"])))
|
|
233
|
+
if c > 0:
|
|
234
|
+
counts.append(c)
|
|
235
|
+
return float(np.median(counts)) if counts else None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _chroma_matrix(vamp_result: dict[str, Any] | None) -> np.ndarray | None:
|
|
239
|
+
analysis = _find_analysis(vamp_result, "nnls_chroma")
|
|
240
|
+
if not analysis:
|
|
241
|
+
return None
|
|
242
|
+
rows = []
|
|
243
|
+
for event in analysis.get("events", []):
|
|
244
|
+
vals = [float(v) for v in event.get("values", [])]
|
|
245
|
+
if len(vals) >= 12:
|
|
246
|
+
rows.append(vals[:12])
|
|
247
|
+
if not rows:
|
|
248
|
+
return None
|
|
249
|
+
matrix_a = np.asarray(rows, dtype=float)
|
|
250
|
+
# NNLS Chroma's bins are A, Bb, B, C, C#, D, Eb, E, F, F#, G, Ab.
|
|
251
|
+
# Rotate to C, C#, D, Eb, E, F, F#, G, Ab, A, Bb, B.
|
|
252
|
+
order = [3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2]
|
|
253
|
+
return matrix_a[:, order]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _key_candidates(chroma: np.ndarray | None) -> list[dict[str, Any]]:
|
|
257
|
+
if chroma is None or chroma.size == 0:
|
|
258
|
+
return []
|
|
259
|
+
profile = np.nanmean(chroma, axis=0)
|
|
260
|
+
if not np.any(np.isfinite(profile)) or np.nansum(profile) <= 0:
|
|
261
|
+
return []
|
|
262
|
+
profile = np.nan_to_num(profile, nan=0.0)
|
|
263
|
+
scores: list[tuple[float, str]] = []
|
|
264
|
+
for root in range(12):
|
|
265
|
+
for mode, template in (("major", MAJOR_PROFILE), ("minor", MINOR_PROFILE)):
|
|
266
|
+
rolled = np.roll(template, root)
|
|
267
|
+
if np.std(profile) == 0 or np.std(rolled) == 0:
|
|
268
|
+
corr = 0.0
|
|
269
|
+
else:
|
|
270
|
+
corr = float(np.corrcoef(profile, rolled)[0, 1])
|
|
271
|
+
scores.append((corr, f"{PITCHES_C[root]} {mode}"))
|
|
272
|
+
scores.sort(reverse=True)
|
|
273
|
+
return [{"key": label, "correlation": score} for score, label in scores[:6]]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _pitch_class_entropy(chroma: np.ndarray | None) -> float | None:
|
|
277
|
+
if chroma is None or chroma.size == 0:
|
|
278
|
+
return None
|
|
279
|
+
p = np.nansum(np.maximum(chroma, 0.0), axis=0)
|
|
280
|
+
total = float(np.sum(p))
|
|
281
|
+
if total <= 0:
|
|
282
|
+
return None
|
|
283
|
+
p = p / total
|
|
284
|
+
p = p[p > 0]
|
|
285
|
+
return float(-np.sum(p * np.log2(p)) / math.log2(12.0))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def analyze_harmony(
|
|
289
|
+
vamp_result: dict[str, Any] | None,
|
|
290
|
+
beat_results: list[BeatResult],
|
|
291
|
+
output_dir: Path,
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""Fuse Chordino/NNLS/QM evidence into a higher-level harmonic summary."""
|
|
294
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
|
|
296
|
+
chord_analysis = _find_analysis(vamp_result, "chords")
|
|
297
|
+
chord_events = list(chord_analysis.get("events", [])) if chord_analysis else []
|
|
298
|
+
chords = _collapse_progression(chord_events)
|
|
299
|
+
labels = [item["label"] for item in chords if item["label"] != "N"]
|
|
300
|
+
|
|
301
|
+
by_duration: Counter[str] = Counter()
|
|
302
|
+
for item in chords:
|
|
303
|
+
by_duration[item["label"]] += float(item["duration"])
|
|
304
|
+
|
|
305
|
+
total_duration = sum(by_duration.values())
|
|
306
|
+
chord_hist = [
|
|
307
|
+
{
|
|
308
|
+
"chord": label,
|
|
309
|
+
"seconds": float(seconds),
|
|
310
|
+
"share": float(seconds / total_duration) if total_duration > 0 else 0.0,
|
|
311
|
+
}
|
|
312
|
+
for label, seconds in by_duration.most_common()
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
transitions = Counter(zip(labels, labels[1:]))
|
|
316
|
+
top_transitions = [
|
|
317
|
+
{"from": a, "to": b, "count": int(count)}
|
|
318
|
+
for (a, b), count in transitions.most_common(16)
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
chroma = _chroma_matrix(vamp_result)
|
|
322
|
+
key_analysis = _find_analysis(vamp_result, "key")
|
|
323
|
+
vamp_key = None
|
|
324
|
+
if key_analysis and key_analysis.get("events"):
|
|
325
|
+
first = key_analysis["events"][0]
|
|
326
|
+
vamp_key = first.get("label") or (first.get("values") or [None])[0]
|
|
327
|
+
|
|
328
|
+
tuning_analysis = _find_analysis(vamp_result, "tuning")
|
|
329
|
+
tuning_hz = None
|
|
330
|
+
if tuning_analysis and tuning_analysis.get("events"):
|
|
331
|
+
vals = tuning_analysis["events"][0].get("values") or []
|
|
332
|
+
if vals:
|
|
333
|
+
tuning_hz = float(vals[0])
|
|
334
|
+
|
|
335
|
+
tonal_changes = _find_analysis(vamp_result, "tonal_changes")
|
|
336
|
+
change_times = [
|
|
337
|
+
float(event.get("start") or 0.0)
|
|
338
|
+
for event in (tonal_changes.get("events", []) if tonal_changes else [])
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
key_candidates = _key_candidates(chroma)
|
|
342
|
+
key_basis = vamp_key or (key_candidates[0]["key"] if key_candidates else None)
|
|
343
|
+
functional = _functional_harmony(chords, key_basis)
|
|
344
|
+
|
|
345
|
+
result = {
|
|
346
|
+
"source": "StemLab Vamp evidence fusion",
|
|
347
|
+
"vamp_key": vamp_key,
|
|
348
|
+
"independent_key_candidates_from_nnls_chroma": key_candidates,
|
|
349
|
+
"functional_harmony": functional,
|
|
350
|
+
"tuning_hz": tuning_hz,
|
|
351
|
+
"pitch_class_entropy_normalized": _pitch_class_entropy(chroma),
|
|
352
|
+
"tonal_change_times_seconds": change_times,
|
|
353
|
+
"chords": {
|
|
354
|
+
"event_count": len(chords),
|
|
355
|
+
"unique_count": len(set(labels)),
|
|
356
|
+
"changes_per_minute": (
|
|
357
|
+
float(60.0 * max(0, len(chords) - 1) / max(1e-9, chords[-1]["end"] - chords[0]["start"]))
|
|
358
|
+
if len(chords) >= 2
|
|
359
|
+
else None
|
|
360
|
+
),
|
|
361
|
+
"median_beats_per_chord": _beats_per_chord(chords, beat_results),
|
|
362
|
+
"duration_histogram": chord_hist,
|
|
363
|
+
"top_transitions": top_transitions,
|
|
364
|
+
"top_bigrams": _ngram_counts(labels, 2),
|
|
365
|
+
"top_trigrams": _ngram_counts(labels, 3),
|
|
366
|
+
"collapsed_progression": chords,
|
|
367
|
+
},
|
|
368
|
+
"method_notes": {
|
|
369
|
+
"key_fusion": "Reports the QM Vamp key independently from a Krumhansl-Schmuckler correlation over mean NNLS chroma; disagreement is retained rather than hidden.",
|
|
370
|
+
"harmonic_rhythm": "Median number of consensus/detected beats falling inside non-N chord segments.",
|
|
371
|
+
"functional_harmony": "Scale-degree, chromatic-root, root-motion and cadence summaries are deterministic derivations relative to the reported key basis, not separate learned labels.",
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
write_json(output_dir / "harmony.json", result)
|
|
375
|
+
return result
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from stemlab.util import write_json
|
|
10
|
+
|
|
11
|
+
WORD_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ0-9]+(?:['’][A-Za-zÀ-ÖØ-öø-ÿ]+)?")
|
|
12
|
+
VOWELS = set("AEIOU")
|
|
13
|
+
STOPWORDS = {
|
|
14
|
+
"a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "for", "from",
|
|
15
|
+
"had", "has", "have", "he", "her", "him", "his", "i", "if", "in", "into", "is", "it",
|
|
16
|
+
"its", "me", "my", "no", "not", "of", "on", "or", "our", "out", "she", "so", "that",
|
|
17
|
+
"the", "their", "them", "then", "there", "they", "this", "to", "up", "us", "was", "we",
|
|
18
|
+
"were", "what", "when", "where", "who", "will", "with", "you", "your", "just", "while",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _words(text: str) -> list[str]:
|
|
23
|
+
return [m.group(0).lower().replace("’", "'") for m in WORD_RE.finditer(text)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalise_line(line: str) -> str:
|
|
27
|
+
return " ".join(_words(line))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cmu() -> dict[str, list[list[str]]] | None:
|
|
31
|
+
try:
|
|
32
|
+
import cmudict
|
|
33
|
+
|
|
34
|
+
raw = cmudict.dict()
|
|
35
|
+
return {str(k).lower(): list(v) for k, v in raw.items()}
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _phones(word: str, dictionary: dict[str, list[list[str]]] | None) -> list[str] | None:
|
|
41
|
+
if not dictionary:
|
|
42
|
+
return None
|
|
43
|
+
variants = dictionary.get(word.lower())
|
|
44
|
+
return variants[0] if variants else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _syllable_count_word(word: str, dictionary: dict[str, list[list[str]]] | None) -> int:
|
|
48
|
+
phones = _phones(word, dictionary)
|
|
49
|
+
if phones:
|
|
50
|
+
return max(1, sum(any(ch.isdigit() for ch in p) for p in phones))
|
|
51
|
+
cleaned = re.sub(r"[^a-z]", "", word.lower())
|
|
52
|
+
if not cleaned:
|
|
53
|
+
return 0
|
|
54
|
+
groups = re.findall(r"[aeiouy]+", cleaned)
|
|
55
|
+
count = len(groups)
|
|
56
|
+
if cleaned.endswith("e") and count > 1 and not cleaned.endswith(("le", "ye")):
|
|
57
|
+
count -= 1
|
|
58
|
+
return max(1, count)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rhyme_key(word: str, dictionary: dict[str, list[list[str]]] | None) -> str:
|
|
62
|
+
phones = _phones(word, dictionary)
|
|
63
|
+
if phones:
|
|
64
|
+
# Keep the final stressed vowel and everything after it. If stress is
|
|
65
|
+
# absent, fall back to the final vowel nucleus.
|
|
66
|
+
idx = None
|
|
67
|
+
for i in range(len(phones) - 1, -1, -1):
|
|
68
|
+
if "1" in phones[i] or "2" in phones[i]:
|
|
69
|
+
idx = i
|
|
70
|
+
break
|
|
71
|
+
if idx is None:
|
|
72
|
+
for i in range(len(phones) - 1, -1, -1):
|
|
73
|
+
if phones[i] and phones[i][0] in VOWELS:
|
|
74
|
+
idx = i
|
|
75
|
+
break
|
|
76
|
+
if idx is not None:
|
|
77
|
+
return " ".join(re.sub(r"\d", "", p) for p in phones[idx:])
|
|
78
|
+
# Orthographic fallback keeps the analysis useful for names/out-of-vocab words.
|
|
79
|
+
cleaned = re.sub(r"[^a-z]", "", word.lower())
|
|
80
|
+
return cleaned[-3:] if len(cleaned) >= 3 else cleaned
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _line_records(lyrics: str, timing: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
84
|
+
lines = [line.strip() for line in lyrics.splitlines() if line.strip()]
|
|
85
|
+
timed_by_norm: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
86
|
+
for item in timing or []:
|
|
87
|
+
timed_by_norm[_normalise_line(str(item.get("text") or ""))].append(item)
|
|
88
|
+
|
|
89
|
+
seen: Counter[str] = Counter()
|
|
90
|
+
records = []
|
|
91
|
+
for index, line in enumerate(lines):
|
|
92
|
+
norm = _normalise_line(line)
|
|
93
|
+
occurrence = seen[norm]
|
|
94
|
+
seen[norm] += 1
|
|
95
|
+
candidates = timed_by_norm.get(norm, [])
|
|
96
|
+
t = candidates[occurrence] if occurrence < len(candidates) else None
|
|
97
|
+
words = _words(line)
|
|
98
|
+
records.append(
|
|
99
|
+
{
|
|
100
|
+
"index": index,
|
|
101
|
+
"text": line,
|
|
102
|
+
"normalised": norm,
|
|
103
|
+
"words": words,
|
|
104
|
+
"end_word": words[-1] if words else "",
|
|
105
|
+
"start": float(t.get("start")) if t and t.get("start") is not None else None,
|
|
106
|
+
"end": float(t.get("end")) if t and t.get("end") is not None else None,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
return records
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rhyme_analysis(records: list[dict[str, Any]], dictionary) -> dict[str, Any]:
|
|
113
|
+
key_to_letter: dict[str, str] = {}
|
|
114
|
+
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
115
|
+
classes: dict[str, list[int]] = defaultdict(list)
|
|
116
|
+
scheme: list[str] = []
|
|
117
|
+
|
|
118
|
+
for rec in records:
|
|
119
|
+
end_word = rec["end_word"]
|
|
120
|
+
key = _rhyme_key(end_word, dictionary) if end_word else ""
|
|
121
|
+
rec["rhyme_key"] = key
|
|
122
|
+
if not key:
|
|
123
|
+
scheme.append("-")
|
|
124
|
+
continue
|
|
125
|
+
if key not in key_to_letter:
|
|
126
|
+
n = len(key_to_letter)
|
|
127
|
+
key_to_letter[key] = alphabet[n] if n < len(alphabet) else f"R{n + 1}"
|
|
128
|
+
letter = key_to_letter[key]
|
|
129
|
+
scheme.append(letter)
|
|
130
|
+
classes[key].append(rec["index"])
|
|
131
|
+
|
|
132
|
+
repeated_classes = [
|
|
133
|
+
{
|
|
134
|
+
"rhyme": key,
|
|
135
|
+
"scheme": key_to_letter[key],
|
|
136
|
+
"line_indexes": indexes,
|
|
137
|
+
"end_words": [records[i]["end_word"] for i in indexes],
|
|
138
|
+
}
|
|
139
|
+
for key, indexes in classes.items()
|
|
140
|
+
if len(indexes) >= 2
|
|
141
|
+
]
|
|
142
|
+
repeated_classes.sort(key=lambda x: (-len(x["line_indexes"]), x["scheme"]))
|
|
143
|
+
|
|
144
|
+
# Consecutive-line rhyme density is a useful simple signal for pop/rap writing.
|
|
145
|
+
adjacent = 0
|
|
146
|
+
adjacent_matches = 0
|
|
147
|
+
for a, b in zip(records, records[1:]):
|
|
148
|
+
if a.get("rhyme_key") and b.get("rhyme_key"):
|
|
149
|
+
adjacent += 1
|
|
150
|
+
adjacent_matches += int(a["rhyme_key"] == b["rhyme_key"])
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"scheme": " ".join(scheme),
|
|
154
|
+
"classes": repeated_classes,
|
|
155
|
+
"unique_rhyme_classes": len(classes),
|
|
156
|
+
"adjacent_end_rhyme_rate": float(adjacent_matches / adjacent) if adjacent else None,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _internal_rhyme_density(records: list[dict[str, Any]], dictionary) -> float | None:
|
|
161
|
+
comparisons = 0
|
|
162
|
+
matches = 0
|
|
163
|
+
for rec in records:
|
|
164
|
+
words = rec["words"]
|
|
165
|
+
if len(words) < 3:
|
|
166
|
+
continue
|
|
167
|
+
keys = [_rhyme_key(word, dictionary) for word in words]
|
|
168
|
+
end_key = keys[-1]
|
|
169
|
+
for key in keys[:-1]:
|
|
170
|
+
if not key or not end_key:
|
|
171
|
+
continue
|
|
172
|
+
comparisons += 1
|
|
173
|
+
matches += int(key == end_key)
|
|
174
|
+
return float(matches / comparisons) if comparisons else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _key_terms(words: list[str]) -> list[dict[str, Any]]:
|
|
178
|
+
terms = [w for w in words if len(w) >= 3 and w not in STOPWORDS and not w.isdigit()]
|
|
179
|
+
counts = Counter(terms)
|
|
180
|
+
return [{"term": term, "count": int(count)} for term, count in counts.most_common(24)]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ngrams(words: list[str], n: int) -> list[dict[str, Any]]:
|
|
184
|
+
if len(words) < n:
|
|
185
|
+
return []
|
|
186
|
+
counts = Counter(tuple(words[i : i + n]) for i in range(len(words) - n + 1))
|
|
187
|
+
return [
|
|
188
|
+
{"phrase": " ".join(items), "count": int(count)}
|
|
189
|
+
for items, count in counts.most_common(20)
|
|
190
|
+
if count >= 2 and not all(word in STOPWORDS for word in items)
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _delivery(records: list[dict[str, Any]], dictionary) -> dict[str, Any]:
|
|
195
|
+
timed = []
|
|
196
|
+
for rec in records:
|
|
197
|
+
if rec["start"] is None or rec["end"] is None or rec["end"] <= rec["start"]:
|
|
198
|
+
continue
|
|
199
|
+
syllables = sum(_syllable_count_word(w, dictionary) for w in rec["words"])
|
|
200
|
+
duration = rec["end"] - rec["start"]
|
|
201
|
+
timed.append(
|
|
202
|
+
{
|
|
203
|
+
"line_index": rec["index"],
|
|
204
|
+
"start": rec["start"],
|
|
205
|
+
"end": rec["end"],
|
|
206
|
+
"words": len(rec["words"]),
|
|
207
|
+
"syllables": syllables,
|
|
208
|
+
"words_per_second": len(rec["words"]) / duration,
|
|
209
|
+
"syllables_per_second": syllables / duration,
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
if not timed:
|
|
213
|
+
return {"timed_lines": [], "median_words_per_second": None, "median_syllables_per_second": None}
|
|
214
|
+
return {
|
|
215
|
+
"timed_lines": timed,
|
|
216
|
+
"median_words_per_second": float(np.median([x["words_per_second"] for x in timed])),
|
|
217
|
+
"median_syllables_per_second": float(np.median([x["syllables_per_second"] for x in timed])),
|
|
218
|
+
"peak_syllables_per_second": float(max(x["syllables_per_second"] for x in timed)),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def analyze_lyrics(
|
|
223
|
+
lyrics: str,
|
|
224
|
+
output_dir: Path,
|
|
225
|
+
*,
|
|
226
|
+
timing: list[dict[str, Any]] | None = None,
|
|
227
|
+
source: str = "canonical",
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
"""Analyse rhyme, repetition, prosody, lexical variety and timed delivery."""
|
|
230
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
dictionary = _cmu()
|
|
232
|
+
records = _line_records(lyrics, timing)
|
|
233
|
+
all_words = [word for rec in records for word in rec["words"]]
|
|
234
|
+
normal_lines = [rec["normalised"] for rec in records if rec["normalised"]]
|
|
235
|
+
line_counts = Counter(normal_lines)
|
|
236
|
+
|
|
237
|
+
syllables_per_line = [
|
|
238
|
+
sum(_syllable_count_word(word, dictionary) for word in rec["words"])
|
|
239
|
+
for rec in records
|
|
240
|
+
]
|
|
241
|
+
for rec, syllables in zip(records, syllables_per_line):
|
|
242
|
+
rec["syllables"] = syllables
|
|
243
|
+
|
|
244
|
+
rhyme = _rhyme_analysis(records, dictionary)
|
|
245
|
+
rhyme["internal_end_rhyme_density"] = _internal_rhyme_density(records, dictionary)
|
|
246
|
+
|
|
247
|
+
result = {
|
|
248
|
+
"source": source,
|
|
249
|
+
"text": lyrics,
|
|
250
|
+
"line_count": len(records),
|
|
251
|
+
"word_count": len(all_words),
|
|
252
|
+
"unique_word_count": len(set(all_words)),
|
|
253
|
+
"type_token_ratio": float(len(set(all_words)) / len(all_words)) if all_words else None,
|
|
254
|
+
"mean_words_per_line": float(np.mean([len(rec["words"]) for rec in records])) if records else None,
|
|
255
|
+
"median_syllables_per_line": float(np.median(syllables_per_line)) if syllables_per_line else None,
|
|
256
|
+
"repetition": {
|
|
257
|
+
"repeated_lines": [
|
|
258
|
+
{"line": line, "count": int(count)}
|
|
259
|
+
for line, count in line_counts.most_common()
|
|
260
|
+
if count >= 2
|
|
261
|
+
],
|
|
262
|
+
"top_bigrams": _ngrams(all_words, 2),
|
|
263
|
+
"top_trigrams": _ngrams(all_words, 3),
|
|
264
|
+
},
|
|
265
|
+
"rhyme": rhyme,
|
|
266
|
+
"lexical": {
|
|
267
|
+
"key_terms": _key_terms(all_words),
|
|
268
|
+
},
|
|
269
|
+
"delivery": _delivery(records, dictionary),
|
|
270
|
+
"lines": records,
|
|
271
|
+
"method_notes": {
|
|
272
|
+
"phonetics": "CMU Pronouncing Dictionary when available, with an orthographic fallback for names and out-of-vocabulary words.",
|
|
273
|
+
"rhyme": "End-rhyme class uses the final stressed vowel nucleus and following phonemes; this is a useful signal, not a complete theory of slant rhyme.",
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
write_json(output_dir / "lyrics.json", result)
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def transcript_to_lyrics(whisper_result: dict[str, Any] | None) -> tuple[str, list[dict[str, Any]]]:
|
|
281
|
+
"""Produce a line-like text/timing representation when canonical lyrics are absent."""
|
|
282
|
+
if not whisper_result:
|
|
283
|
+
return "", []
|
|
284
|
+
segments = whisper_result.get("segments") or []
|
|
285
|
+
text_lines = []
|
|
286
|
+
timing = []
|
|
287
|
+
for seg in segments:
|
|
288
|
+
text = str(seg.get("text") or "").strip()
|
|
289
|
+
if not text:
|
|
290
|
+
continue
|
|
291
|
+
text_lines.append(text)
|
|
292
|
+
timing.append({"start": seg.get("start"), "end": seg.get("end"), "text": text})
|
|
293
|
+
return "\n".join(text_lines), timing
|