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.
Files changed (56) hide show
  1. danceflow_stemlab-1.0.0.dist-info/METADATA +347 -0
  2. danceflow_stemlab-1.0.0.dist-info/RECORD +56 -0
  3. danceflow_stemlab-1.0.0.dist-info/WHEEL +5 -0
  4. danceflow_stemlab-1.0.0.dist-info/entry_points.txt +2 -0
  5. danceflow_stemlab-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. danceflow_stemlab-1.0.0.dist-info/top_level.txt +1 -0
  7. stemlab/__init__.py +10 -0
  8. stemlab/__main__.py +4 -0
  9. stemlab/analysis/__init__.py +5 -0
  10. stemlab/analysis/harmony.py +375 -0
  11. stemlab/analysis/lyrics.py +293 -0
  12. stemlab/analysis/registry.py +111 -0
  13. stemlab/analysis/rhythm.py +235 -0
  14. stemlab/analysis/runner.py +187 -0
  15. stemlab/analysis/semantic_audio.py +119 -0
  16. stemlab/analysis/semantic_text.py +117 -0
  17. stemlab/analysis/song_map.py +220 -0
  18. stemlab/analysis/sonic.py +192 -0
  19. stemlab/analysis/structure.py +104 -0
  20. stemlab/analysis/transcription.py +98 -0
  21. stemlab/audio.py +161 -0
  22. stemlab/beats/__init__.py +5 -0
  23. stemlab/beats/base.py +12 -0
  24. stemlab/beats/beatnet.py +103 -0
  25. stemlab/beats/beatthis.py +25 -0
  26. stemlab/beats/beattransformer.py +157 -0
  27. stemlab/beats/common.py +123 -0
  28. stemlab/bootstrap.py +133 -0
  29. stemlab/branding.py +57 -0
  30. stemlab/canonical.py +188 -0
  31. stemlab/cli.py +188 -0
  32. stemlab/manifest.py +35 -0
  33. stemlab/models.py +114 -0
  34. stemlab/pipeline.py +398 -0
  35. stemlab/separators/__init__.py +18 -0
  36. stemlab/separators/base.py +12 -0
  37. stemlab/separators/bsroformer.py +71 -0
  38. stemlab/separators/common.py +30 -0
  39. stemlab/separators/demucs.py +46 -0
  40. stemlab/separators/openunmix.py +46 -0
  41. stemlab/separators/scnet.py +57 -0
  42. stemlab/server.py +1162 -0
  43. stemlab/sonic_visualiser.py +1125 -0
  44. stemlab/spectrogram.py +92 -0
  45. stemlab/speech.py +109 -0
  46. stemlab/types.py +71 -0
  47. stemlab/util.py +62 -0
  48. stemlab/vamp.py +411 -0
  49. stemlab/vamp_runtime.py +253 -0
  50. stemlab/web/app.js +844 -0
  51. stemlab/web/arcadians-cover.jpg +0 -0
  52. stemlab/web/arcadians-reference.json +338 -0
  53. stemlab/web/canonical.js +423 -0
  54. stemlab/web/index.html +148 -0
  55. stemlab/web/style.css +272 -0
  56. 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