Spaces:
Running on Zero
Running on Zero
| 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} | |
| """ | |
| 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 | |