File size: 10,183 Bytes
7bb70e8
 
 
 
 
 
 
 
 
 
 
 
 
b295017
7bb70e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55082a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7bb70e8
 
 
 
 
 
55082a2
7bb70e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b295017
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7bb70e8
 
 
 
 
 
 
 
 
 
 
 
b295017
 
7bb70e8
b295017
7bb70e8
b295017
7bb70e8
 
 
b295017
 
 
 
 
7bb70e8
 
 
 
 
 
 
 
 
55082a2
 
 
7bb70e8
 
 
55082a2
 
7bb70e8
 
55082a2
7bb70e8
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""Put generated music on a recording's own bar lines.

YuE2 plays a score at the score's tempo, but it does not promise exact
timing, and the singer's recording breathes: bars are never all the same
length. So the new audio is matched to the recording bar by bar and time-warped
until each of its bars starts where the recording's bar starts.

- `bar_starts` turns SheetSage2's beats into the start time of every score bar.
- `match` pairs the generated audio's bars with the score's bars by harmony,
  using dynamic time warping so a bar YuE2 added, dropped or smeared costs a
  little and does not shift everything after it.
- `warp` stretches the generated audio between matched bar lines.
"""
import logging
import shutil

import numpy as np

# A bar that has to shrink or grow more than this is a bad match, not a tempo
# wobble, and is left out of the warp.
MIN_RATIO, MAX_RATIO = 0.6, 1.7


def beat_rows(text):
    """SheetSage2's beat.lab: (time, position in bar, beats per bar)."""
    rows = []
    for line in (text or "").splitlines():
        parts = line.split()
        if len(parts) >= 3:
            rows.append((float(parts[0]), int(parts[1]), int(parts[2])))
    return rows


def bar_starts(beats, bars, duration):
    """Start times of `bars` score bars, plus the end of the last one.

    SheetSage2 builds its score from the same beats: a bar starts on each
    downbeat, a pickup bar on the first beat, and past the last detected beat
    the final beat period carries on. Returns None when there are too few beats.
    """
    if len(beats) < 2 or bars < 1:
        return None
    times = [b[0] for b in beats]
    period = float(np.median(np.diff(times[-9:])))
    if period <= 0:
        return None
    starts = [t for i, (t, position, _) in enumerate(beats) if i == 0 or position == 1]
    per_bar = beats[-1][2] * period
    while len(starts) < bars + 1:
        starts.append(starts[-1] + per_bar)
    starts = starts[:bars + 1]
    # The notation pads its last bar out to the end of the audio.
    starts[-1] = max(starts[-1], min(duration, starts[-2] + per_bar))
    return [round(float(t), 4) for t in starts]


def uniform_starts(bars, seconds_per_bar, offset=0.0):
    return [round(offset + i * seconds_per_bar, 4) for i in range(bars + 1)]


def bar_chroma(audio, sr, starts):
    """Mean chroma of each bar [starts[i], starts[i+1]), normalised."""
    import librosa
    mono = audio.mean(axis=0) if audio.ndim == 2 else audio
    hop = 512
    chroma = librosa.feature.chroma_cqt(y=mono.astype(np.float32), sr=sr, hop_length=hop)
    frames = chroma.shape[1]
    rows = []
    for a, b in zip(starts, starts[1:]):
        lo, hi = int(a * sr / hop), max(int(a * sr / hop) + 1, int(b * sr / hop))
        lo, hi = min(lo, frames - 1), min(hi, frames)
        vector = chroma[:, lo:hi].mean(axis=1) if hi > lo else np.zeros(12)
        norm = np.linalg.norm(vector)
        rows.append(vector / norm if norm else vector)
    return np.array(rows)


def match(expected, heard, skip=0.35, stretch=0.25):
    """Pair score bars (rows of `expected`) with generated bars (rows of `heard`).

    Every score bar is placed. Generated bars can be skipped at a cost, or two
    score bars can land on one generated bar at a cost; skipping generated bars
    before the first match and after the last is free, since YuE2 can lead in
    or ring out. Returns a list of (score bar, generated bar) diagonal matches.
    """
    n, m = len(expected), len(heard)
    if not n or not m:
        return []
    cost = 1.0 - np.clip(expected @ heard.T, 0.0, 1.0)
    big = 1e9
    total = np.full((n + 1, m + 1), big)
    total[0, :] = 0.0  # free lead-in on the generated side
    step = np.zeros((n + 1, m + 1), dtype=np.int8)
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            options = (total[i - 1, j - 1] + cost[i - 1, j - 1],  # 0: bar for bar
                       total[i - 1, j] + cost[i - 1, j - 1] + stretch,  # 1: score bar shares a heard bar
                       total[i, j - 1] + skip)  # 2: skip a heard bar
            choice = int(np.argmin(options))
            total[i, j], step[i, j] = options[choice], choice
    i, j = n, int(np.argmin(total[n, 1:])) + 1  # free ring-out
    pairs = []
    while i > 0 and j > 0:
        choice = step[i, j]
        if choice == 0:
            pairs.append((i - 1, j - 1))
            i, j = i - 1, j - 1
        elif choice == 1:
            i -= 1
        else:
            j -= 1
    return pairs[::-1]


def beats_within(pairs, target, heard, target_beats, heard_beats):
    """(generated, recording) points for the beats inside matched bars.

    A bar is only subdivided when both sides found the same number of beats in
    it; otherwise its bar line alone holds it, which is still correct, just
    looser inside the bar.
    """
    if target_beats is None or heard_beats is None:
        return []
    ours, theirs = np.asarray(target_beats, dtype=float), np.asarray(heard_beats, dtype=float)
    points = []
    for i, j in pairs:
        # A small margin keeps a beat that sits right on a bar line in its own bar.
        want = ours[(ours > target[i] + 0.03) & (ours < target[i + 1] - 0.03)]
        got = theirs[(theirs > heard[j] + 0.03) & (theirs < heard[j + 1] - 0.03)]
        if len(want) and len(want) == len(got):
            points.extend(zip(got.tolist(), want.tolist()))
    return points


def anchors(pairs, target, heard, extra=()):
    """(generated time, recording time) points for every trustworthy bar line,
    plus any beat points inside the bars, in time order."""
    points = []
    for i, j in pairs:
        points.append((heard[j], target[i]))
    last_i, last_j = pairs[-1] if pairs else (None, None)
    if pairs:
        points.append((heard[last_j + 1], target[last_i + 1]))
    points = sorted(points + list(extra))
    kept = []
    for source, dest in points:
        if kept:
            gap_in, gap_out = source - kept[-1][0], dest - kept[-1][1]
            if gap_in <= 0.05 or gap_out <= 0.05:
                continue
            if not MIN_RATIO <= gap_out / gap_in <= MAX_RATIO:
                continue
        kept.append((source, dest))
    return kept


def _stretch_segment(segment, sr, length):
    import librosa
    if length <= 0 or segment.shape[-1] == 0:
        return np.zeros((segment.shape[0], max(0, length)), dtype=np.float32)
    rate = segment.shape[-1] / length
    out = np.stack([librosa.effects.time_stretch(ch, rate=rate) for ch in segment])
    if out.shape[1] < length:
        out = np.pad(out, ((0, 0), (0, length - out.shape[1])))
    return out[:, :length]


def timemap(points, length, sr, duration):
    """Sample positions (source, destination) for the warp, both strictly rising.

    Material before the first bar line and after the last keeps its natural
    speed. Bar lines past the end of the audio (the render can be shorter than
    the bars it was matched to) are dropped, so the map always ends inside the
    audio, which Rubber Band requires.
    """
    if len(points) < 2:
        return None
    seconds = length / float(sr)
    first_in, first_out = points[0]
    lead = min(first_in, first_out)
    kept = []
    for a, b in [(first_in - lead, first_out - lead)] + list(points):
        if a > seconds:
            break
        if kept and (a <= kept[-1][0] or b <= kept[-1][1]):
            continue
        kept.append((a, b))
    last_in, last_out = kept[-1]
    tail = max(0.0, min(seconds - last_in, duration - last_out))
    if tail > 1e-3:
        kept.append((last_in + tail, last_out + tail))
    src, dst = [], []
    for a, b in kept:
        s, d = min(length, int(round(a * sr))), int(round(b * sr))
        if src and (s <= src[-1] or d <= dst[-1]):
            continue
        src.append(s)
        dst.append(d)
    return (src, dst) if len(src) >= 2 else None


def warp(audio, sr, points, duration):
    """Stretch stereo `audio` so each anchor's generated time lands on its
    recording time, and return exactly `duration` seconds.

    Uses Rubber Band's time map when it is installed (one pass, high quality),
    and a per-segment phase vocoder otherwise.
    """
    audio = np.asarray(audio, dtype=np.float32)
    if audio.ndim == 1:
        audio = audio[None, :]
    total = int(round(duration * sr))
    out = np.zeros((audio.shape[0], total), dtype=np.float32)
    marks = timemap(points, audio.shape[1], sr, duration)
    if marks is None:
        return None
    src, dst = marks
    piece = audio[:, src[0]:src[-1]]
    stretched = None
    if shutil.which("rubberband"):
        import pyrubberband
        mapping = [(s - src[0], d - dst[0]) for s, d in zip(src, dst)]
        try:
            stretched = pyrubberband.timemap_stretch(piece.T, sr, mapping).T.astype(np.float32)
        except Exception:
            logging.exception("Rubber Band refused the time map; using the phase vocoder")
    if stretched is None:
        stretched = np.concatenate([_stretch_segment(audio[:, a:b], sr, d - c)
                                    for a, b, c, d in zip(src, src[1:], dst, dst[1:])], axis=1)
    begin = dst[0]
    end = min(total, begin + stretched.shape[1])
    if end > begin:
        out[:, begin:end] = stretched[:, :end - begin]
    return out


def lock(audio, sr, score, target, heard, duration, target_beats=None, heard_beats=None):
    """The whole job: match bars by harmony, then warp on every bar line and,
    when both beat lists are known, every beat. Returns (audio, report)."""
    expected = np.array([_profile(score, i) for i in range(len(target) - 1)])
    heard_chroma = bar_chroma(audio, sr, heard)
    pairs = match(expected, heard_chroma)
    inside = beats_within(pairs, target, heard, target_beats, heard_beats)
    points = anchors(pairs, target, heard, inside)
    warped = warp(audio, sr, points, duration)
    report = {"bars": len(target) - 1, "heard_bars": len(heard) - 1, "matched": len(pairs),
              "beats": len(inside), "anchors": len(points), "warped": warped is not None}
    return warped, report


def _profile(score, index):
    import scores
    return scores.profile(score, index)