italant7 commited on
Commit
f35bc1b
Β·
verified Β·
1 Parent(s): 81236b5

Upload structure.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. structure.py +272 -0
structure.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map a canonical lyric sheet onto what the recording actually sings.
2
+
3
+ A lyric sheet is *canonical*: the chorus is written once, repeats are collapsed,
4
+ and unsung extra verses sometimes ride along. Karaoke needs the *performance*
5
+ sequence β€” the real order, with the chorus appearing as many times as it is sung.
6
+ Forced alignment cannot invent that: it consumes the reference in order, so a
7
+ chorus written once but sung three times leaves two thirds of the vocal to be
8
+ absorbed by whatever line happens to be adjacent (measured: 1.37 s mean line
9
+ error, worst 3.7 s, when a reference carried lines the recording never sang).
10
+
11
+ The two inputs have exactly complementary strengths:
12
+
13
+ sheet right words, wrong structure
14
+ transcript right structure, wrong words
15
+
16
+ So we use the transcript only to decide *which sheet line is being sung when*,
17
+ never for its words. That works even when the transcript is poor β€” measured CER
18
+ on real songs is 0.64, but token-overlap similarity still identifies the correct
19
+ sheet line, because picking one line out of ~30 needs far less signal than
20
+ reading it. This is why the mapper is worth more than a better ASR.
21
+
22
+ No network, no API key, no LLM: it is a similarity matrix plus a Viterbi pass
23
+ with a continuation bonus. See `resolve_with_llm` for where a model genuinely
24
+ helps (ambiguous sheets), which is a much smaller job than this one.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from typing import List, Tuple
30
+
31
+ # A sheet line only counts as "sung here" above this token-overlap score. Below
32
+ # it the transcript segment is an ad-lib, an instrumental mis-fire, or a line the
33
+ # sheet simply does not contain.
34
+ MIN_MATCH = 0.34
35
+ # Reward for continuing to the next sheet line, which disambiguates the common
36
+ # case of near-identical lines (a chorus whose lines differ by one word) without
37
+ # forbidding the backward jump that a chorus repeat *is*.
38
+ CONTINUE_BONUS = 0.22
39
+ # Cost of jumping backwards in the sheet, i.e. claiming a line is sung again.
40
+ # A *penalty*, not a reward: a repeat has to be earned by the similarity, because
41
+ # sheets legitimately contain the same chorus text twice and a second chorus
42
+ # reads as a backward jump otherwise. Swept against ground truth β€” at 0.0 two
43
+ # fixtures gained phantom repeats; at -0.10 both are exact and the real repeat is
44
+ # still found.
45
+ REPEAT_BONUS = -0.10
46
+
47
+
48
+ def _norm(s: str) -> str:
49
+ s = s.lower().replace("Ρ‘", "Π΅")
50
+ s = re.sub(r"[^\w\s]|_", " ", s, flags=re.UNICODE)
51
+ return re.sub(r"\s+", " ", s).strip()
52
+
53
+
54
+ def _tokens(s: str) -> List[str]:
55
+ return _norm(s).split()
56
+
57
+
58
+ def _bigrams(word: str) -> set:
59
+ w = f" {word} "
60
+ return {w[i:i + 2] for i in range(len(w) - 1)}
61
+
62
+
63
+ def word_similarity(a: str, b: str) -> float:
64
+ """Dice coefficient over character bigrams β€” tolerant of the one- or
65
+ two-character errors that dominate sung ASR output."""
66
+ if a == b:
67
+ return 1.0
68
+ ga, gb = _bigrams(a), _bigrams(b)
69
+ if not ga or not gb:
70
+ return 0.0
71
+ return 2 * len(ga & gb) / (len(ga) + len(gb))
72
+
73
+
74
+ def line_similarity(hyp: str, ref: str) -> float:
75
+ """Greedy token matching between two lines, 0…1.
76
+
77
+ Token-level rather than character-level so that a transcript which gets a
78
+ word wrong still scores the line it belongs to. Length-normalized against
79
+ the *reference* so a long transcript run doesn't out-score a short line.
80
+ """
81
+ ht, rt = _tokens(hyp), _tokens(ref)
82
+ if not ht or not rt:
83
+ return 0.0
84
+ used = [False] * len(ht)
85
+ score = 0.0
86
+ for rw in rt:
87
+ best, bi = 0.0, -1
88
+ for i, hw in enumerate(ht):
89
+ if used[i]:
90
+ continue
91
+ s = word_similarity(rw, hw)
92
+ if s > best:
93
+ best, bi = s, i
94
+ if bi >= 0 and best >= 0.5:
95
+ used[bi] = True
96
+ score += best
97
+ return score / len(rt)
98
+
99
+
100
+ def map_performance(sheet: List[str], hyp_lines: List[dict],
101
+ min_match: float = MIN_MATCH) -> List[dict]:
102
+ """Decide which sheet line each transcript segment is singing.
103
+
104
+ `hyp_lines` are the transcript's timed lines ({startMs, endMs, text}).
105
+ Returns one entry per transcript segment: the matched sheet index (or None),
106
+ its score, and the segment's timing.
107
+
108
+ Viterbi over sheet index, so the choice is made for the sequence as a whole
109
+ rather than greedily per line β€” that is what lets a repeated chorus win over
110
+ a locally-similar verse line.
111
+ """
112
+ n, m = len(hyp_lines), len(sheet)
113
+ if not n or not m:
114
+ return []
115
+
116
+ sim = [[line_similarity(h["text"], s) for s in sheet] for h in hyp_lines]
117
+
118
+ NONE = m # an extra state: "matches nothing"
119
+ best = [[float("-inf")] * (m + 1) for _ in range(n)]
120
+ back = [[-1] * (m + 1) for _ in range(n)]
121
+ for j in range(m):
122
+ best[0][j] = sim[0][j]
123
+ best[0][NONE] = min_match * 0.999 # ...just under any real match
124
+
125
+ for i in range(1, n):
126
+ for j in range(m + 1):
127
+ emit = min_match * 0.999 if j == NONE else sim[i][j]
128
+ for pj in range(m + 1):
129
+ if best[i - 1][pj] == float("-inf"):
130
+ continue
131
+ bonus = 0.0
132
+ if j != NONE and pj != NONE:
133
+ if j == pj + 1:
134
+ bonus = CONTINUE_BONUS # running through a section
135
+ elif j < pj:
136
+ bonus = REPEAT_BONUS # jumped back: a repeat
137
+ v = best[i - 1][pj] + emit + bonus
138
+ if v > best[i][j]:
139
+ best[i][j] = v
140
+ back[i][j] = pj
141
+
142
+ j = max(range(m + 1), key=lambda k: best[n - 1][k])
143
+ path = [j]
144
+ for i in range(n - 1, 0, -1):
145
+ j = back[i][j]
146
+ path.append(j)
147
+ path.reverse()
148
+
149
+ out = []
150
+ for i, j in enumerate(path):
151
+ matched = j != NONE and sim[i][j] >= min_match
152
+ out.append({
153
+ "startMs": hyp_lines[i]["startMs"],
154
+ "endMs": hyp_lines[i]["endMs"],
155
+ "sheetIdx": j if matched else None,
156
+ "score": round(sim[i][j], 3) if j != NONE else 0.0,
157
+ "hyp": hyp_lines[i]["text"],
158
+ })
159
+ return out
160
+
161
+
162
+ def expand_reference(sheet: List[str], hyp_lines: List[dict],
163
+ min_match: float = MIN_MATCH) -> Tuple[List[str], List[dict]]:
164
+ """Build the reference the aligner should actually be given.
165
+
166
+ Returns `(lines, plan)` where `lines` is the sheet rewritten in performance
167
+ order β€” a chorus sung twice appears twice β€” and `plan` is the mapping detail.
168
+
169
+ **Strictly additive: no sheet line is ever dropped.** The mapper's recall is
170
+ bounded by the transcript's, and the transcript is poor β€” on a fixture where
171
+ all 16 sheet lines are sung, the ASR produced 12 usable segments, so a
172
+ "drop what wasn't matched" rule deleted 8 lines that really were sung. Adding
173
+ a repeat that isn't there costs a little alignment drift; deleting a line the
174
+ singer sings loses it from the karaoke entirely. So the sheet is the backbone
175
+ and the transcript may only *insert* into it.
176
+
177
+ Consecutive transcript segments matching the *same* sheet line collapse into
178
+ one: the transcript often splits a sung line in two, which is an artefact
179
+ rather than a repeat.
180
+ """
181
+ plan = map_performance(sheet, hyp_lines, min_match)
182
+
183
+ # Collapse ASR-split duplicates, keeping the matched entries in time order.
184
+ matched: List[dict] = []
185
+ for p in plan:
186
+ j = p["sheetIdx"]
187
+ if j is None:
188
+ continue
189
+ if matched and j == matched[-1]["sheetIdx"] and \
190
+ p["startMs"] - matched[-1]["endMs"] < 1500:
191
+ matched[-1]["endMs"] = p["endMs"]
192
+ continue
193
+ matched.append({"sheetIdx": j, "startMs": p["startMs"],
194
+ "endMs": p["endMs"], "score": p["score"]})
195
+
196
+ lines: List[str] = []
197
+ order: List[dict] = []
198
+
199
+ def emit(j: int, repeat: bool, hit: dict = None) -> None:
200
+ lines.append(sheet[j])
201
+ order.append({
202
+ "sheetIdx": j, "repeat": repeat,
203
+ "startMs": (hit or {}).get("startMs"),
204
+ "endMs": (hit or {}).get("endMs"),
205
+ "score": (hit or {}).get("score", 0.0),
206
+ })
207
+
208
+ # Walk the matched entries one at a time against a high-water mark. Grouping
209
+ # them into runs first was wrong twice over: a run that began with a repeat
210
+ # but then ran forward got classified as a repeat *whole*, and the high-water
211
+ # mark wasn't advanced on that branch, so the tail re-emitted the entire
212
+ # sheet β€” 16 lines came out as 28.
213
+ emitted = -1
214
+ for e in matched:
215
+ j = e["sheetIdx"]
216
+ if j > emitted:
217
+ # Forward progress. Emit any sheet lines the transcript skipped over
218
+ # (it has poor recall) so they are never lost, then this one.
219
+ for k in range(emitted + 1, j):
220
+ emit(k, False)
221
+ emit(j, False, e)
222
+ emitted = j
223
+ else:
224
+ # Already past this line, so the recording is singing it again.
225
+ emit(j, True, e)
226
+ for j in range(emitted + 1, len(sheet)): # tail the transcript never reached
227
+ emit(j, False)
228
+ return lines, order
229
+
230
+
231
+ def coverage(sheet: List[str], order: List[dict]) -> dict:
232
+ """How much of the sheet the performance used, and how much it repeated."""
233
+ return {
234
+ "sheetLines": len(sheet),
235
+ "performanceLines": len(order),
236
+ "repeatsInserted": sum(1 for o in order if o.get("repeat")),
237
+ "linesWithEvidence": sum(1 for o in order if o.get("startMs") is not None),
238
+ }
239
+
240
+
241
+ def resolve_with_llm(sheet: List[str], hyp_lines: List[dict], call) -> List[str]:
242
+ """Optional escape hatch for sheets the matcher can't resolve.
243
+
244
+ `call(prompt) -> str` is supplied by the caller so this module stays free of
245
+ any SDK or API key. Only worth reaching for when `coverage()` looks wrong β€”
246
+ a sheet in the wrong order, interleaved with a translation, or carrying a
247
+ second song. For the ordinary "chorus written once, sung twice" case the
248
+ deterministic path above is cheaper, faster and does not invent lines.
249
+
250
+ The model is asked to *reorder and repeat the given lines only*; any line it
251
+ returns that is not in the sheet is dropped, because an LLM inventing lyrics
252
+ is the one failure this whole pipeline exists to avoid.
253
+ """
254
+ numbered = "\n".join(f"{i}: {l}" for i, l in enumerate(sheet))
255
+ heard = "\n".join(f"{h['startMs']/1000:.1f}s: {h['text']}" for h in hyp_lines)
256
+ prompt = (
257
+ "A lyric sheet is written in canonical form (chorus once). A rough "
258
+ "machine transcript shows what the recording actually sings, in order, "
259
+ "with timings. The transcript has many wrong words β€” trust it only for "
260
+ "ORDER and REPETITION.\n\n"
261
+ f"SHEET (numbered):\n{numbered}\n\nTRANSCRIPT:\n{heard}\n\n"
262
+ "Output the sheet line numbers in the order they are actually sung, one "
263
+ "per line, repeating a number when its line is sung again. Output "
264
+ "nothing but numbers."
265
+ )
266
+ raw = call(prompt)
267
+ out = []
268
+ for tok in re.findall(r"\d+", raw or ""):
269
+ i = int(tok)
270
+ if 0 <= i < len(sheet): # never accept a line not in the sheet
271
+ out.append(sheet[i])
272
+ return out