File size: 7,841 Bytes
dee8df0
 
 
 
 
6b1f7f2
 
 
dee8df0
 
 
 
 
 
 
 
 
 
 
6b1f7f2
 
 
 
 
 
 
 
 
 
 
21c2288
 
 
 
 
 
6b1f7f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9249c0d
 
 
 
 
6b1f7f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dee8df0
 
21c2288
 
dee8df0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21c2288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dee8df0
 
 
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
"""Small deterministic contracts shared by the Space workers."""

from __future__ import annotations

import re
from dataclasses import dataclass
from math import isfinite
from statistics import median


MAX_SECONDS = 35.0
INSTRUMENTS = {
    "Флейта": (73, "flute"),
    "Кларнет": (71, "clarinet"),
    "Фортепиано": (0, "acoustic piano"),
    "Виолончель": (42, "cello"),
}


@dataclass(frozen=True)
class Beat:
    time: float
    position: int
    meter: int


def drum_hits(beat_lab: str, melody_spans: list[tuple[float, float]],
              duration: float) -> list[tuple[float, int, int]]:
    """Place a small GM kit on SheetSage's source-time beat grid."""
    beats = []
    lines = beat_lab.splitlines()
    if (len(lines) > 1 and lines[0].split() == ["0.0", "1", "1", "8"]
            and lines[1].split()[1:2] == ["1"]):
        # SheetSage sometimes exports a 1/8 time-zero anchor before its real grid.
        lines = lines[1:]
    for line in lines:
        fields = line.split()
        if len(fields) != 4:
            raise ValueError("SheetSage2 вернул некорректную сетку долей")
        time, position, meter, denominator = map(float, fields)
        if (not all(map(isfinite, (time, position, meter, denominator)))
                or int(position) != position or int(meter) != meter
                or int(denominator) != denominator or time < 0
                or int(meter) not in (2, 3, 4, 6)
                or int(denominator) not in (4, 8)
                or not 1 <= position <= meter):
            raise ValueError("SheetSage2 вернул неподдерживаемый размер или долю")
        if beats and time <= beats[-1].time:
            raise ValueError("Доли SheetSage2 идут не по времени")
        beats.append(Beat(time, int(position), int(meter)))
    if len(beats) < 3 or not melody_spans:
        raise ValueError("Для барабанов нужно хотя бы три доли и слышимая мелодия")
    # SheetSage can emit a time-zero anchor immediately before its first real
    # downbeat. Two adjacent beat-1 labels cannot both start the same measure.
    if (beats[0].time == 0 and beats[0].position == beats[1].position == 1
            and beats[0].meter == beats[1].meter):
        beats = beats[1:]
    period = median(b.time - a.time for a, b in zip(beats, beats[1:]))
    if not 0.12 <= period <= 2.0:
        raise ValueError("Не удалось определить разумный темп барабанов")

    def active(time: float) -> bool:
        return any(start < time + period / 2 and end > time - period / 2
                   for start, end in melody_spans)

    hits = []
    for index, beat in enumerate(beats):
        if beat.time >= duration or not active(beat.time):
            continue
        if beat.position == 1 or (beat.meter == 4 and beat.position == 3):
            hits.append((beat.time, 36, 90))  # kick
        elif beat.position in ({2, 4} if beat.meter == 4 else
                              {2} if beat.meter == 2 else
                              {3} if beat.meter == 3 else {4}):
            hits.append((beat.time, 38, 76))  # snare
        hits.append((beat.time, 42, 46))  # closed hi-hat
        if index + 1 < len(beats):
            interval = beats[index + 1].time - beat.time
            halfway = beat.time + interval / 2
            if interval <= period * 1.5 and halfway < duration and active(halfway):
                hits.append((halfway, 42, 32))
    if not hits:
        raise ValueError("Сетка долей не пересекается со звучащим напевом")
    return hits


def instrumental_abc(native: str, keep_chords: bool = False) -> str:
    """Move the native Vocal melody into Ins, failing on unknown ABC layout."""
    if "V: Vocal" not in native and 'V: Ins clef=treble name="Instrumental Melody"' in native:
        return native
    lines = native.splitlines()
    header = []
    body = []
    in_body = False
    for line in lines:
        if line == "V: Vocal":
            in_body = True
        (body if in_body else header).append(line)
    if not body or not any(line.startswith("V: Vocal ") for line in header):
        raise ValueError("SheetSage2 ABC has no expected Vocal melody")
    header = [line for line in header if not line.startswith(("V: Vocal ", "V: Ins "))]
    key_index = next((i for i, line in enumerate(header) if line.startswith("K:")), None)
    if key_index is None:
        raise ValueError("ABC has no key signature")
    header.insert(key_index, 'V: Ins clef=treble name="Instrumental Melody" snm="Ins"')
    out = []
    index = 0
    moved = 0
    while index < len(body):
        line = body[index]
        if line.startswith("%") or not line.strip():
            out.append(line)
            index += 1
            continue
        if (index + 3 >= len(body) or body[index] != "V: Vocal"
                or body[index + 2] != "V: Ins"
                or not re.fullmatch(r"Z\d*\|", body[index + 3])):
            raise ValueError("Unexpected SheetSage2 ABC voice layout")
        melody = body[index + 1]
        if not keep_chords:
            melody = re.sub(r'"[^"\n]*"', "", melody)
        out.extend(("V: Ins", melody))
        moved += 1
        index += 4
    if not moved:
        raise ValueError("No melody bars in SheetSage2 ABC")
    result = "\n".join(header + out) + "\n"
    if "Vocal" in result or (not keep_chords and any('"' in line for line in out)):
        raise ValueError("Vocal label or unexpected chord survived conversion")
    return result


def notes_to_abc(notes: list[tuple[float, float, int]], duration: float) -> str:
    """Make a coarse instrumental ABC solely for YuE when SheetSage has no notes."""
    if not notes:
        raise ValueError("ABC requires notes")
    unit = .0625  # A 1/32 ABC note at 120 quarter-notes per minute.
    events = []
    previous = 0
    for start, end, pitch in sorted(notes):
        first = max(previous, round(start / unit))
        last = max(first + 1, round(end / unit))
        events.append((first, last, pitch))
        previous = last
    total = max(32, round(duration / unit), events[-1][1])
    names = ("C", "^C", "D", "^D", "E", "F", "^F", "G", "^G", "A", "^A", "B")

    def name(pitch: int) -> str:
        octave = pitch // 12 - 1
        base = names[pitch % 12]
        if octave >= 5:
            return base.lower() + "'" * (octave - 5)
        return base + "," * (4 - octave)

    body = []
    cursor = 0
    for first, last, pitch in events:
        for event_start, event_end, symbol in ((cursor, first, "z"),
                                               (first, last, name(pitch))):
            while event_start < event_end:
                chunk = min(event_end - event_start, 32 - event_start % 32)
                more = symbol != "z" and event_start + chunk < event_end
                body.append(f"{symbol}{chunk}{'-' if more else ''}")
                event_start += chunk
                if event_start % 32 == 0:
                    body.append("|")
        cursor = last
    while cursor < total:
        chunk = min(total - cursor, 32 - cursor % 32)
        body.append(f"z{chunk}")
        cursor += chunk
        if cursor % 32 == 0:
            body.append("|")
    if body[-1] != "|":
        body.append("|")
    return ('X:1\nT:Hum F0 fallback\nM:4/4\nL:1/32\nQ:1/4=120\n'
            'V: Ins clef=treble name="Instrumental Melody" snm="Ins"\n'
            'K:C\nV: Ins\n' + " ".join(body) + "\n")


def validate_duration(seconds: float) -> None:
    if not 0.4 <= seconds <= MAX_SECONDS:
        raise ValueError(f"Нужен напев от 0,4 до {MAX_SECONDS:g} секунд; сейчас {seconds:.1f} с.")