PiCoGen / sheetsage /utils.py
Vansh Chugh
vendor jukebox+sheetsage source and recovered weights, use_jukebox=True
ff7b988
Raw
History Blame Contribute Delete
13.3 kB
import gzip
import hashlib
import json
import pathlib
import shlex
import subprocess
import tempfile
import warnings
from functools import lru_cache
from io import BytesIO
import audioread
import librosa
import numpy as np
from PIL import Image
from scipy.io.wavfile import write as wavwrite
def compute_checksum(path_or_bytes, algorithm="sha256", gunzip=False, chunk_size=4096):
"""Computes checksum of target path.
Parameters
----------
path_or_bytes : :class:`pathlib.Path` or bytes
Location or bytes of file to compute checksum for.
algorithm : str, optional
Hash algorithm (from :func:`hashlib.algorithms_available`); default ``sha256``.
gunzip : bool, optional
If true, decompress before computing checksum.
chunk_size : int, optional
Chunk size for iterating through file.
Raises
------
:class:`FileNotFoundError`
Unknown path.
:class:`IsADirectoryError`
Path is a directory.
:class:`ValueError`
Unknown algorithm.
Returns
-------
str
Hex representation of checksum.
"""
if algorithm not in hashlib.algorithms_guaranteed or algorithm.startswith("shake"):
raise ValueError("Unknown algorithm")
computed = hashlib.new(algorithm)
if isinstance(path_or_bytes, bytes):
computed.update(path_or_bytes)
else:
open_fn = gzip.open if gunzip else open
with open_fn(path_or_bytes, "rb") as f:
while True:
data = f.read(chunk_size)
if not data:
break
computed.update(data)
return computed.hexdigest()
def run_cmd_sync(cmd, cwd=None, interactive=False, timeout=None):
"""Runs a console command synchronously and returns the results.
Parameters
----------
cmd : str
The command to execute.
cwd : :class:`pathlib.Path`, optional
The working directory in which to execute the command.
interactive : bool, optional
If set, run command interactively and pipe all output to console.
timeout : float, optional
If specified, kills process and throws error after this many seconds.
Returns
-------
int
Process exit status code.
str, optional
Standard output (if not in interactive mode).
str, optional
Standard error (if not in interactive mode).
Raises
------
:class:`FileNotFoundError`
Unknown command.
:class:`NotADirectoryError`
Specified working directory is not a directory.
:class:`subprocess.TimeoutExpired`
Specified timeout expired.
"""
if cmd is None or len(cmd.strip()) == 0:
raise FileNotFoundError()
kwargs = {}
if not interactive:
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE
err = None
with subprocess.Popen(shlex.split(cmd), cwd=cwd, **kwargs) as p:
try:
p_res = p.communicate(timeout=timeout)
except subprocess.TimeoutExpired as e:
err = e
p.kill()
if err is not None:
raise err
result = p.returncode
if not interactive:
stdout, stderr = [s.decode("utf-8").strip() for s in p_res]
result = (result, stdout, stderr)
return result
_RETRIEVE_AUDIO_CMD_TEMPLATE = """
yt-dlp \
--no-cache-dir \
--no-continue \
--no-playlist \
--format bestaudio/best \
{other} \
{url}
"""
@lru_cache(maxsize=1)
def retrieve_audio_bytes(
url,
return_name=False,
max_filesize_mb=None,
max_duration_seconds=None,
timeout=60.0,
):
"""Retrieves encoded audio (as raw bytes) from specified URL.
Parameters
----------
url: str
The URL to retrieve from.
return_name: bool
If True, return the retrieved file name.
timeout : float
Max amount of time to wait before throwing an error.
Returns
-------
bytes
The raw bytes of the encoded audio file.
str, optional
The name of the encoded audio file, if return_name is True.
Raises
------
:class:`subprocess.TimeoutExpired`
Specified timeout expired.
ValueError
Video too large or too long.
Exception
Error during retrieval.
"""
with tempfile.TemporaryDirectory() as d:
if max_duration_seconds is not None:
status, stdout, stderr = run_cmd_sync(
cmd=_RETRIEVE_AUDIO_CMD_TEMPLATE.format(
url=url.strip(), other="--dump-json"
),
cwd=d,
timeout=timeout,
)
try:
assert status == 0
metadata = json.loads(stdout)
duration = float(metadata["duration"])
except:
raise Exception(f"Failed to retrieve duration from {url}:\n{stderr}")
if duration > max_duration_seconds:
raise ValueError(
f"Specified url is too long ({duration} > {max_duration_seconds})."
)
assert len(list(pathlib.Path(d).iterdir())) == 0
status, stdout, stderr = run_cmd_sync(
cmd=_RETRIEVE_AUDIO_CMD_TEMPLATE.format(
url=url.strip(),
other=""
if max_filesize_mb is None
else f"--max-filesize {max_filesize_mb}m",
),
cwd=d,
timeout=timeout,
)
if max_filesize_mb is not None and "File is larger than max" in stdout:
raise ValueError("Specified url is too large.")
paths = list(pathlib.Path(d).iterdir())
if status != 0 or len(paths) == 0:
raise Exception(f"Failed to retrieve from {url}:\n{stderr}\n{stdout}")
assert len(paths) == 1
path = paths[0]
with open(path, "rb") as f:
audio_bytes = f.read()
if return_name:
result = (audio_bytes, path.name)
else:
result = audio_bytes
return result
def decode_audio(
path_or_bytes,
sr=None,
offset=0.0,
duration=None,
mono=False,
normalize=False,
res_type="kaiser_best",
):
"""Decodes encoded audio from path or raw bytes.
Parameters
----------
path_or_bytes: :class:`pathlib.Path`, str, or bytes
The filepath or raw bytes to decode.
sr: int
If specified, resample audio to this sample rate.
offset: float
Decode audio starting from this timestamp in seconds.
duration: float
Decode at most this many audio in seconds.
mono: bool
If True, average multichannel audio to mono.
normalize: bool
If True, normalize audio to max(abs(audio)) == 1.0.
res_type: str
The resampling algorithm to use (see `librosa.load` documentation).
Returns
-------
int
The sample rate of the decoded audio.
:class:`np.ndarray`
A NumPy array of the audio (shape [nsamps, nch], dtype float32).
Raises
------
:class:`FileNotFoundError`
Unknown file.
:class:`RuntimeError`
Unknown file format.
"""
with tempfile.NamedTemporaryFile("wb") as f:
# NOTE: This could be BytesIO but librosa has buggy support.
if isinstance(path_or_bytes, bytes):
f.write(path_or_bytes)
f.flush()
path = f.name
else:
path = path_or_bytes
# Decode audio file
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
audio, sr = librosa.load(
path,
sr=sr,
mono=mono,
offset=offset,
duration=duration,
res_type=res_type,
)
except audioread.exceptions.NoBackendError as e:
raise RuntimeError("Unknown audio format") from e
# Check output and rearrange to [nsamps, nch]
assert isinstance(sr, int)
assert audio.dtype == np.float32
assert audio.ndim in [1, 2]
if audio.ndim == 1:
audio = audio[np.newaxis, :]
assert audio.shape[0] > 0
audio = np.swapaxes(audio, 0, 1)
audio = np.asfortranarray(audio)
# Normalize
if normalize and audio.shape[0] > 0:
norm_factor = np.abs(audio).max()
if norm_factor > 0:
audio /= norm_factor
return sr, audio
def encode_audio(path, sr, audio, bitexact=False, timeout=60.0):
"""Encodes raw audio array into different file formats using FFmpeg.
Parameters
----------
path: :class:`pathlib.Path`, str
Destination filepath (where the extension determines the codec).
sr: int
The sample rate of the audio.
audio: :class:`np.ndarray`
A NumPy array of the audio (shape [nsamps, nch], dtype float32).
bitexact: bool
If True, ensure reproducible checksums of output file (only on FFmpeg 4+).
timeout: float
Maximum amount of time to wait for encoding.
Raises
:class:`subprocess.TimeoutExpired`
Specified timeout expired.
:class:`Exception`
FFmpeg threw an error while encoding.
"""
with tempfile.NamedTemporaryFile(suffix=".wav") as f:
wavwrite(f.name, sr, audio)
cmd = f"ffmpeg -v error -i {f.name} -y {'-bitexact' if bitexact else ''} {path}"
status, stdout, stderr = run_cmd_sync(cmd, timeout=timeout)
if status != 0:
raise Exception(f"FFmpeg failed: {stderr}")
assert pathlib.Path(path).is_file()
def get_approximate_audio_length(path, timeout=10):
"""Retrieves the approximate length of an audio file."""
status, stdout, stderr = run_cmd_sync(
f"ffprobe -v error -i {path} -show_format -show_streams -print_format json",
timeout=timeout,
)
try:
assert status == 0
assert len(stderr) == 0
except:
raise Exception(f"FFmpeg failed: {stderr}")
d = json.loads(stdout)
duration = float(d["format"]["duration"])
return duration
_LILYPOND_ENGRAVE_TEMPLATE = """
lilypond \
-s \
{args} \
--{out_format} \
-o {out_path} \
{in_path}
""".strip()
def engrave(
lilypond,
out_format="png",
transparent=True,
trim=True,
hide_footer=True,
args=None,
timeout=60,
):
if out_format not in ["png", "pdf"]:
raise ValueError()
if args is not None and not isinstance(args, str):
raise ValueError()
# Adjust lilypond
if hide_footer:
lilypond += "\n\\header { tagline = ##f }"
# Engrave
with tempfile.TemporaryDirectory() as d:
# Create cmd
in_path = pathlib.Path(d, "in.ly")
with open(in_path, "w") as f:
f.write(lilypond)
args = "" if args is None else args
if out_format != "pdf":
args += " -dpixmap-format=pngalpha"
cmd = _LILYPOND_ENGRAVE_TEMPLATE.format(
args=args,
out_format=out_format,
out_path=pathlib.Path(d, "out"),
in_path=in_path,
)
# Run cmd
status, stdout, stderr = run_cmd_sync(cmd, timeout=timeout)
if status != 0:
raise Exception(f"Failed to engrave ({status}): {stderr}")
# Load output pages
out_paths = sorted(
[p for p in pathlib.Path(d).glob(f"out*.{out_format}") if p.is_file()]
)
if len(out_paths) == 0:
raise Exception("No output")
assert len(out_paths) == 1 or out_format == "png"
pages = []
for p in out_paths:
with open(p, "rb") as f:
pages.append(f.read())
# Post processes
if out_format == "pdf":
assert len(out_paths) == 1
result_bytes = pages[0]
else:
def _png_to_image(png_bytes):
return Image.open(BytesIO(png_bytes))
def _image_to_png(im):
bio = BytesIO()
im.save(bio, format="png")
return bio.getvalue()
def _concatenate(pages_bytes):
pages = [_png_to_image(p) for p in pages_bytes]
cat_width = max([p.width for p in pages])
cat_height = sum([p.height for p in pages])
cat = Image.new("RGB", (cat_width, cat_height))
h = 0
for p in pages:
cat.paste(p, (0, h))
h += p.height
return _image_to_png(cat)
def _trim(page_bytes):
im = _png_to_image(page_bytes)
bbox = im.getbbox()
if bbox is not None:
im = im.crop(bbox)
return _image_to_png(im)
def _remove_transparency(page_bytes):
im = _png_to_image(page_bytes).convert("RGBA")
background = Image.new("RGBA", im.size, (255, 255, 255))
im = Image.alpha_composite(background, im)
return _image_to_png(im)
if len(pages) == 1:
result_bytes = pages[0]
else:
result_bytes = _concatenate(pages)
if trim:
result_bytes = _trim(result_bytes)
if not transparent:
result_bytes = _remove_transparency(result_bytes)
return result_bytes