PiCoGen / sheetsage /infer.py
Vansh Chugh
vendor jukebox+sheetsage source and recovered weights, use_jukebox=True
ff7b988
Raw
History Blame Contribute Delete
38.3 kB
import json
import logging
import pathlib
import tempfile
from enum import Enum
from functools import lru_cache as cache
import numpy as np
import torch
import validators
from scipy.special import softmax
from .align import create_beat_to_time_fn
from .assets import retrieve_asset
from .beat_track import madmom
from .modules import EncOnlyTransducer, IdentityEncoder, TransformerEncoder
from .representations import Handcrafted, Jukebox
from .theory import (
Chord,
Harmony,
KeyChanges,
LeadSheet,
Melody,
MeterChanges,
Note,
TempoChanges,
estimate_key_changes,
)
from .utils import decode_audio, retrieve_audio_bytes
class InputFeats(Enum):
HANDCRAFTED = 0
JUKEBOX = 1
class Task(Enum):
MELODY = 0
HARMONY = 1
class Model(Enum):
LINEAR = 0
TRANSFORMER = 1
class Status(Enum):
FETCHING_AUDIO = 0
DETECTING_BEATS = 1
EXTRACTING_FEATURES = 2
TRANSCRIBING = 3
FORMATTING = 4
DONE = 5
_INPUT_TO_FRAME_RATE = {
InputFeats.HANDCRAFTED: 16000 / 512,
InputFeats.JUKEBOX: 44100 / 128,
}
_INPUT_TO_DIM = {
InputFeats.HANDCRAFTED: 229,
InputFeats.JUKEBOX: 4800,
}
_JUKEBOX_CHUNK_DURATION_EDGE = 23.75
_TERTIARIES_PER_BEAT = 4
_MELODY_PITCH_MIN = 21
_HARMONY_FAMILIES = ["", "m", "m7", "7", "maj7", "sus", "dim", "aug"]
_FAMILY_TO_INTERVALS = {
"": (4, 3),
"m": (3, 4),
"m7": (3, 4, 3),
"7": (4, 3, 3),
"maj7": (4, 3, 4),
"sus": (5, 2),
"dim": (3, 3),
"aug": (4, 4),
}
_TASK_TO_VOCAB_SIZE = {Task.MELODY: 89, Task.HARMONY: 97}
_MAX_TERTIARIES_PER_CHUNK = 384
@cache()
def _init_extractor(input_feats):
if input_feats == InputFeats.HANDCRAFTED:
extractor = Handcrafted()
elif input_feats == InputFeats.JUKEBOX:
extractor = Jukebox()
else:
raise ValueError()
return extractor
@cache()
def _init_model(task, input_feats, model):
if model == Model.LINEAR:
# NOTE: Just need to catalogue these configs / weights
raise NotImplementedError()
asset_prefix = f"SHEETSAGE_V02_{input_feats.name}_{task.name}"
with open(retrieve_asset(f"{asset_prefix}_CFG", log=False), "r") as f:
cfg = json.load(f)
assert cfg["src_max_len"] == _MAX_TERTIARIES_PER_CHUNK
src_dim = _INPUT_TO_DIM[input_feats]
output_dim = _TASK_TO_VOCAB_SIZE[task]
if cfg["model"] == "probe":
raise RuntimeError("Probe model not supported")
model = EncOnlyTransducer(
output_dim,
src_emb_mode="identity",
src_vocab_size=None,
src_dim=src_dim,
src_emb_dim=None,
src_pos_emb=False,
src_dropout_p=0.0,
enc_cls=IdentityEncoder,
enc_kwargs={},
)
elif cfg["model"] == "transformer":
model = EncOnlyTransducer(
output_dim,
src_emb_mode="project",
src_vocab_size=None,
src_dim=src_dim,
src_emb_dim=512,
src_pos_emb="pos_emb" in cfg["hacks"],
src_dropout_p=0.1,
enc_cls=TransformerEncoder,
enc_kwargs={
"model_dim": 512,
"num_heads": 8,
"num_layers": 4 if "4layers" in cfg["hacks"] else 6,
"feedforward_dim": 2048,
"dropout_p": 0.1,
},
)
else:
raise ValueError()
device = torch.device("cpu")
model.to(device)
model.load_state_dict(
torch.load(
retrieve_asset(f"{asset_prefix}_MODEL", log=False),
map_location=device,
weights_only=False,
)
)
model.eval()
return model
def _closest_idx(x, l):
assert len(l) > 0
return int(np.argmin([abs(li - x) for li in l]) + 1e-6)
def _beat_tracking_with_hints(
audio_path_or_bytes,
segment_start_hint,
segment_end_hint,
segment_hints_are_downbeats,
beats_per_measure_hint,
beats_per_minute_hint,
beat_detection_padding,
legacy_behavior,
):
# Decode a segment of the audio
beat_detection_start = 0.0 if segment_start_hint is None else segment_start_hint
beat_detection_start = max(beat_detection_start - beat_detection_padding, 0.0)
beat_detection_end = None if segment_end_hint is None else segment_end_hint
beat_detection_end = (
None
if beat_detection_end is None
else beat_detection_end + beat_detection_padding
)
if legacy_behavior:
l = segment_start_hint - beat_detection_padding
r = segment_start_hint + _JUKEBOX_CHUNK_DURATION_EDGE + beat_detection_padding
sr, audio = decode_audio(audio_path_or_bytes)
audio_duration = audio.shape[0] / sr
l, r = [round(t * sr) for t in (l, r)]
l = max(0, l)
r = min(audio.shape[0], r)
assert r > l
audio = audio[l:r]
else:
sr, audio = decode_audio(
audio_path_or_bytes,
offset=beat_detection_start,
duration=(
None
if beat_detection_end is None
else beat_detection_end - beat_detection_start
),
)
# Run beat detection on segment
first_downbeat_idx, beats_per_measure, beats = madmom(
sr,
audio,
beats_per_bar=(
beats_per_measure_hint if beats_per_measure_hint is not None else [3, 4]
),
beats_per_minute_hint=beats_per_minute_hint,
)
if first_downbeat_idx is None or beats_per_measure is None or len(beats) == 0:
raise ValueError("Audio too short to detect time signature")
assert first_downbeat_idx >= 0 and first_downbeat_idx < beats_per_measure
assert beats_per_measure in [3, 4]
beats = [beat_detection_start + t for t in beats]
downbeats = [
t for i, t in enumerate(beats) if i % beats_per_measure == first_downbeat_idx
]
assert len(beats) > 0
assert len(downbeats) > 0
# Convert beats into tertiary (sixteenth note) timestamps
# NOTE: Yes, this is super ugly, but sometimes you gotta do what you gotta do
beat_to_time_fn = create_beat_to_time_fn(list(range(len(beats))), beats)
tertiaries = np.arange(0, len(beats) - 1 + 1e-6, 1 / _TERTIARIES_PER_BEAT)
assert tertiaries.shape[0] == (len(beats) - 1) * _TERTIARIES_PER_BEAT + 1
tertiaries_centered = tertiaries - (1 / _TERTIARIES_PER_BEAT) / 2
tertiaries_times = beat_to_time_fn(tertiaries_centered)
tertiaries_times = np.maximum(tertiaries_times, 0.0)
tertiaries_times = np.minimum(tertiaries_times, beats[-1])
# Find first downbeat of the song from optional hint
if segment_start_hint is None:
segment_start = downbeats[0]
else:
if segment_hints_are_downbeats:
segment_start = segment_start_hint
else:
segment_start = downbeats[_closest_idx(segment_start_hint, downbeats)]
segment_start_downbeat = _closest_idx(segment_start, beats)
downbeats = [
t
for i, t in enumerate(beats)
if i % beats_per_measure == segment_start_downbeat % beats_per_measure
]
# Find last downbeat of the song from optional hint
if segment_end_hint is None:
segment_end = downbeats[-1]
else:
if segment_hints_are_downbeats:
segment_end = segment_end_hint
else:
segment_end = downbeats[_closest_idx(segment_end_hint, downbeats)]
segment_end_beat = _closest_idx(segment_end, beats)
if segment_end_beat == segment_start_downbeat:
raise ValueError("Specified segment is too short (<1 measure).")
# NOTE on naming conventions: segment_start_downbeat *is* an (internally-consistent)
# downbeat, but segment_end_beat may not be (if segment_hints_are_downbeats is true
# and user specifies an inaccurate timestamp).
if legacy_behavior:
beats = beats[segment_start_downbeat:]
beat_to_time_fn = create_beat_to_time_fn(list(range(len(beats))), beats)
tertiaries = np.arange(0, len(beats) + 1e-6, 1 / _TERTIARIES_PER_BEAT)
assert tertiaries.shape[0] > 0
tertiaries -= (1 / _TERTIARIES_PER_BEAT) / 2
tertiaries_times = beat_to_time_fn(tertiaries)
tertiaries_times = np.maximum(tertiaries_times, 0.0)
tertiaries_times = np.minimum(tertiaries_times, audio_duration)
segment_offset = tertiaries_times[0]
tertiaries_times = [
t
for t in tertiaries_times
if t < segment_offset + _JUKEBOX_CHUNK_DURATION_EDGE
]
segment_duration = tertiaries_times[-1] - segment_offset
tertiaries = (
np.arange(len(tertiaries_times)) * (1 / _TERTIARIES_PER_BEAT)
).tolist()
segment_end_beat = (
segment_start_downbeat + len(tertiaries) / _TERTIARIES_PER_BEAT
)
if abs(segment_end_beat - round(segment_end_beat)) < 1e-6:
segment_end_beat = round(segment_end_beat)
else:
segment_end_beat = int(np.ceil(segment_end_beat) + 1e-6)
tertiaries = np.array(tertiaries)
tertiaries_times = np.array(tertiaries_times)
return (
beats_per_measure,
list(range(len(beats))),
beats,
tertiaries,
tertiaries_times,
segment_start_downbeat,
segment_end_beat,
)
def _beat_parsing_with_hint(
beat_information,
segment_start_hint,
segment_end_hint,
segment_hints_are_downbeats,
beats_per_measure_hint,
beats_per_minute_hint,
beat_detection_padding,
legacy_behavior,
):
beats_times = beat_information["beats"]
beats = np.array((range(len(beats_times))))
beat_to_time_fn = create_beat_to_time_fn(list(range(len(beats_times))), beats_times)
tertiaries = np.arange(0, len(beats_times) - 1 + 1e-6, 1 / _TERTIARIES_PER_BEAT)
assert tertiaries.shape[0] == (len(beats_times) - 1) * _TERTIARIES_PER_BEAT + 1
tertiaries_centered = tertiaries - (1 / _TERTIARIES_PER_BEAT) / 2
tertiaries_times = beat_to_time_fn(tertiaries_centered)
tertiaries_times = np.maximum(tertiaries_times, 0.0)
tertiaries_times = np.minimum(tertiaries_times, beats_times[-1])
# NOTE: tertiaries_times does not include the last beat
downbeats_times = beat_information["downbeats"]
downbeats = [_closest_idx(t, beats_times) for t in downbeats_times]
while downbeats[-1] * _TERTIARIES_PER_BEAT >= len(tertiaries_times):
downbeats.pop()
return beats, downbeats, beats_times, tertiaries, tertiaries_times
def _split_into_chunks(
tertiaries_times,
measures_per_chunk,
beats_per_measure,
segment_start_downbeat,
segment_end_beat,
avoid_chunking_if_possible,
legacy_behavior,
):
chunks = []
if legacy_behavior:
chunk_slice = slice(None, None)
chunk_tertiaries_times = tertiaries_times[chunk_slice]
duration = chunk_tertiaries_times[-1] - chunk_tertiaries_times[0]
assert duration > 0 and duration <= _JUKEBOX_CHUNK_DURATION_EDGE
chunks.append(chunk_slice)
else:
beats_per_chunk = beats_per_measure * measures_per_chunk
if avoid_chunking_if_possible:
chunk_start_tertiary = segment_start_downbeat * _TERTIARIES_PER_BEAT
chunk_end_tertiary = (segment_end_beat * _TERTIARIES_PER_BEAT) + 1
chunk_slice = slice(chunk_start_tertiary, chunk_end_tertiary)
chunk_tertiaries_times = tertiaries_times[chunk_slice]
duration = chunk_tertiaries_times[-1] - chunk_tertiaries_times[0]
if duration <= _JUKEBOX_CHUNK_DURATION_EDGE:
beats_per_chunk = segment_end_beat
for b in range(segment_start_downbeat, segment_end_beat, beats_per_chunk):
chunk_start_tertiary = b * _TERTIARIES_PER_BEAT
chunk_end_tertiary = ((b + beats_per_chunk) * _TERTIARIES_PER_BEAT) + 1
chunk_end_tertiary = min(
chunk_end_tertiary, (segment_end_beat * _TERTIARIES_PER_BEAT) + 1
)
assert chunk_end_tertiary <= tertiaries_times.shape[0]
chunk_slice = slice(chunk_start_tertiary, chunk_end_tertiary)
chunk_tertiaries_times = tertiaries_times[chunk_slice]
duration = chunk_tertiaries_times[-1] - chunk_tertiaries_times[0]
assert duration > 0
if duration > _JUKEBOX_CHUNK_DURATION_EDGE:
raise NotImplementedError(
"Dynamic chunking not implemented. Try halving measures_per_chunk."
)
chunks.append(chunk_slice)
return chunks
def _split_into_chunks_dynamicly(
tertiaries_times,
downbeats,
measures_per_chunk,
segment_start_downbeat,
segment_end_beat,
):
chunks = []
if downbeats[0] != 0: # NOTE: include upbeat
downbeats = [0] + downbeats
chunk_start_tertiary = downbeats[0] * _TERTIARIES_PER_BEAT
accu_duration = 0
accu_num_measures = 0
for i in range(len(downbeats)):
measure_start_tertiary = downbeats[i] * _TERTIARIES_PER_BEAT
if i < len(downbeats) - 1:
measure_end_tertiary = downbeats[i + 1] * _TERTIARIES_PER_BEAT + 1
else:
measure_end_tertiary = len(tertiaries_times)
# if not measure_start_tertiary < measure_end_tertiary:
# continue
assert measure_end_tertiary <= tertiaries_times.shape[0]
measure_slice = slice(measure_start_tertiary, measure_end_tertiary)
measure_tertiaries_times = tertiaries_times[measure_slice]
accu_duration = (
measure_tertiaries_times[-1] - tertiaries_times[chunk_start_tertiary]
)
accu_num_measures += 1
if accu_duration > _JUKEBOX_CHUNK_DURATION_EDGE:
chunks.append(slice(chunk_start_tertiary, measure_start_tertiary + 1))
chunk_start_tertiary = measure_start_tertiary
accu_duration = (
measure_tertiaries_times[-1] - tertiaries_times[chunk_start_tertiary]
)
accu_num_measures = 1
if accu_num_measures >= measures_per_chunk:
chunks.append(slice(chunk_start_tertiary, measure_end_tertiary))
chunk_start_tertiary = measure_end_tertiary - 1
accu_duration = 0
accu_num_measures = 0
if accu_duration > _JUKEBOX_CHUNK_DURATION_EDGE:
raise ValueError(
f"Chunk duration should not exceed {_JUKEBOX_CHUNK_DURATION_EDGE} seconds. Current chunk duration: {accu_duration}."
)
if accu_num_measures > 0:
chunk_end_tertiary = len(tertiaries_times)
if (
chunk_start_tertiary < chunk_end_tertiary - 1
): # NOTE: make sure chunk size > 1
chunks.append(slice(chunk_start_tertiary, chunk_end_tertiary))
assert (
sum([c.stop - c.start for c in chunks])
== len(tertiaries_times) + len(chunks) - 1
)
return chunks
def _extract_features(
audio_path_or_bytes, input_feats, tertiaries_times, chunks_tertiaries, tqdm
):
tertiary_diff_frames = np.diff(tertiaries_times) * _INPUT_TO_FRAME_RATE[input_feats]
if np.any(tertiary_diff_frames.astype(np.int64) == 0):
raise ValueError("Tempo too fast for beat-informed feature resampling")
extractor = _init_extractor(input_feats)
chunks_features = []
with tempfile.NamedTemporaryFile("wb") as f:
if isinstance(audio_path_or_bytes, bytes):
f.write(audio_path_or_bytes)
f.flush()
audio_path = f.name
else:
audio_path = str(audio_path_or_bytes)
for chunk_slice in tqdm(chunks_tertiaries):
chunk_tertiaries_times = tertiaries_times[chunk_slice]
offset = chunk_tertiaries_times[0]
duration = chunk_tertiaries_times[-1] - offset
assert duration <= _JUKEBOX_CHUNK_DURATION_EDGE
fr, feats = extractor(audio_path, offset=offset, duration=duration)
beat_resampled = []
for i in range(chunk_tertiaries_times.shape[0] - 1):
s = int((chunk_tertiaries_times[i] - offset) * fr)
e = int((chunk_tertiaries_times[i + 1] - offset) * fr)
assert e > s
beat_resampled.append(np.mean(feats[s:e], axis=0, keepdims=True))
beat_resampled = np.concatenate(beat_resampled, axis=0)
chunks_features.append(beat_resampled)
# Normalize handcrafted features (after beat resampling)
# NOTE: Normalizing after beat resampling is probably a bug in retrospect, but it's
# what the model expects.
if input_feats == InputFeats.HANDCRAFTED:
moments = np.load(
retrieve_asset(f"SHEETSAGE_V02_{input_feats.name}_MOMENTS", log=False)
)
for chunk in chunks_features:
chunk -= moments[0]
chunk /= moments[1]
return chunks_features
def _transcribe_chunks(chunks_features, input_feats, detect_melody, detect_harmony):
melody_logits = None
melody_last_hidden_state = None
if detect_melody:
melody_model = _init_model(Task.MELODY, input_feats, Model.TRANSFORMER)
melody_logits = []
melody_last_hidden_state = []
harmony_logits = None
harmony_last_hidden_state = None
if detect_harmony:
harmony_model = _init_model(Task.HARMONY, input_feats, Model.TRANSFORMER)
harmony_logits = []
harmony_last_hidden_state = []
if detect_melody or detect_harmony:
device = torch.device("cpu")
with torch.no_grad():
for src in chunks_features:
src_len = src.shape[0]
src = np.pad(src, [(0, _MAX_TERTIARIES_PER_CHUNK - src_len), (0, 0)])
src = src[:, np.newaxis]
src = torch.tensor(src).float()
src_len = torch.tensor(src_len).long().view(-1)
src.to(device)
src_len.to(device)
if detect_melody:
melody_output = melody_model(src, src_len, None, None)
logits, state = (
melody_output["logits"],
melody_output["last_hidden_state"],
)
logits = logits[: src_len.item(), 0]
state = state[: src_len.item(), 0]
melody_logits.append(logits.cpu().numpy())
melody_last_hidden_state.append(state.cpu().numpy())
if detect_harmony:
harmony_output = harmony_model(src, src_len, None, None)
logits, state = (
harmony_output["logits"],
harmony_output["last_hidden_state"],
)
logits = logits[: src_len.item(), 0]
state = state[: src_len.item(), 0]
harmony_logits.append(logits.cpu().numpy())
harmony_last_hidden_state.append(state.cpu().numpy())
total_num_tertiary = sum([c.shape[0] for c in chunks_features])
if detect_melody:
assert sum([c.shape[0] for c in melody_logits]) == total_num_tertiary
if detect_harmony:
assert sum([c.shape[0] for c in harmony_logits]) == total_num_tertiary
melody_last_hidden_state = np.concatenate(melody_last_hidden_state, axis=0)
harmony_last_hidden_state = np.concatenate(harmony_last_hidden_state, axis=0)
assert len(melody_last_hidden_state.shape) == 2
assert len(harmony_last_hidden_state.shape) == 2
return (
melody_logits,
harmony_logits,
melody_last_hidden_state,
harmony_last_hidden_state,
)
def _format_lead_sheet(
melody_logits,
harmony_logits,
beats_per_measure,
beats,
beats_times,
segment_start_downbeat,
segment_end_beat,
total_num_tertiary,
melody_threshold=None,
harmony_threshold=None,
):
def decode(logits, threshold=None):
if threshold is None:
preds = np.argmax(logits, axis=-1)
else:
probs_nonnull = 1 - softmax(logits, axis=-1)[:, 0]
preds_nonnull = 1 + np.argmax(logits[:, 1:], axis=-1)
preds = np.where(probs_nonnull >= threshold, preds_nonnull, 0)
return preds
# Decode melody
if melody_logits is None:
melody = Melody()
else:
melody_logits = np.concatenate(melody_logits, axis=0)
assert melody_logits.shape[0] == total_num_tertiary
melody_preds = decode(melody_logits, threshold=melody_threshold)
melody_onsets = []
for o, p in enumerate(melody_preds):
if p != 0:
assert p >= 1
p -= 1
p = (p + _MELODY_PITCH_MIN).tolist()
melody_onsets.append((o, Note(p % 12, p // 12)))
melody = []
for i, (o, n) in enumerate(melody_onsets):
if i + 1 < len(melody_onsets):
d = melody_onsets[i + 1][0] - o
else:
d = total_num_tertiary - o
melody.append((o, d, n))
melody = Melody(*melody)
# Decode harmony
if harmony_logits is None:
harmony = Harmony()
else:
harmony_logits = np.concatenate(harmony_logits, axis=0)
assert harmony_logits.shape[0] == total_num_tertiary
harmony_preds = decode(harmony_logits, threshold=harmony_threshold)
harmony = []
last_chord = None
for o, c in enumerate(harmony_preds):
if c != 0:
assert c >= 1
c -= 1
c = c.tolist()
c = (
c // len(_HARMONY_FAMILIES),
_HARMONY_FAMILIES[c % len(_HARMONY_FAMILIES)],
)
chord = Chord(c[0], _FAMILY_TO_INTERVALS[c[1]])
if chord != last_chord:
harmony.append((o, chord))
last_chord = chord
harmony = Harmony(*harmony)
# Extract tempo
measures_bps = []
for b in range(segment_start_downbeat, segment_end_beat, beats_per_measure):
m0_time = beats_times[b]
try:
mp1_time = beats_times[b + beats_per_measure]
except IndexError:
break
assert mp1_time >= m0_time
if mp1_time > m0_time:
bps = beats_per_measure / (mp1_time - m0_time)
measures_bps.append(bps)
if len(measures_bps) > 0:
beats_per_second = np.median(measures_bps)
else:
beats_per_second = 2
meter_changes = MeterChanges((0, (beats_per_measure, 2, 2)))
tempo_changes = TempoChanges((0, (round(beats_per_second * 60),)))
try:
key_changes = estimate_key_changes(meter_changes, harmony, melody)
except:
# NOTE: C major by default
key_changes = KeyChanges((0, (0, (2, 2, 1, 2, 2, 2))))
lead_sheet = LeadSheet(
meter_changes, tempo_changes, key_changes, harmony, melody, total_num_tertiary
)
assert beats[0] == 0
segment_beats = [b - segment_start_downbeat for b in beats]
return lead_sheet, segment_beats, beats_times
@torch.no_grad()
def sheetsage(
audio_path_bytes_or_url,
segment_start_hint=None,
segment_end_hint=None,
use_jukebox=False,
measures_per_chunk=8,
dynamic_chunking=False,
segment_hints_are_downbeats=False,
beat_information=None,
beats_per_measure_hint=None,
beats_per_minute_hint=None,
detect_melody=True,
detect_harmony=True,
melody_threshold=None,
harmony_threshold=None,
beat_detection_padding=15.0,
avoid_chunking_if_possible=True,
legacy_behavior=False,
status_change_callback=lambda s: logging.info(s.name),
return_intermediaries=False,
tqdm=lambda x: x,
):
"""Main driver function for Sheet Sage: music audio -> lead sheet.
Parameters
----------
audio_path_bytes_or_url : :class:`pathlib.Path`, bytes, or str
The filepath, raw bytes, or string URL of the audio to transcribe.
segment_start_hint : float or None
Approximate timestamp of start downbeat (to transcribe a segment of the audio).
segment_end_hint : float or None
Approximate timestamp of end downbeat (to transcribe a segment of the audio).
use_jukebox : bool
If True, improves transcription quality by using OpenAI Jukebox (requires GPU w/
>=12GB VRAM).
measures_per_chunk : int
The number of measures which Sheet Sage transcribes at a time (for best results,
set to phrase length).
segment_hints_are_downbeats: bool
If True, overrides downbeat detection using the specified segment hints (note
that the hints must be *very* precise for this to work as intended).
beats_per_measure_hint : int or None
If specified, overrides time signature detection (4 for "4/4" or 3 for "3/4").
beats_per_minute_hint : int or None
If specified, helps the beat detector find the right tempo. Useful if detected
tempo is a factor of 2 off from real tempo.
detect_melody : bool
If False, skips melody transcription.
detect_harmony : bool
If False, skips chord recognition.
melody_threshold : float
If specified, overrides default melody threshold (0-1, lower for more notes.)
harmony_threshold : float
If specified, overrides default harmony threshold (0-1, lower for more chords.)
beat_detection_padding : float
Amount of audio padding to use when running beat detection on segment.
avoid_chunking_if_possible : bool
If False, uses chunking even for segments shorter than training length.
legacy_behavior : bool
If True, ignores segment_end_hint and transcribes exactly one max-length chunk.
status_change_callback : Callable
If specified, calls this method upon changes in `Status`.
return_intermediaries : bool
If True, returns intermediate high-level results.
Returns
-------
:class:`sheetsage.LeadSheet`
Pass
Callable[float, float]
Metronome function for converting beat values to timestamps
"""
# Check mpi4py is installed manually
try:
from mpi4py import MPI
except ModuleNotFoundError:
raise ModuleNotFoundError(
"Please install mpi4py to use SheetSage. "
"You can install it via 'conda install mpi4py'."
)
if return_intermediaries:
logging.warning(
"Returning intermediate results is deprecated and will be removed in a future release."
)
# Check values
if segment_start_hint is not None and segment_start_hint < 0:
raise ValueError("Segment start hint cannot be negative")
if segment_end_hint is not None and segment_end_hint < 0:
raise ValueError("Segment end hint cannot be negative")
if (
segment_start_hint is not None
and segment_end_hint is not None
and segment_end_hint <= segment_start_hint
):
raise ValueError("Segment end hint should be greater than start hint")
if measures_per_chunk <= 0:
raise ValueError("Invalid measures per chunk specified")
if measures_per_chunk > 24:
# TODO: Allow 32 if time signature is 3/4??
raise ValueError("Sheet Sage can only transcribe 24 measures per chunk")
if beats_per_measure_hint is not None and beats_per_measure_hint not in [3, 4]:
raise ValueError(
"Currently, Sheet Sage only supports 4/4 and 3/4 time signatures"
)
if beat_detection_padding < 0:
raise ValueError("Beat detection padding cannot be negative")
input_feats = InputFeats.JUKEBOX if use_jukebox else InputFeats.HANDCRAFTED
# Disambiguate between URL and file path for string inputs and retrieve URL
audio_path_or_bytes = audio_path_bytes_or_url
if isinstance(audio_path_bytes_or_url, str):
if validators.url(audio_path_bytes_or_url):
status_change_callback(Status.FETCHING_AUDIO)
logging.info(f"Retrieving audio from {audio_path_bytes_or_url}")
audio_path_or_bytes = retrieve_audio_bytes(audio_path_bytes_or_url)
else:
logging.info(f"Loading audio from {audio_path_bytes_or_url}")
audio_path_or_bytes = pathlib.Path(audio_path_bytes_or_url).resolve()
if (
isinstance(audio_path_or_bytes, pathlib.Path)
and not audio_path_or_bytes.exists()
):
raise FileNotFoundError(audio_path_or_bytes)
# NOTE: If beat information is not provided, run beat detection (madmom)
if beat_information is not None:
status_change_callback(Status.DETECTING_BEATS)
(
beats,
downbeats,
beats_times,
tertiaries,
tertiaries_times,
) = _beat_parsing_with_hint(
beat_information,
segment_start_hint,
segment_end_hint,
segment_hints_are_downbeats,
beats_per_measure_hint,
beats_per_minute_hint,
beat_detection_padding,
legacy_behavior,
)
beats_per_measure = None
segment_start_downbeat = None
segment_end_beat = None
else:
# TODO: Implement original beat detection
raise NotImplementedError("We haven't implemented this yet.")
# Run beat detection
status_change_callback(Status.DETECTING_BEATS)
(
beats_per_measure,
beats,
beats_times,
tertiaries,
tertiaries_times,
segment_start_downbeat,
segment_end_beat,
) = _beat_tracking_with_hints(
audio_path_or_bytes,
segment_start_hint,
segment_end_hint,
segment_hints_are_downbeats,
beats_per_measure_hint,
beats_per_minute_hint,
beat_detection_padding,
legacy_behavior,
)
# Identify suitable chunks for running through transcription model
if dynamic_chunking:
chunks_tertiaries = _split_into_chunks_dynamicly(
tertiaries_times,
downbeats,
measures_per_chunk,
segment_start_downbeat,
segment_end_beat,
)
else:
# TODO: Implement original chunking function
raise NotImplementedError("We only support dynamic chunking for now.")
chunks_tertiaries = _split_into_chunks(
tertiaries_times,
measures_per_chunk,
beats_per_measure,
segment_start_downbeat,
segment_end_beat,
avoid_chunking_if_possible,
legacy_behavior,
)
# Extract features
status_change_callback(Status.EXTRACTING_FEATURES)
if use_jukebox:
logging.info("Feature extraction w/ Jukebox could take several minutes.")
chunks_features = _extract_features(
audio_path_or_bytes, input_feats, tertiaries_times, chunks_tertiaries, tqdm
)
# Transcribe chunks
status_change_callback(Status.TRANSCRIBING)
(
melody_logits,
harmony_logits,
melody_last_hidden_state,
harmony_last_hidden_state,
) = _transcribe_chunks(chunks_features, input_feats, detect_melody, detect_harmony)
# Create lead sheet
if dynamic_chunking:
logging.warning(
"Format lead sheet for dynamic chunking is not implemented currently."
)
lead_sheet = None
segment_beats = None
segment_beats_times = None
beats_per_measure = downbeats[1] - downbeats[0] # TODO: It's just a workaround
segment_start_downbeat = downbeats[0]
segment_end_beat = downbeats[-1]
else:
# TODO: Implement original behavior for dynamic chunking
status_change_callback(Status.FORMATTING)
total_num_tertiary = sum([c.shape[0] for c in chunks_features])
lead_sheet, segment_beats, segment_beats_times = _format_lead_sheet(
melody_logits,
harmony_logits,
beats_per_measure,
beats,
beats_times,
segment_start_downbeat,
segment_end_beat,
total_num_tertiary,
melody_threshold=melody_threshold,
harmony_threshold=harmony_threshold,
)
status_change_callback(Status.DONE)
return dict(
lead_sheet=lead_sheet,
segment_beats=segment_beats,
segment_beats_times=segment_beats_times,
chunks_tertiaries=chunks_tertiaries,
melody_logits=melody_logits,
harmony_logits=harmony_logits,
melody_last_hidden_state=melody_last_hidden_state,
harmony_last_hidden_state=harmony_last_hidden_state,
)
if __name__ == "__main__":
import pathlib
import uuid
from argparse import ArgumentParser
from tqdm import tqdm
from .utils import engrave
parser = ArgumentParser()
parser.add_argument(
"audio_path_or_url",
type=str,
help="The filepath or URL of the audio to transcribe.",
)
parser.add_argument(
"-s",
"--segment_start_hint",
type=float,
help="Approximate timestamp of start downbeat (to transcribe a segment of the audio).",
)
parser.add_argument(
"-e",
"--segment_end_hint",
type=float,
help="Approximate timestamp of end downbeat (to transcribe a segment of the audio).",
)
parser.add_argument(
"-t",
"--title",
type=str,
help="Title of the song.",
)
parser.add_argument(
"-a",
"--artist",
type=str,
help="Name of the artist or composer.",
)
parser.add_argument(
"-o",
"--output_dir",
type=str,
help="Directory to save the output files (lead sheet PDF, synchronized MIDI, etc.).",
)
parser.add_argument(
"-j",
"--use_jukebox",
action="store_true",
help="If set, improves transcription quality by using OpenAI Jukebox (requires GPU w/ >=12GB VRAM).",
)
parser.add_argument(
"--measures_per_chunk",
type=int,
help="The number of measures which Sheet Sage transcribes at a time (for best results, set to phrase length).",
)
parser.add_argument(
"--segment_hints_are_downbeats",
action="store_true",
help="If set, overrides downbeat detection using the specified segment hints (note that the hints must be *very* precise for this to work as intended).",
)
parser.add_argument(
"--beats_per_measure",
type=int,
choices=[3, 4],
help="If specified, overrides time signature detection (4 for '4/4' or 3 for '3/4').",
)
parser.add_argument(
"--beats_per_minute_hint",
type=int,
help="If specified, helps the beat detector find the right tempo. Useful if detected tempo is a factor of 2 off from real tempo.",
)
parser.add_argument(
"--melody_threshold",
type=float,
help="If specified, overrides default melody threshold (0-1, lower for more notes.)",
)
parser.add_argument(
"--harmony_threshold",
type=float,
help="If specified, overrides default harmony threshold (0-1, lower for more chords.)",
)
parser.add_argument(
"--skip_melody",
action="store_false",
dest="detect_melody",
help="If set, skips melody transcription.",
)
parser.add_argument(
"--skip_harmony",
action="store_false",
dest="detect_harmony",
help="If set, skips chord recognition.",
)
parser.add_argument(
"--legacy_behavior",
action="store_true",
dest="legacy_behavior",
help="If set, ignores segment_end_hint and transcribes exactly one max-length chunk.",
)
parser.set_defaults(
segment_start_hint=None,
segment_end_hint=None,
title=None,
artist=None,
output_dir="./output",
use_jukebox=False,
measures_per_chunk=8,
segment_hints_are_downbeats=False,
beats_per_measure=None,
beats_per_minute_hint=None,
melody_threshold=None,
harmony_threshold=None,
detect_melody=True,
detect_harmony=True,
legacy_behavior=False,
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
lead_sheet, segment_beats, segment_beats_times = sheetsage(
args.audio_path_or_url,
segment_start_hint=args.segment_start_hint,
segment_end_hint=args.segment_end_hint,
use_jukebox=args.use_jukebox,
measures_per_chunk=args.measures_per_chunk,
segment_hints_are_downbeats=args.segment_hints_are_downbeats,
beats_per_measure_hint=args.beats_per_measure,
beats_per_minute_hint=args.beats_per_minute_hint,
detect_melody=args.detect_melody,
detect_harmony=args.detect_harmony,
melody_threshold=args.melody_threshold,
harmony_threshold=args.harmony_threshold,
legacy_behavior=args.legacy_behavior,
tqdm=tqdm,
)
# Create output directory
output_dir = pathlib.Path(args.output_dir).resolve()
if output_dir == pathlib.Path("./output").resolve():
uuid = uuid.uuid4().hex
output_dir = pathlib.Path(output_dir, uuid)
logging.info(f"Writing to {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
# Write lead sheet
lily = lead_sheet.as_lily(artist=args.artist, title=args.title)
with open(pathlib.Path(output_dir, "output.ly"), "w") as f:
f.write(lily)
with open(pathlib.Path(output_dir, "output.pdf"), "wb") as f:
f.write(
engrave(
lily, out_format="pdf", transparent=False, trim=False, hide_footer=False
)
)
# Write MIDI
with open(pathlib.Path(output_dir, "output.midi"), "wb") as f:
f.write(
lead_sheet.as_midi(
pulse_to_time_fn=create_beat_to_time_fn(
segment_beats, segment_beats_times
)
)
)