Files
smthemex-ComfyUI_YuE/SheetSage2/tokenization_sheetsage2.py
T
2026-09-15 11:04:52 +08:00

733 lines
29 KiB
Python

import re
import hashlib
import json
import mir_eval.chord
import numpy as np
from .durations_sheetsage2 import DURATION_TEMPLATES, duration_boundaries
from .labels_sheetsage2 import STRUCTURE_LABELS
from .schema_sheetsage2 import get_prompt_multitask_schema
PROMPT_ORDER = tuple(task.name for task in get_prompt_multitask_schema("v1").tasks)
CHROMATIC_SHARPS = (
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
)
FULL_CHORD_QUALITIES = (
"maj",
"min",
"dim",
"aug",
"maj7",
"min7",
"7",
"hdim7",
"dim7",
"minmaj7",
"sus2",
"sus4",
"sus4(b7)",
"maj6",
"min6",
)
FULL_CHORD_INVERSIONS = {
"maj": ("/2", "/3", "/5"),
"min": ("/2", "/b3", "/5"),
"maj7": ("/3", "/5", "/7"),
"min7": ("/b3", "/5", "/b7"),
"7": ("/3", "/5", "/b7"),
}
def build_full_chord_vocabulary():
labels = ["N"]
for quality in FULL_CHORD_QUALITIES:
inversions = FULL_CHORD_INVERSIONS.get(quality, ())
for root in CHROMATIC_SHARPS:
for inversion in (*inversions, ""):
labels.append(f"{root}:{quality}{inversion}")
return tuple(labels)
FULL_CHORD_VOCABULARY = build_full_chord_vocabulary()
class SheetSage2Tokenizer:
"""Typed prompt and event vocabulary for prompt-conditioned transcription."""
meter_numerators = tuple(range(1, 33))
meter_denominators = (1, 2, 4, 8, 16, 32)
n_eighth_positions = 256
max_subbeat_shift = 256
prompt_capacity = 256
def __init__(
self,
audio_length_seconds=300.0,
time_hz=100,
schema_version="v1",
expected_fingerprint=None,
):
self.audio_length_seconds = float(audio_length_seconds)
self.time_hz = int(time_hz)
self.schema = get_prompt_multitask_schema(schema_version)
self.schema_version = self.schema.version
self.task_specs = self.schema.tasks
self.event_field_order = self.schema.event_field_order
self.n_time_tokens = int(round(self.audio_length_seconds * self.time_hz))
if self.n_time_tokens <= 0:
raise ValueError("audio_length_seconds must produce at least one time token")
self.pad_token = 0
self.sos_token = 1
self.bos_token = self.sos_token
self.eos_token = 2
self.out_token = 3
self.prompt_token_start = 4
self.prompt_names = tuple(task.name for task in self.task_specs)
if len(self.prompt_names) > self.prompt_capacity:
raise ValueError(
f"Schema {self.schema_version} has {len(self.prompt_names)} prompts, "
f"exceeding immutable capacity {self.prompt_capacity}"
)
self.prompt_to_id = {
name: self.prompt_token_start + index
for index, name in enumerate(self.prompt_names)
}
self.prompt_token_end = self.prompt_token_start + self.prompt_capacity
self.subbeat_shift_token_start = self.prompt_token_end
self.n_subbeat_shift_tokens = self.max_subbeat_shift + 1
self.subbeat_shift_token_end = (
self.subbeat_shift_token_start + self.n_subbeat_shift_tokens
)
self.time_token_start = self.subbeat_shift_token_end
self.time_token_end = self.time_token_start + self.n_time_tokens
self.meter_pairs = tuple(
(numerator, denominator)
for numerator in self.meter_numerators
for denominator in self.meter_denominators
)
self.meter_to_id = {meter: index for index, meter in enumerate(self.meter_pairs)}
self.meter_token_start = self.time_token_end
self.meter_token_end = self.meter_token_start + len(self.meter_pairs)
self.eighth_position_token_start = self.meter_token_end
self.eighth_position_token_end = (
self.eighth_position_token_start + self.n_eighth_positions
)
self.structure_labels = tuple(STRUCTURE_LABELS)
self.structure_token_start = self.eighth_position_token_end
self.structure_token_end = self.structure_token_start + len(self.structure_labels)
self.key_token_start = self.structure_token_end
self.n_key_tokens = 24
self.key_token_end = self.key_token_start + self.n_key_tokens
self.majmin_chord_labels = (
"N",
*(f"{root}:maj" for root in CHROMATIC_SHARPS),
*(f"{root}:min" for root in CHROMATIC_SHARPS),
)
self.majmin_chord_token_start = self.key_token_end
self.majmin_chord_token_end = (
self.majmin_chord_token_start + len(self.majmin_chord_labels)
)
self.full_chord_labels = FULL_CHORD_VOCABULARY
self.full_chord_to_id = {
label: index for index, label in enumerate(self.full_chord_labels)
}
self.full_chord_token_start = self.majmin_chord_token_end
self.full_chord_token_end = (
self.full_chord_token_start + len(self.full_chord_labels)
)
self.pitch_token_start = self.full_chord_token_end
self.n_pitch_tokens = 256
self.pitch_token_end = self.pitch_token_start + self.n_pitch_tokens
self.duration_templates = DURATION_TEMPLATES
self.duration_boundaries = duration_boundaries
self.duration_token_start = self.pitch_token_end
self.n_duration_tokens = len(self.duration_templates)
self.duration_token_end = self.duration_token_start + self.n_duration_tokens
self.appended_token_blocks = {}
next_token = self.duration_token_end
for block in self.schema.appended_token_blocks:
start = next_token
end = start + len(block.labels)
self.appended_token_blocks[block.name] = {
"start": start,
"end": end,
"labels": tuple(block.labels),
"output_field": block.output_field or block.name,
}
next_token = end
self.n_tokens = next_token
self._full_chord_templates = self._build_full_chord_templates()
self.vocab_fingerprint = self._compute_fingerprint()
if (
expected_fingerprint is not None
and str(expected_fingerprint) != self.vocab_fingerprint
):
raise ValueError(
f"Tokenizer fingerprint mismatch for schema {self.schema_version}: "
f"expected {expected_fingerprint}, got {self.vocab_fingerprint}"
)
def _compute_fingerprint(self):
payload = {
"schema_version": self.schema_version,
"audio_length_seconds": self.audio_length_seconds,
"time_hz": self.time_hz,
"prompt_capacity": self.prompt_capacity,
"prompt_names": self.prompt_names,
"event_field_order": self.event_field_order,
"meter_pairs": self.meter_pairs,
"structure_labels": self.structure_labels,
"majmin_chord_labels": self.majmin_chord_labels,
"full_chord_labels": self.full_chord_labels,
"duration_templates": tuple(int(value) for value in self.duration_templates),
"appended_token_blocks": tuple(
(name, block["labels"], block["output_field"])
for name, block in self.appended_token_blocks.items()
),
"n_tokens": self.n_tokens,
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode(
"utf-8"
)
return hashlib.sha256(encoded).hexdigest()[:16]
def get_config(self):
return {
"schema_version": self.schema_version,
"audio_length_seconds": self.audio_length_seconds,
"time_hz": self.time_hz,
"vocab_fingerprint": self.vocab_fingerprint,
"n_tokens": self.n_tokens,
}
def normalize_prompts(self, prompts):
names = []
seen = set()
for prompt in prompts:
name = str(prompt).strip()
if name.startswith("<|") and name.endswith("|>"):
name = name[2:-2]
if name not in self.prompt_to_id:
raise ValueError(f"Unknown prompt: {prompt!r}")
if name not in seen:
names.append(name)
seen.add(name)
names.sort(key=self.prompt_names.index)
selected_groups = {}
task_by_name = {task.name: task for task in self.task_specs}
for name in names:
group = task_by_name[name].sampling_group
previous = selected_groups.get(group)
if previous is not None:
raise ValueError(
f"Prompts {previous!r} and {name!r} are mutually exclusive "
f"within sampling group {group!r}"
)
selected_groups[group] = name
if not names:
raise ValueError("At least one task prompt is required")
return tuple(names)
def prompt_to_token(self, prompt):
return self.prompt_to_id[self.normalize_prompts((prompt,))[0]]
def token_to_prompt(self, token):
token = int(token)
for prompt, prompt_token in self.prompt_to_id.items():
if token == prompt_token:
return prompt
raise ValueError(f"token {token} is not a prompt token")
def prompt_prefix(self, prompts):
prompts = self.normalize_prompts(prompts)
return [
self.sos_token,
*(self.prompt_to_id[prompt] for prompt in prompts),
self.out_token,
]
def subbeat_shift_to_tokens(self, shift):
shift = int(shift)
if shift < 0:
raise ValueError("subbeat shift must be non-negative")
tokens = []
while shift > self.max_subbeat_shift:
tokens.append(self.subbeat_shift_token_start + self.max_subbeat_shift)
shift -= self.max_subbeat_shift
tokens.append(self.subbeat_shift_token_start + shift)
return tokens
def token_to_subbeat_shift(self, token):
token = int(token)
if not self.subbeat_shift_token_start <= token < self.subbeat_shift_token_end:
raise ValueError(f"token {token} is not a subbeat shift token")
return token - self.subbeat_shift_token_start
def time_id_to_token(self, time_id):
time_id = int(time_id)
if not 0 <= time_id < self.n_time_tokens:
raise ValueError(f"time id {time_id} is outside [0, {self.n_time_tokens})")
return self.time_token_start + time_id
def token_to_time_id(self, token):
token = int(token)
if not self.time_token_start <= token < self.time_token_end:
raise ValueError(f"token {token} is not a time token")
return token - self.time_token_start
def meter_to_token(self, numerator, denominator):
meter = (int(numerator), int(denominator))
if meter not in self.meter_to_id:
raise ValueError(f"Unsupported meter {meter[0]}/{meter[1]}")
return self.meter_token_start + self.meter_to_id[meter]
def eighth_position_to_token(self, position):
position = int(position)
if not 0 <= position < self.n_eighth_positions:
raise ValueError(
f"eighth-note position {position} is outside [0, {self.n_eighth_positions})"
)
return self.eighth_position_token_start + position
def structure_to_token(self, label):
label = str(label)
if label not in self.structure_labels:
raise ValueError(f"Unknown structure label: {label!r}")
return self.structure_token_start + self.structure_labels.index(label)
def key_to_token(self, key_label, pitch_shift=0):
tonic, mode = str(key_label).split(":", 1)
tonic_id = mir_eval.chord.pitch_class_to_semitone(tonic)
if tonic_id < 0:
raise ValueError(f"Invalid key tonic: {key_label!r}")
tonic_id = (int(tonic_id) + int(pitch_shift)) % 12
minor_modes = {"minor", "dorian", "phrygian", "locrian"}
mode_id = 1 if mode.lower() in minor_modes else 0
return self.key_token_start + mode_id * 12 + tonic_id
@staticmethod
def _canonical_chord_parts(chord_label, pitch_shift=0):
chord_label = str(chord_label).strip()
if chord_label in {"N", "X", ""}:
return "N", None, None
root, suffix = chord_label.split(":", 1)
root_id = mir_eval.chord.pitch_class_to_semitone(root)
if root_id < 0:
return "N", None, None
root_id = (int(root_id) + int(pitch_shift)) % 12
canonical = f"{CHROMATIC_SHARPS[root_id]}:{suffix}"
quality = re.split(r"/", suffix, maxsplit=1)[0]
return canonical, root_id, quality
def chord_majmin_to_token(self, chord_label, pitch_shift=0):
canonical, root_id, quality = self._canonical_chord_parts(
chord_label,
pitch_shift,
)
if canonical == "N":
return self.majmin_chord_token_start
try:
original_root, chroma, _ = mir_eval.chord.encode(str(chord_label))
relative = mir_eval.chord.rotate_bitmap_to_root(chroma, original_root)
has_minor_third = bool(relative[3] > 0)
has_major_third = bool(relative[4] > 0)
except Exception:
has_minor_third = quality.startswith(("min", "dim", "hdim"))
has_major_third = not has_minor_third
if has_major_third and not has_minor_third:
quality_id = 0
elif has_minor_third and not has_major_third:
quality_id = 1
elif quality.startswith(("min", "dim", "hdim")):
quality_id = 1
elif has_major_third:
quality_id = 0
else:
return self.majmin_chord_token_start
return self.majmin_chord_token_start + 1 + quality_id * 12 + root_id
@staticmethod
def _chord_template(chord_label):
root, chroma, bass = mir_eval.chord.encode(chord_label)
root_chroma = np.zeros(12, dtype=np.float32)
root_chroma[root] = 1.0
relative_chroma = mir_eval.chord.rotate_bitmap_to_root(chroma, root).astype(
np.float32
)
bass_chroma = np.zeros(12, dtype=np.float32)
bass_chroma[(bass + root) % 12] = 1.0
return np.concatenate([root_chroma, relative_chroma, bass_chroma])
def _build_full_chord_templates(self):
templates = [np.zeros(36, dtype=np.float32)]
templates.extend(
self._chord_template(label) for label in self.full_chord_labels[1:]
)
return np.stack(templates)
def chord_full_to_token(self, chord_label, pitch_shift=0):
canonical, _root_id, _quality = self._canonical_chord_parts(
chord_label,
pitch_shift,
)
chord_id = self.full_chord_to_id.get(canonical)
if chord_id is None:
if canonical == "N":
chord_id = 0
else:
try:
target = self._chord_template(canonical)
distances = np.abs(self._full_chord_templates - target[None]).sum(
axis=1
)
chord_id = int(np.argmin(distances))
except Exception:
chord_id = 0
return self.full_chord_token_start + chord_id
def pitch_to_token(self, pitch, track=0, full_melody=False):
pitch = int(pitch)
track = int(track)
if not 0 <= pitch < 128:
raise ValueError(f"MIDI pitch {pitch} is outside [0, 128)")
if track not in (0, 1):
raise ValueError(f"melody track {track} is outside [0, 2)")
pitch_id = pitch + (128 if full_melody and track == 1 else 0)
return self.pitch_token_start + pitch_id
def duration_bin_to_token(self, duration_bin):
duration_bin = int(duration_bin)
if not 0 <= duration_bin < self.n_duration_tokens:
raise ValueError(
f"duration bin {duration_bin} is outside [0, {self.n_duration_tokens})"
)
return self.duration_token_start + duration_bin
def token_to_duration_bin(self, token):
token = int(token)
if not self.duration_token_start <= token < self.duration_token_end:
raise ValueError(f"token {token} is not a duration token")
return token - self.duration_token_start
def appended_label_to_token(self, block_name, label):
block = self.appended_token_blocks.get(str(block_name))
if block is None:
raise ValueError(f"unknown appended token block: {block_name!r}")
try:
index = block["labels"].index(str(label))
except ValueError as exc:
raise ValueError(
f"unknown label {label!r} for appended block {block_name!r}"
) from exc
return block["start"] + index
def token_to_appended_label(self, token):
token = int(token)
token_type = self.token_type(token)
block = self.appended_token_blocks.get(token_type)
if block is None:
raise ValueError(f"token {token} is not from an appended token block")
return token_type, block["labels"][token - block["start"]]
def _token_output_field(self, token_type):
built_in = {
"time": "timestamp",
"meter": "rhythm",
"eighth_position": "rhythm",
"structure": "structure",
"key": "key",
"chord_majmin": "chord",
"chord_full": "chord",
"pitch": "melody",
"duration": "melody",
}
if token_type in built_in:
return built_in[token_type]
block = self.appended_token_blocks.get(token_type)
return None if block is None else block["output_field"]
def _decode_field(self, field, field_tokens, prompts):
token_types = [self.token_type(token) for token in field_tokens]
if field == "timestamp":
return self.token_to_time_id(field_tokens[0]) / self.time_hz
if field == "rhythm":
rhythm = {}
for token, token_type in zip(field_tokens, token_types):
if token_type == "meter":
rhythm["meter"] = self.meter_pairs[token - self.meter_token_start]
elif token_type == "eighth_position":
rhythm["eighth_position"] = token - self.eighth_position_token_start
return rhythm
if field == "structure":
return self.structure_labels[field_tokens[0] - self.structure_token_start]
if field == "key":
key_id = field_tokens[0] - self.key_token_start
mode = "minor" if key_id >= 12 else "major"
return f"{CHROMATIC_SHARPS[key_id % 12]}:{mode}"
if field == "chord":
if token_types[0] == "chord_majmin":
return self.majmin_chord_labels[
field_tokens[0] - self.majmin_chord_token_start
]
return self.full_chord_labels[
field_tokens[0] - self.full_chord_token_start
]
if field == "melody":
notes = []
index = 0
while index < len(field_tokens):
pitch_id = field_tokens[index] - self.pitch_token_start
duration_bin = 0
if (
index + 1 < len(field_tokens)
and self.token_type(field_tokens[index + 1]) == "duration"
):
duration_bin = self.token_to_duration_bin(field_tokens[index + 1])
index += 2
else:
index += 1
notes.append(
{
"pitch": pitch_id % 128,
"track": int(pitch_id >= 128),
"duration_bin": duration_bin,
"duration_steps": int(self.duration_templates[duration_bin]),
}
)
return notes
values = []
for token in field_tokens:
block_name, label = self.token_to_appended_label(token)
values.append({"block": block_name, "label": label})
return values[0] if len(values) == 1 else values
def decode_sequence(self, tokens, strict=True):
"""Parse one prompt-conditioned sequence into timed, typed events."""
if hasattr(tokens, "detach"):
tokens = tokens.detach().cpu().tolist()
tokens = [int(token) for token in tokens]
while tokens and tokens[-1] == self.pad_token:
tokens.pop()
if not tokens or tokens[0] != self.sos_token:
raise ValueError("sequence must begin with <|sos|>")
try:
out_index = tokens.index(self.out_token, 1)
except ValueError as exc:
raise ValueError("sequence is missing <|out|>") from exc
prompts = tuple(self.token_to_prompt(token) for token in tokens[1:out_index])
if strict and self.normalize_prompts(prompts) != prompts:
raise ValueError("prompt tokens are not in canonical schema order")
active_fields = {
task.output_field for task in self.task_specs if task.name in prompts
}
events = []
position = out_index + 1
current_step = 0
saw_eos = False
while position < len(tokens):
token = tokens[position]
if token == self.eos_token:
saw_eos = True
position += 1
break
if self.token_type(token) != "subbeat_shift":
raise ValueError(f"event at token index {position} has no subbeat shift")
shift = 0
while (
position < len(tokens)
and self.token_type(tokens[position]) == "subbeat_shift"
):
shift += self.token_to_subbeat_shift(tokens[position])
position += 1
current_step += shift
tokens_by_field = {field: [] for field in self.event_field_order}
while position < len(tokens):
token = tokens[position]
token_type = self.token_type(token)
if token_type == "subbeat_shift" or token == self.eos_token:
break
field = self._token_output_field(token_type)
if field is None or field not in tokens_by_field:
raise ValueError(
f"token {token} ({token_type}) has no field in schema "
f"{self.schema_version}"
)
if strict and field not in active_fields:
raise ValueError(
f"token {token} belongs to inactive output field {field!r}"
)
tokens_by_field[field].append(token)
position += 1
tokens_by_field = {
field: values for field, values in tokens_by_field.items() if values
}
if not tokens_by_field:
if strict:
raise ValueError(f"empty event at subbeat {current_step}")
continue
if strict:
for field, values in tokens_by_field.items():
types = [self.token_type(value) for value in values]
if field == "timestamp" and types != ["time"]:
raise ValueError("timestamp event must contain exactly one time token")
if field == "rhythm":
if types not in (["eighth_position"], ["meter", "eighth_position"]):
raise ValueError(f"invalid rhythm payload: {types}")
if field in {"structure", "key", "chord"} and len(values) != 1:
raise ValueError(f"field {field!r} must contain exactly one token")
if field == "melody":
index = 0
while index < len(types):
if types[index] != "pitch":
raise ValueError(
"melody payload must contain pitch tokens with optional duration"
)
if index + 1 < len(types) and types[index + 1] == "duration":
index += 2
else:
index += 1
events.append(
{
"subbeat": current_step,
"tokens_by_field": tokens_by_field,
"values": {
field: self._decode_field(field, values, prompts)
for field, values in tokens_by_field.items()
},
}
)
if strict and not saw_eos:
raise ValueError("sequence is missing <|eos|>")
if strict and position != len(tokens):
raise ValueError("non-padding tokens follow <|eos|>")
return {
"schema_version": self.schema_version,
"prompts": prompts,
"events": events,
"has_eos": saw_eos,
}
def encode_decoded_sequence(self, decoded):
"""Re-encode the lossless representation returned by decode_sequence."""
prompts = self.normalize_prompts(decoded["prompts"])
output = self.prompt_prefix(prompts)
previous_step = 0
for event in decoded["events"]:
step = int(event["subbeat"])
if step < previous_step:
raise ValueError("events must be sorted by non-decreasing subbeat")
output.extend(self.subbeat_shift_to_tokens(step - previous_step))
previous_step = step
tokens_by_field = event["tokens_by_field"]
for field in self.event_field_order:
output.extend(int(token) for token in tokens_by_field.get(field, ()))
if decoded.get("has_eos", True):
output.append(self.eos_token)
return output
def token_type(self, token):
token = int(token)
for name, start, end in (
("subbeat_shift", self.subbeat_shift_token_start, self.subbeat_shift_token_end),
("time", self.time_token_start, self.time_token_end),
("meter", self.meter_token_start, self.meter_token_end),
("eighth_position", self.eighth_position_token_start, self.eighth_position_token_end),
("structure", self.structure_token_start, self.structure_token_end),
("key", self.key_token_start, self.key_token_end),
("chord_majmin", self.majmin_chord_token_start, self.majmin_chord_token_end),
("chord_full", self.full_chord_token_start, self.full_chord_token_end),
("pitch", self.pitch_token_start, self.pitch_token_end),
("duration", self.duration_token_start, self.duration_token_end),
):
if start <= token < end:
return name
if token in self.prompt_to_id.values():
return "prompt"
for name, block in self.appended_token_blocks.items():
if block["start"] <= token < block["end"]:
return name
if token == self.pad_token:
return "pad"
if token == self.sos_token:
return "sos"
if token == self.eos_token:
return "eos"
if token == self.out_token:
return "out"
raise ValueError(f"token {token} is outside vocabulary size {self.n_tokens}")
def describe(self, token):
token = int(token)
token_type = self.token_type(token)
if token_type == "prompt":
prompt_by_id = {value: key for key, value in self.prompt_to_id.items()}
return f"<|{prompt_by_id[token]}|>"
if token_type == "subbeat_shift":
return f"<subbeat_shift_{token - self.subbeat_shift_token_start}>"
if token_type == "time":
time_id = token - self.time_token_start
return f"<time_{time_id / self.time_hz:.2f}s>"
if token_type == "meter":
numerator, denominator = self.meter_pairs[token - self.meter_token_start]
return f"<meter_{numerator}/{denominator}>"
if token_type == "eighth_position":
return f"<eighth_pos_{token - self.eighth_position_token_start}>"
if token_type == "structure":
return f"<structure_{self.structure_labels[token - self.structure_token_start]}>"
if token_type == "key":
key_id = token - self.key_token_start
mode = "minor" if key_id >= 12 else "major"
return f"<key_{CHROMATIC_SHARPS[key_id % 12]}:{mode}>"
if token_type == "chord_majmin":
return f"<chord_majmin_{self.majmin_chord_labels[token - self.majmin_chord_token_start]}>"
if token_type == "chord_full":
return f"<chord_full_{self.full_chord_labels[token - self.full_chord_token_start]}>"
if token_type == "pitch":
pitch = token - self.pitch_token_start
track = 1 if pitch >= 128 else 0
return f"<pitch_{pitch % 128}_track_{track}>"
if token_type == "duration":
return f"<duration_{token - self.duration_token_start}>"
if token_type in self.appended_token_blocks:
block = self.appended_token_blocks[token_type]
label = block["labels"][token - block["start"]]
return f"<{token_type}_{label}>"
return f"<|{token_type}|>"