Harden ace step transcription engine to detect and break out of loops. Add melbanroformer to seperate out the vocals and music for better transcriptions. Much better results.

This commit is contained in:
Jaret Burkett committed 2026-09-17 09:49:19 -06:00
1 parent 1db6083645
commit 080873bec9
11 files changed
+1218 -15

No files matched your search

+241 -14
View File
@@ -4,10 +4,13 @@ try:
import librosa
except ImportError:
librosa = None
import threading
import numpy as np
import torch
import torchaudio
from transformers import Qwen2_5OmniForConditionalGeneration, Qwen2_5OmniProcessor
from transformers.generation import LogitsProcessor, LogitsProcessorList
from collections import OrderedDict
import traceback
@@ -33,10 +36,39 @@ CAPTION_PROMPT = "*Task* Describe this music in detail. Include genre, mood, ins
# same budget the Qwen2.5-Omni top-level generate used (thinker_max_new_tokens)
MAX_NEW_TOKENS = 1024
# lyric transcription loops on repeated musical phrases more than captioning
TRANSCRIBER_REPETITION_PENALTY = 1.05
# whisper-style temperature fallback when greedy decode degenerates into a
# token loop; the penalty alone cannot break a confident single-token loop
TRANSCRIBER_RETRIES = (
{"do_sample": True, "temperature": 0.4, "repetition_penalty": 1.20},
{"do_sample": True, "temperature": 0.7, "repetition_penalty": 1.30},
{"do_sample": True, "temperature": 1.0, "repetition_penalty": 1.50},
)
# a loop is a unit of up to LOOP_MAX_PERIOD tokens repeated at least
# LOOP_REPEATS times over a span of at least LOOP_MIN_SPAN tokens; sampled
# decode jitters a token now and then, so the match is a fraction, not exact.
# real outros repeat a line up to ~12 times (~128 tokens); loops never stop
LOOP_MAX_PERIOD = 32
LOOP_REPEATS = 4
LOOP_MIN_SPAN = 160
LOOP_MATCH_FRACTION = 0.9
# on a detected loop the unit's tokens are banned for max(2*period, this) steps
LOOP_BAN_STEPS = 8
# loops broken before decode is forced to stop
LOOP_BREAKS_BEFORE_STOP = 2
# repeats of the looping unit kept when truncating
LOOP_KEEP_REPEATS = 2
CAPTIONER_REPETITION_PENALTY = 1.05
# fixed cache length under compiled decode: the processor caps audio at 300s
# (7500 audio tokens) so prompt + MAX_NEW_TOKENS always fits; a constant keeps
# the static kv cache (and the compiled decode graph) at one shape for every file
STATIC_MAX_LENGTH = 8704
# silence trim on the extracted vocal stem: frames below this fraction of the
# peak frame RMS (-40 dB) at the head and tail are cut, keeping a short margin
SILENCE_FRAME_SECONDS = 0.02
SILENCE_RMS_FRACTION = 0.01
SILENCE_MARGIN_SECONDS = 0.25
# Key profiles for Krumhansl-Schmuckler key detection
MAJOR_PROFILE = np.array(
@@ -132,6 +164,88 @@ from extensions_built_in.llm_models.src.thinker import ( # noqa: E402 shared f
CAPTION_FORMATS = ("ace_step", "yue2")
def find_loop_period(tokens) -> int:
"""Period of a unit repeated across the tail of the token ids, 0 if none."""
arr = np.asarray(tokens)
for period in range(1, LOOP_MAX_PERIOD + 1):
span = max(LOOP_REPEATS * period, LOOP_MIN_SPAN)
if span > arr.shape[0]:
continue
tail = arr[-span:]
if np.mean(tail[period:] == tail[:-period]) >= LOOP_MATCH_FRACTION:
return period
return 0
def find_loop_start(tokens, period: int) -> int:
"""Index where the periodic run at the tail of the token ids begins."""
# a mismatch run longer than this is real content, shorter is jitter
tolerance = max(period, 4)
start = len(tokens) - 1
mismatch_run = 0
while start - period >= 0:
if tokens[start] == tokens[start - period]:
mismatch_run = 0
else:
mismatch_run += 1
if mismatch_run > tolerance:
start += mismatch_run # back out of the real content
break
start -= 1
return start + 1
class LoopBreaker(LogitsProcessor):
"""Breaks token loops inside a single decode: bans the repeating unit's
tokens for a stretch so the model has to move on, and forces EOS once it
has looped LOOP_BREAKS_BEFORE_STOP times. Batch size 1 only."""
def __init__(self, prompt_len: int, eos_ids):
self.prompt_len = prompt_len
self.eos_ids = list(eos_ids)
self.generated: list = []
self.banned: list = []
self.ban_until = 0
self.window_start = 0 # detection only looks at tokens after the last break
self.breaks = 0
self.stopped = False
# first loop: where it began, its period, and where decode resumed after the ban
self.loop_start = 0
self.loop_period = 0
self.loop_resume = 0
def __call__(self, input_ids, scores):
n = input_ids.shape[1] - self.prompt_len
if n > len(self.generated):
self.generated.extend(input_ids[0, self.prompt_len + len(self.generated):].tolist())
if self.stopped:
return self._force_eos(scores)
if n < self.ban_until:
scores[:, self.banned] = float("-inf")
return scores
period = find_loop_period(self.generated[self.window_start:])
if not period:
return scores
self.breaks += 1
if self.breaks >= LOOP_BREAKS_BEFORE_STOP:
self.stopped = True
return self._force_eos(scores)
self.banned = sorted(set(self.generated[-period:]))
self.ban_until = n + max(2 * period, LOOP_BAN_STEPS)
self.window_start = self.ban_until
if self.breaks == 1:
self.loop_start = find_loop_start(self.generated, period)
self.loop_period = period
self.loop_resume = self.ban_until
scores[:, self.banned] = float("-inf")
return scores
def _force_eos(self, scores):
scores[:] = float("-inf")
scores[:, self.eos_ids] = 0.0
return scores
def clean_lyrics(text: str) -> str:
"""Strip the transcriber's '# Languages ... # Lyrics' header, keep the sectioned lyrics."""
if "# Lyrics" in text:
@@ -146,6 +260,11 @@ class AceStepCaptionConfig(CaptionConfig):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.fixed_caption: Optional[str] = kwargs.get("fixed_caption", None)
# transcribe a MelBandRoformer vocal stem instead of the full mix; the
# captioner still hears the full mix
self.extract_vocals_before_transcribe: bool = bool(
kwargs.get("extract_vocals_before_transcribe", False)
)
self.caption_format: str = kwargs.get("caption_format", "ace_step") or "ace_step"
if self.caption_format not in CAPTION_FORMATS:
raise ValueError(
@@ -159,6 +278,9 @@ class AceStepCaptioner(BaseCaptioner):
def __init__(self, process_id: int, job, config: OrderedDict, **kwargs):
super(AceStepCaptioner, self).__init__(process_id, job, config, **kwargs)
self.separator = None
# prep runs in worker threads; one separation on the GPU at a time
self.separator_lock = threading.Lock()
def _load_thinker(self, name_or_path: str, label: str):
"""Load a Qwen2.5-Omni checkpoint and keep only its thinker."""
@@ -190,6 +312,11 @@ class AceStepCaptioner(BaseCaptioner):
self.model2, self.processor2 = self._load_thinker(
self.caption_config.model_name_or_path2, "captioner"
)
if self.caption_config.extract_vocals_before_transcribe:
from toolkit.audio.melbandroformer import load_melbandroformer
self.print_and_status_update("Loading vocal separator")
self.separator = load_melbandroformer(device=self.device_torch)
flush()
def maybe_compile_models(self):
@@ -238,25 +365,59 @@ class AceStepCaptioner(BaseCaptioner):
)
@staticmethod
def _load_audio(file_path: str):
waveform, sr = torchaudio.load(file_path)
def _to_model_audio(waveform: torch.Tensor, sr: int) -> np.ndarray:
"""[C, T] at any rate -> mono float array at TARGET_SAMPLE_RATE."""
if waveform.shape[0] > 1:
waveform = waveform.mean(dim=0, keepdim=True)
if sr != TARGET_SAMPLE_RATE:
waveform = torchaudio.functional.resample(waveform, sr, TARGET_SAMPLE_RATE)
return waveform.squeeze(0).numpy()
@staticmethod
def _load_audio(file_path: str):
waveform, sr = torchaudio.load(file_path)
return AceStepCaptioner._to_model_audio(waveform, sr)
@staticmethod
def trim_silence(audio: np.ndarray, sample_rate: int = TARGET_SAMPLE_RATE) -> np.ndarray:
"""Cut leading and trailing silence, judged by frame RMS against the loudest frame."""
frame = max(1, int(sample_rate * SILENCE_FRAME_SECONDS))
n_frames = len(audio) // frame
if n_frames < 2:
return audio
rms = np.sqrt(np.mean(audio[: n_frames * frame].reshape(n_frames, frame) ** 2, axis=1))
loud = np.flatnonzero(rms >= rms.max() * SILENCE_RMS_FRACTION)
if len(loud) == 0:
return audio
margin = int(sample_rate * SILENCE_MARGIN_SECONDS)
start = max(0, loud[0] * frame - margin)
end = min(len(audio), (loud[-1] + 1) * frame + margin)
return audio[start:end]
def _extract_vocals(self, waveform: torch.Tensor, sr: int) -> np.ndarray:
"""Vocal stem of [C, T] audio as mono TARGET_SAMPLE_RATE, silence-trimmed."""
from toolkit.audio.melbandroformer import separate
with self.separator_lock:
vocals, _ = separate(self.separator, waveform, sr)
return self.trim_silence(self._to_model_audio(vocals.cpu(), sr))
def _prep_file(self, file_path: str) -> dict:
"""CPU side of one file (runs in a worker thread): librosa analysis,
decode + resample, and the mel/tokenize pass for each model."""
audio_data = self._load_audio(file_path)
decode + resample, and the mel/tokenize pass for each model. Vocal
separation is the one GPU step here, serialized by separator_lock."""
waveform, sr = torchaudio.load(file_path)
audio_data = self._to_model_audio(waveform, sr)
lyrics_audio = (
self._extract_vocals(waveform, sr) if self.separator is not None else audio_data
)
item = {
"file": file_path,
# BPM/key/time signature only appear in the ace_step layout
"analysis": analyze_audio(file_path)
if self.caption_config.caption_format == "ace_step"
else None,
"lyrics_inputs": self._make_inputs(self.processor, audio_data, LYRICS_PROMPT),
"lyrics_inputs": self._make_inputs(self.processor, lyrics_audio, LYRICS_PROMPT),
}
if self.model2 is not None:
item["caption_inputs"] = self._make_inputs(
@@ -264,7 +425,16 @@ class AceStepCaptioner(BaseCaptioner):
)
return item
def _generate(self, model, processor, inputs) -> str:
def _generate(
self,
model,
processor,
inputs,
repetition_penalty: float,
retries: tuple = (),
break_loops: bool = False,
file_path: str = "",
) -> str:
inputs = inputs.to(model.device).to(model.dtype)
# a generate that dies between static-cache creation and its first
# forward leaves a half-built cache that breaks every later call
@@ -273,12 +443,14 @@ class AceStepCaptioner(BaseCaptioner):
del model._cache
model._pad_mask_2d = inputs.get("attention_mask", None)
input_len = inputs["input_ids"].shape[1]
gen_kwargs = {"max_new_tokens": MAX_NEW_TOKENS}
# greedy decode loops on repeated phrases without a penalty
gen_kwargs = {"max_new_tokens": MAX_NEW_TOKENS, "repetition_penalty": repetition_penalty}
if model.generation_config.cache_implementation == "static":
if input_len + 16 < STATIC_MAX_LENGTH:
from transformers.generation import MaxLengthCriteria, StoppingCriteriaList
gen_kwargs = {
"repetition_penalty": repetition_penalty,
"max_length": STATIC_MAX_LENGTH,
"stopping_criteria": StoppingCriteriaList(
[MaxLengthCriteria(max_length=min(input_len + MAX_NEW_TOKENS, STATIC_MAX_LENGTH))]
@@ -287,15 +459,62 @@ class AceStepCaptioner(BaseCaptioner):
else:
# prompt would not fit the fixed cache; eager for this file
gen_kwargs["cache_implementation"] = "dynamic"
generated = model.generate(**inputs, **gen_kwargs)
eos_ids = model.generation_config.eos_token_id
eos_ids = set(eos_ids if isinstance(eos_ids, (list, tuple)) else [eos_ids])
def strip_eos(tokens: torch.Tensor) -> torch.Tensor:
end = tokens.shape[0]
while end > 0 and int(tokens[end - 1]) in eos_ids:
end -= 1
return tokens[:end]
def run_pass():
breaker = LoopBreaker(input_len, eos_ids) if break_loops else None
kwargs = dict(gen_kwargs)
if breaker is not None:
kwargs["logits_processor"] = LogitsProcessorList([breaker])
model._pad_mask_2d = inputs.get("attention_mask", None)
generated = model.generate(**inputs, **kwargs)
return strip_eos(generated[0, input_len:]), breaker
new_tokens, breaker = run_pass()
first_tokens, first_breaker = new_tokens, breaker
stopped = breaker is not None and breaker.stopped
for attempt, retry in enumerate(retries, 1):
if not stopped:
break
print(
f"\n[AITK] {file_path}: transcription looped {breaker.breaks} times and was stopped, "
f"retry {attempt}/{len(retries)} with temperature={retry['temperature']} "
f"repetition_penalty={retry['repetition_penalty']}"
)
gen_kwargs.update(retry)
new_tokens, breaker = run_pass()
stopped = breaker.stopped
if stopped:
# every pass re-looped after a break; keep the greedy pass up to its first loop
keep = first_breaker.loop_start + LOOP_KEEP_REPEATS * first_breaker.loop_period
new_tokens = first_tokens[:keep]
print(
f"\n[AITK] {file_path}: transcription looped on every pass; keeping the greedy "
f"pass truncated at its first loop (period {first_breaker.loop_period} tokens)"
)
elif breaker is not None and breaker.breaks:
# drop the excess repeats and the tokens forced out under the ban
keep = breaker.loop_start + LOOP_KEEP_REPEATS * breaker.loop_period
new_tokens = torch.cat([new_tokens[:keep], new_tokens[breaker.loop_resume :]])
print(
f"\n[AITK] {file_path}: broke a transcription loop "
f"(period {breaker.loop_period} tokens) and continued"
)
output = processor.batch_decode(
generated[:, input_len:],
new_tokens[None],
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
return output.strip()
def get_audio_lyrics(self, inputs) -> str:
def get_audio_lyrics(self, inputs, file_path: str = "") -> str:
if (
self.caption_config.low_vram
and self.model2 is not None
@@ -304,18 +523,26 @@ class AceStepCaptioner(BaseCaptioner):
self.model2.to("cpu")
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
return self._generate(self.model, self.processor, inputs)
return self._generate(
self.model,
self.processor,
inputs,
TRANSCRIBER_REPETITION_PENALTY,
retries=TRANSCRIBER_RETRIES,
break_loops=True,
file_path=file_path,
)
def get_audio_caption(self, inputs) -> str:
if self.caption_config.low_vram and self.model.device != torch.device("cpu"):
self.model.to("cpu")
if self.model2.device == torch.device("cpu"):
self.model2.to(self.device_torch)
return self._generate(self.model2, self.processor2, inputs)
return self._generate(self.model2, self.processor2, inputs, CAPTIONER_REPETITION_PENALTY)
def _caption_item(self, item: dict) -> str:
if self.caption_config.caption_format == "yue2":
lyrics = clean_lyrics(self.get_audio_lyrics(item["lyrics_inputs"]))
lyrics = clean_lyrics(self.get_audio_lyrics(item["lyrics_inputs"], item["file"]))
if self.caption_config.fixed_caption is not None:
caption = self.caption_config.fixed_caption
else:
@@ -324,7 +551,7 @@ class AceStepCaptioner(BaseCaptioner):
return f"{caption}\n[Lyrics]\n{lyrics}"
analysis = item["analysis"]
lyrics = self.get_audio_lyrics(item["lyrics_inputs"])
lyrics = self.get_audio_lyrics(item["lyrics_inputs"], item["file"])
language = "en"
if "# Languages" in lyrics and "# Lyrics" in lyrics:
+127
View File
@@ -0,0 +1,127 @@
"""Convert an MSST / lucidrains Mel-Band RoFormer .ckpt into a self-describing safetensors file
(model kwargs + inference defaults in the metadata) that toolkit.audio.melbandroformer loads.
python scripts/convert_melbandroformer.py # KimberleyJSN vocals model -> MODELS_PATH/checkpoints
python scripts/convert_melbandroformer.py --ckpt x.ckpt --config msst_config.yaml --out x.safetensors
"""
import argparse
import inspect
import json
import os
import sys
TOOLKIT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, TOOLKIT_ROOT)
from dotenv import load_dotenv
load_dotenv(os.path.join(TOOLKIT_ROOT, ".env")) # MODELS_PATH etc., before toolkit.paths is imported
import torch
import yaml
from safetensors.torch import save_file
from toolkit.paths import MODELS_PATH
from toolkit.audio.melbandroformer.model import MelBandRoformer
from toolkit.audio.melbandroformer.separate import DEFAULT_WEIGHTS
# https://huggingface.co/KimberleyJSN/melbandroformer (MIT), config from
# MSST configs/KimberleyJensen/config_vocals_mel_band_roformer_kj.yaml
KJ_VOCALS = dict(
repo_id="KimberleyJSN/melbandroformer",
filename="MelBandRoformer.ckpt",
license="MIT",
model=dict(
dim=384,
depth=6,
stereo=True,
num_stems=1,
time_transformer_depth=1,
freq_transformer_depth=1,
num_bands=60,
dim_head=64,
heads=8,
attn_dropout=0,
ff_dropout=0,
sample_rate=44100,
stft_n_fft=2048,
stft_hop_length=441,
stft_win_length=2048,
stft_normalized=False,
mask_estimator_depth=2,
),
inference=dict(chunk_size=352800, num_overlap=2),
stems=["vocals"],
)
def config_from_msst_yaml(path):
with open(path) as f:
cfg = yaml.load(f, Loader=yaml.FullLoader) # MSST configs carry !!python/tuple
allowed = set(inspect.signature(MelBandRoformer.__init__).parameters) - {"self"}
model = {k: v for k, v in cfg["model"].items() if k in allowed}
dropped = sorted(set(cfg["model"]) - allowed)
if dropped:
print(f"ignoring non-inference model keys: {dropped}")
assert cfg["model"].get("linear_transformer_depth", 0) == 0, "linear transformer layers are not vendored"
inference = dict(
chunk_size=cfg.get("inference", {}).get("chunk_size", cfg["audio"]["chunk_size"]),
num_overlap=cfg["inference"]["num_overlap"],
)
training = cfg.get("training", {})
stems = [training["target_instrument"]] if training.get("target_instrument") else training.get("instruments", [])
return model, inference, stems
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--ckpt", default=None, help="local .ckpt (default: download KimberleyJSN/melbandroformer)")
parser.add_argument("--config", default=None, help="MSST yaml config (default: KJ vocals config)")
parser.add_argument("--out", default=os.path.join(MODELS_PATH, "checkpoints", DEFAULT_WEIGHTS))
parser.add_argument("--dtype", default="fp32", choices=["fp32", "fp16", "bf16"])
args = parser.parse_args()
if args.config:
model_cfg, inference_cfg, stems = config_from_msst_yaml(args.config)
source = args.ckpt
license_ = ""
else:
model_cfg, inference_cfg, stems = KJ_VOCALS["model"], KJ_VOCALS["inference"], KJ_VOCALS["stems"]
source = f"{KJ_VOCALS['repo_id']}/{KJ_VOCALS['filename']}"
license_ = KJ_VOCALS["license"]
ckpt = args.ckpt
if ckpt is None:
from huggingface_hub import hf_hub_download
ckpt = hf_hub_download(KJ_VOCALS["repo_id"], KJ_VOCALS["filename"])
print(f"loading {ckpt}")
state_dict = torch.load(ckpt, map_location="cpu", weights_only=True)
if "state_dict" in state_dict:
state_dict = state_dict["state_dict"]
state_dict = {k[len("module."):] if k.startswith("module.") else k: v for k, v in state_dict.items()}
# validate against the vendored module before writing anything
model = MelBandRoformer(**model_cfg)
model.load_state_dict(state_dict, strict=True)
dtype = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}[args.dtype]
# shared rotary freqs alias one storage in the ckpt; safetensors needs distinct tensors
tensors = {k: v.detach().to(dtype).clone().contiguous() for k, v in state_dict.items()}
metadata = dict(
klass="MelBandRoformer",
config=json.dumps(model_cfg),
inference=json.dumps(inference_cfg),
stems=json.dumps(stems),
source=str(source),
license=license_,
)
os.makedirs(os.path.dirname(args.out), exist_ok=True)
save_file(tensors, args.out, metadata=metadata)
n_params = sum(v.numel() for v in tensors.values())
print(f"wrote {args.out} ({n_params / 1e6:.1f}M params, {args.dtype}, {os.path.getsize(args.out) / 1e6:.0f} MB)")
if __name__ == "__main__":
main()
@@ -0,0 +1,52 @@
---
license: mit
pipeline_tag: audio-to-audio
tags:
- music-source-separation
- vocals
- mel-band-roformer
- ai-toolkit
---
# Mel-Band RoFormer (vocals) for ai-toolkit
Safetensors repack of Kimberley Jensen's Mel-Band RoFormer vocal separation model, used by
[ai-toolkit](https://github.com/ostris/ai-toolkit) to split songs into a vocals track and an
instrumental track (`instrumental = mix - vocals`).
**These weights are not ours.** All credit goes to the original authors:
- Weights: [KimberleyJSN/melbandroformer](https://huggingface.co/KimberleyJSN/melbandroformer) (`MelBandRoformer.ckpt`, MIT) by Kimberley Jensen
- Training framework and config: [ZFTurbo/Music-Source-Separation-Training](https://github.com/ZFTurbo/Music-Source-Separation-Training)
([`config_vocals_mel_band_roformer_kj.yaml`](https://github.com/ZFTurbo/Music-Source-Separation-Training/blob/main/configs/KimberleyJensen/config_vocals_mel_band_roformer_kj.yaml)) by Roman Solovyev
- Architecture implementation: [lucidrains/BS-RoFormer](https://github.com/lucidrains/BS-RoFormer) by Phil Wang
- Paper: [Mel-Band RoFormer for Music Source Separation](https://arxiv.org/abs/2310.01809), Wang et al. 2023
## Files
| file | notes |
|---|---|
| `melbandroformer_vocals_kj.safetensors` | fp32, byte-identical tensors to the original `.ckpt`. Model kwargs and inference defaults (`chunk_size`, `num_overlap`) are stored in the safetensors metadata, so no separate config is needed. |
Conversion script: [`scripts/convert_melbandroformer.py`](https://github.com/ostris/ai-toolkit/blob/main/scripts/convert_melbandroformer.py).
## Usage
ai-toolkit downloads this file automatically on first use:
```bash
python -m toolkit.audio.melbandroformer song.flac
# -> song_vocals.flac, song_instrumental.flac
```
```python
from toolkit.audio.melbandroformer import load_melbandroformer, separate
model = load_melbandroformer(device="cuda", compile=True)
vocals, instrumental = separate(model, wav, sample_rate) # wav: [channels, samples]
```
Input: mono or stereo at any sample rate (resampled to 44.1 kHz internally, output at the input rate).
## License
MIT, same as the original weights and code.
+28
View File
@@ -0,0 +1,28 @@
The code in this directory is derived from:
https://github.com/ZFTurbo/Music-Source-Separation-Training (MIT, Copyright (c) 2024 Roman Solovyev)
https://github.com/lucidrains/BS-RoFormer (MIT, Copyright (c) 2023 Phil Wang)
https://github.com/lucidrains/rotary-embedding-torch (MIT, Copyright (c) 2021 Phil Wang)
The default weights (melbandroformer_vocals_kj.safetensors) are converted from
https://huggingface.co/KimberleyJSN/melbandroformer (MIT).
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12
View File
@@ -0,0 +1,12 @@
from .model import MelBandRoformer
from .separate import load_melbandroformer, separate, separate_stems, get_weights_path, HF_REPO, DEFAULT_WEIGHTS
__all__ = [
"MelBandRoformer",
"load_melbandroformer",
"separate",
"separate_stems",
"get_weights_path",
"HF_REPO",
"DEFAULT_WEIGHTS",
]
+120
View File
@@ -0,0 +1,120 @@
"""python -m toolkit.audio.melbandroformer song.flac [more files or dirs] [--out_dir DIR] [--format flac]
Writes <name>_vocals.<format> and <name>_instrumental.<format> next to each input (or in --out_dir).
Decode and encode run in worker threads so the GPU is never waiting on file IO."""
import argparse
import os
import time
from concurrent.futures import ThreadPoolExecutor
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", ".env")) # MODELS_PATH, before toolkit.paths
import torch
from . import load_melbandroformer, separate, DEFAULT_WEIGHTS
AUDIO_EXTS = {".flac", ".wav", ".mp3", ".m4a", ".ogg", ".opus", ".aac", ".wma", ".aif", ".aiff"}
def collect_inputs(paths):
files = []
for p in paths:
if os.path.isdir(p):
for root, _, names in os.walk(p):
files += [os.path.join(root, n) for n in sorted(names) if os.path.splitext(n)[1].lower() in AUDIO_EXTS]
else:
files.append(p)
return files
def output_paths(path, out_dir, fmt):
out_dir = out_dir or os.path.dirname(path)
name = os.path.splitext(os.path.basename(path))[0]
return {stem: os.path.join(out_dir, f"{name}_{stem}.{fmt}") for stem in ("vocals", "instrumental")}
def decode(path):
from torchcodec.decoders import AudioDecoder
samples = AudioDecoder(path).get_all_samples()
return samples.data, samples.sample_rate
def encode(audio, sample_rate, out_path):
from torchcodec.encoders import AudioEncoder
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
AudioEncoder(audio, sample_rate=sample_rate).to_file(out_path)
return out_path
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("inputs", nargs="+", help="audio files or directories")
parser.add_argument("--out_dir", default=None)
parser.add_argument("--format", default="flac", help="output container/codec by extension (flac is lossless and ~6x faster to encode than mp3)")
parser.add_argument("--weights", default=DEFAULT_WEIGHTS, help="filename under MODELS_PATH/checkpoints")
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
parser.add_argument("--batch_size", type=int, default=8)
parser.add_argument("--fp32", action="store_true", help="disable fp16 autocast")
parser.add_argument("--no_compile", action="store_true", help="skip torch.compile of the trunk (faster startup, slower per file)")
parser.add_argument("--overwrite", action="store_true", help="redo files whose outputs already exist")
parser.add_argument("--io_workers", type=int, default=4)
args = parser.parse_args()
files = collect_inputs(args.inputs)
todo = []
for path in files:
outs = output_paths(path, args.out_dir, args.format)
if args.overwrite or not all(os.path.exists(p) for p in outs.values()):
todo.append((path, outs))
print(f"{len(todo)} of {len(files)} files to separate")
if not todo:
return
t_start = time.perf_counter()
model = load_melbandroformer(args.weights, device=args.device, compile=not args.no_compile)
dtype = None if args.fp32 else torch.float16
total_audio = 0.0
decoder_pool = ThreadPoolExecutor(max_workers=2)
encoder_pool = ThreadPoolExecutor(max_workers=args.io_workers)
pending_encodes = []
# prefetch two files ahead of the GPU
futures = [decoder_pool.submit(decode, path) for path, _ in todo[:2]]
for idx, (path, outs) in enumerate(todo):
if idx + 2 < len(todo):
futures.append(decoder_pool.submit(decode, todo[idx + 2][0]))
t_wait = time.perf_counter()
try:
wav, sample_rate = futures.pop(0).result()
except Exception as e:
print(f"skip {path}: {type(e).__name__}: {e}")
continue
t0 = time.perf_counter()
wav = wav.to(args.device, non_blocking=True)
vocals, instrumental = separate(model, wav, sample_rate, batch_size=args.batch_size, dtype=dtype)
for stem, audio in (("vocals", vocals), ("instrumental", instrumental)):
pending_encodes.append(encoder_pool.submit(encode, audio.cpu(), sample_rate, outs[stem]))
seconds = wav.shape[-1] / sample_rate
total_audio += seconds
print(f"[{idx + 1}/{len(todo)}] {path} {seconds:.0f}s audio in {time.perf_counter() - t0:.2f}s"
f" (waited {t0 - t_wait:.2f}s for decode)", flush=True)
# keep the encode queue bounded so memory does not grow with the file count
while len(pending_encodes) > 2 * args.io_workers:
pending_encodes.pop(0).result()
t_drain = time.perf_counter()
for f in pending_encodes:
f.result()
decoder_pool.shutdown()
encoder_pool.shutdown()
wall = time.perf_counter() - t_start
print(f"done: {len(todo)} files, {total_audio:.0f}s audio in {wall:.1f}s ({total_audio / wall:.0f}x realtime, "
f"final encode drain {time.perf_counter() - t_drain:.2f}s)", flush=True)
if __name__ == "__main__":
main()
+470
View File
@@ -0,0 +1,470 @@
# Vendored from ZFTurbo/Music-Source-Separation-Training (MIT, Roman Solovyev 2024),
# derived from lucidrains/BS-RoFormer (MIT, Phil Wang 2023). See LICENSE in this directory.
# Inference-only: training loss, PoPE, linear attention and checkpointing removed;
# rotary_embedding_torch inlined; attention is plain torch SDPA; the 60 per-band
# linears are stacked bmm's (upstream per-band checkpoint keys are repacked on load).
# core() is torch.compile-friendly: no einops, no complex ops, dynamic batch dim.
from functools import partial
import torch
from torch import nn
from torch.nn import Module, ModuleList
import torch.nn.functional as F
from einops import rearrange, pack, unpack, reduce, repeat
def exists(val):
return val is not None
def pack_one(t, pattern):
return pack([t], pattern)
def unpack_one(t, ps, pattern):
return unpack(t, ps, pattern)[0]
class RMSNorm(Module):
def __init__(self, dim):
super().__init__()
self.scale = dim ** 0.5
self.gamma = nn.Parameter(torch.ones(dim))
def forward(self, x):
return F.normalize(x, dim=-1) * self.scale * self.gamma
# rotary (matches rotary_embedding_torch.RotaryEmbedding(dim) defaults: freqs_for='lang', theta=1e4)
def rotate_half(x):
x1, x2 = x.unflatten(-1, (-1, 2)).unbind(dim=-1)
return torch.stack((-x2, x1), dim=-1).flatten(-2)
class RotaryEmbedding(Module):
def __init__(self, dim):
super().__init__()
freqs = 1.0 / (10000 ** (torch.arange(0, dim, 2)[: dim // 2].float() / dim))
# nn.Parameter (not buffer) so the key matches upstream checkpoints
self.freqs = nn.Parameter(freqs, requires_grad=False)
def rotate_queries_or_keys(self, t):
# t: (b h n d); rotation is computed in fp32 regardless of autocast
n = t.shape[-2]
with torch.autocast(device_type=t.device.type, enabled=False):
seq = torch.arange(n, device=t.device, dtype=self.freqs.dtype)
freqs = torch.einsum('n,f->nf', seq, self.freqs).repeat_interleave(2, dim=-1)
tf = t.float()
out = tf * freqs.cos() + rotate_half(tf) * freqs.sin()
return out.type(t.dtype)
# attention
class FeedForward(Module):
def __init__(self, dim, mult=4, dropout=0.):
super().__init__()
dim_inner = int(dim * mult)
self.net = nn.Sequential(
RMSNorm(dim),
nn.Linear(dim, dim_inner),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(dim_inner, dim),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
class Attention(Module):
def __init__(self, dim, heads=8, dim_head=64, dropout=0., rotary_embed=None):
super().__init__()
self.heads = heads
self.dropout = dropout
dim_inner = heads * dim_head
self.rotary_embed = rotary_embed
self.norm = RMSNorm(dim)
self.to_qkv = nn.Linear(dim, dim_inner * 3, bias=False)
self.to_gates = nn.Linear(dim, heads)
self.to_out = nn.Sequential(
nn.Linear(dim_inner, dim, bias=False),
nn.Dropout(dropout),
)
def forward(self, x):
x = self.norm(x)
b, n, _ = x.shape
q, k, v = self.to_qkv(x).view(b, n, 3, self.heads, -1).permute(2, 0, 3, 1, 4).unbind(0) # (b h n d)
if exists(self.rotary_embed):
q = self.rotary_embed.rotate_queries_or_keys(q)
k = self.rotary_embed.rotate_queries_or_keys(k)
out = F.scaled_dot_product_attention(q, k, v, dropout_p=self.dropout if self.training else 0.)
gates = self.to_gates(x)
out = out * gates.transpose(1, 2).unsqueeze(-1).sigmoid()
out = out.transpose(1, 2).reshape(b, n, -1)
return self.to_out(out)
class Transformer(Module):
def __init__(
self,
*,
dim,
depth,
dim_head=64,
heads=8,
attn_dropout=0.,
ff_dropout=0.,
ff_mult=4,
norm_output=True,
rotary_embed=None,
):
super().__init__()
self.layers = ModuleList([])
for _ in range(depth):
self.layers.append(ModuleList([
Attention(dim=dim, dim_head=dim_head, heads=heads, dropout=attn_dropout, rotary_embed=rotary_embed),
FeedForward(dim=dim, mult=ff_mult, dropout=ff_dropout),
]))
self.norm = RMSNorm(dim) if norm_output else nn.Identity()
def forward(self, x):
for attn, ff in self.layers:
x = attn(x) + x
x = ff(x) + x
return self.norm(x)
# band split / mask estimator: all bands in stacked bmm's instead of 60 per-band linears.
# Parameters are stored stacked and zero-padded to the widest band; upstream per-band
# checkpoints are repacked on load (see _load_from_state_dict), so keys stay compatible.
class BandSplit(Module):
def __init__(self, dim, dim_inputs):
super().__init__()
self.dim_inputs = tuple(dim_inputs)
n, max_in = len(dim_inputs), max(dim_inputs)
self.n, self.max_in = n, max_in
self.gamma = nn.Parameter(torch.ones(n, max_in))
self.weight = nn.Parameter(torch.empty(n, max_in, dim))
self.bias = nn.Parameter(torch.zeros(n, dim))
for i, d in enumerate(dim_inputs):
bound = d ** -0.5
nn.init.uniform_(self.weight[i, :d], -bound, bound)
nn.init.uniform_(self.bias[i], -bound, bound)
# gather map from the concatenated band features into the padded (n, max_in) layout;
# pads read the extra zero column appended in forward. Built on cpu for meta-device init.
pad_src = torch.full((n, max_in), sum(dim_inputs), dtype=torch.long, device='cpu')
offset = 0
for i, d in enumerate(dim_inputs):
pad_src[i, :d] = torch.arange(offset, offset + d, device='cpu')
offset += d
self.register_buffer('pad_src', pad_src.flatten(), persistent=False)
self.register_buffer('scale', torch.tensor([d ** 0.5 for d in dim_inputs], device='cpu')[:, None], persistent=False)
def _load_from_state_dict(self, state_dict, prefix, *args):
if f"{prefix}to_features.0.1.weight" in state_dict:
ws, bs, gs = [], [], []
for i, d in enumerate(self.dim_inputs):
ws.append(F.pad(state_dict.pop(f"{prefix}to_features.{i}.1.weight").t(), (0, 0, 0, self.max_in - d)))
bs.append(state_dict.pop(f"{prefix}to_features.{i}.1.bias"))
gs.append(F.pad(state_dict.pop(f"{prefix}to_features.{i}.0.gamma"), (0, self.max_in - d)))
state_dict[f"{prefix}weight"] = torch.stack(ws)
state_dict[f"{prefix}bias"] = torch.stack(bs)
state_dict[f"{prefix}gamma"] = torch.stack(gs)
super()._load_from_state_dict(state_dict, prefix, *args)
def forward(self, x):
b, t, _ = x.shape
x = F.pad(x.reshape(b * t, -1), (0, 1))[:, self.pad_src].view(b * t, self.n, self.max_in)
# per-band RMSNorm; zero pads do not change the norm
norm = x.float().norm(dim=-1, keepdim=True).clamp(min=1e-12)
x = (x / norm * self.scale * self.gamma).transpose(0, 1) # (n, M, max_in)
out = torch.baddbmm(self.bias[:, None, :], x, self.weight) # (n, M, dim)
return out.transpose(0, 1).reshape(b, t, self.n, -1)
class MaskEstimator(Module):
def __init__(self, dim, dim_inputs, depth, mlp_expansion_factor=4):
super().__init__()
self.dim_inputs = tuple(dim_inputs)
n, hidden = len(dim_inputs), dim * mlp_expansion_factor
dim_outs = [2 * d for d in dim_inputs] # GLU halves it back to d
max_out = max(dim_outs)
self.n, self.depth, self.max_out = n, depth, max_out
dims = (dim, *((hidden,) * depth), max_out)
self.weights = nn.ParameterList([nn.Parameter(torch.empty(n, i, o)) for i, o in zip(dims[:-1], dims[1:])])
self.biases = nn.ParameterList([nn.Parameter(torch.zeros(n, o)) for o in dims[1:]])
for w, bias, i in zip(self.weights, self.biases, dims[:-1]):
nn.init.uniform_(w, -i ** -0.5, i ** -0.5)
nn.init.uniform_(bias, -i ** -0.5, i ** -0.5)
# GLU gather: a * sigmoid(b) with a/b the first/second half of each band's valid output columns
glu_a, glu_b = [], []
for i, d in enumerate(dim_inputs):
base = i * max_out
glu_a.append(torch.arange(base, base + d, device='cpu'))
glu_b.append(torch.arange(base + d, base + 2 * d, device='cpu'))
self.register_buffer('glu_a', torch.cat(glu_a), persistent=False)
self.register_buffer('glu_b', torch.cat(glu_b), persistent=False)
def _load_from_state_dict(self, state_dict, prefix, *args):
if f"{prefix}to_freqs.0.0.0.weight" in state_dict:
for j in range(self.depth + 1):
ws, bs = [], []
for i in range(self.n):
w = state_dict.pop(f"{prefix}to_freqs.{i}.0.{2 * j}.weight").t()
bias = state_dict.pop(f"{prefix}to_freqs.{i}.0.{2 * j}.bias")
if j == self.depth:
w = F.pad(w, (0, self.max_out - w.shape[1]))
bias = F.pad(bias, (0, self.max_out - bias.shape[0]))
ws.append(w)
bs.append(bias)
state_dict[f"{prefix}weights.{j}"] = torch.stack(ws)
state_dict[f"{prefix}biases.{j}"] = torch.stack(bs)
super()._load_from_state_dict(state_dict, prefix, *args)
def forward(self, x):
b, t, n, d = x.shape
h = x.permute(2, 0, 1, 3).reshape(n, b * t, d)
for j in range(self.depth):
h = torch.tanh(torch.baddbmm(self.biases[j][:, None, :], h, self.weights[j]))
out = torch.baddbmm(self.biases[-1][:, None, :], h, self.weights[-1]) # (n, M, max_out)
out = out.transpose(0, 1).reshape(b * t, n * self.max_out)
out = out[:, self.glu_a] * torch.sigmoid(out[:, self.glu_b])
return out.view(b, t, -1)
# main class
class MelBandRoformer(Module):
def __init__(
self,
dim,
*,
depth,
stereo=False,
num_stems=1,
time_transformer_depth=2,
freq_transformer_depth=2,
num_bands=60,
dim_head=64,
heads=8,
attn_dropout=0.1,
ff_dropout=0.1,
sample_rate=44100, # for the librosa mel filter bank
stft_n_fft=2048,
stft_hop_length=512,
stft_win_length=2048,
stft_normalized=False,
zero_dc=True,
mask_estimator_depth=1,
mlp_expansion_factor=4,
):
super().__init__()
self.stereo = stereo
self.audio_channels = 2 if stereo else 1
self.num_stems = num_stems
self.layers = ModuleList([])
transformer_kwargs = dict(
dim=dim,
heads=heads,
dim_head=dim_head,
attn_dropout=attn_dropout,
ff_dropout=ff_dropout,
)
# one rotary module shared across all time (resp. freq) transformers, as upstream
time_rotary_embed = RotaryEmbedding(dim=dim_head)
freq_rotary_embed = RotaryEmbedding(dim=dim_head)
for _ in range(depth):
self.layers.append(nn.ModuleList([
Transformer(depth=time_transformer_depth, rotary_embed=time_rotary_embed, **transformer_kwargs),
Transformer(depth=freq_transformer_depth, rotary_embed=freq_rotary_embed, **transformer_kwargs),
]))
self.stft_window_fn = partial(torch.hann_window, stft_win_length)
self.stft_kwargs = dict(
n_fft=stft_n_fft,
hop_length=stft_hop_length,
win_length=stft_win_length,
normalized=stft_normalized,
)
freqs = stft_n_fft // 2 + 1
# binary mel filter bank as in section 2 of the paper; overlapping bands' masks are averaged
from librosa import filters # lazy: ~1s import, only needed here
mel_filter_bank = torch.from_numpy(filters.mel(sr=sample_rate, n_fft=stft_n_fft, n_mels=num_bands))
# upstream forces the first/last bins in so every frequency is covered by some band
mel_filter_bank[0][0] = 1.
mel_filter_bank[-1, -1] = 1.
freqs_per_band = mel_filter_bank > 0
assert freqs_per_band.any(dim=0).all(), 'all frequencies need to be covered by all bands for now'
# explicit cpu so the loader can build the module under torch.device('meta')
repeated_freq_indices = repeat(torch.arange(freqs, device='cpu'), 'f -> b f', b=num_bands)
freq_indices = repeated_freq_indices[freqs_per_band]
if stereo:
freq_indices = repeat(freq_indices, 'f -> f s', s=2)
freq_indices = freq_indices * 2 + torch.arange(2, device='cpu')
freq_indices = rearrange(freq_indices, 'f s -> (f s)')
self.register_buffer('freq_indices', freq_indices, persistent=False)
self.register_buffer('freqs_per_band', freqs_per_band, persistent=False)
num_freqs_per_band = reduce(freqs_per_band, 'b f -> b', 'sum')
num_bands_per_freq = reduce(freqs_per_band, 'b f -> f', 'sum')
self.register_buffer('num_freqs_per_band', num_freqs_per_band, persistent=False)
self.register_buffer('num_bands_per_freq', num_bands_per_freq, persistent=False)
freqs_per_bands_with_complex = tuple(2 * f * self.audio_channels for f in num_freqs_per_band.tolist())
self.band_split = BandSplit(dim=dim, dim_inputs=freqs_per_bands_with_complex)
self.mask_estimators = nn.ModuleList([
MaskEstimator(
dim=dim,
dim_inputs=freqs_per_bands_with_complex,
depth=mask_estimator_depth,
mlp_expansion_factor=mlp_expansion_factor,
)
for _ in range(num_stems)
])
self.zero_dc = zero_dc
def stft(self, raw_audio):
"""raw_audio (b s t) -> band-gathered real features (b t (f c)), and stft_repr (b (f s) t c)."""
device = raw_audio.device
batch = raw_audio.shape[0]
raw_audio, batch_audio_channel_packed_shape = pack_one(raw_audio, '* t')
stft_window = self.stft_window_fn(device=device)
stft_repr = torch.stft(raw_audio, **self.stft_kwargs, window=stft_window, return_complex=True)
stft_repr = torch.view_as_real(stft_repr)
stft_repr = unpack_one(stft_repr, batch_audio_channel_packed_shape, '* f t c')
# merge stereo / mono into the frequency dim, frequency leading, for band splitting
stft_repr = rearrange(stft_repr, 'b s f t c -> b (f s) t c')
batch_arange = torch.arange(batch, device=device)[..., None]
x = stft_repr[batch_arange, self.freq_indices]
# fold complex into the frequency dim
x = rearrange(x, 'b f t c -> b t (f c)')
return x, stft_repr
def core(self, x):
"""Real-valued trunk (b t (f c)) -> masks (b n t (f c)); the torch.compile target."""
x = self.band_split(x)
# axial attention: time then freq
b, t, f, d = x.shape
# native views instead of einops: same math, ~3x faster dynamo tracing
for time_transformer, freq_transformer in self.layers:
x = time_transformer(x.transpose(1, 2).reshape(b * f, t, d)).view(b, f, t, d)
x = freq_transformer(x.transpose(1, 2).reshape(b * t, f, d)).view(b, t, f, d)
return torch.stack([fn(x) for fn in self.mask_estimators], dim=1)
def apply_masks(self, stft_repr, masks, channels):
batch = stft_repr.shape[0]
num_stems = masks.shape[1]
masks = rearrange(masks, 'b n t (f c) -> b n f t c', c=2)
# modulate frequency representation (complex multiply)
stft_repr = rearrange(stft_repr, 'b f t c -> b 1 f t c')
stft_repr = torch.view_as_complex(stft_repr)
masks = torch.view_as_complex(masks)
masks = masks.type(stft_repr.dtype)
# average the estimated mask over overlapping bands
scatter_indices = repeat(self.freq_indices, 'f -> b n f t', b=batch, n=num_stems, t=stft_repr.shape[-1])
stft_repr_expanded_stems = repeat(stft_repr, 'b 1 ... -> b n ...', n=num_stems)
masks_summed = torch.zeros_like(stft_repr_expanded_stems).scatter_add_(2, scatter_indices, masks)
denom = repeat(self.num_bands_per_freq, 'f -> (f r) 1', r=channels)
masks_averaged = masks_summed / denom.clamp(min=1e-8)
stft_repr = stft_repr * masks_averaged
# istft
stft_repr = rearrange(stft_repr, 'b n (f s) t -> (b n s) f t', s=self.audio_channels)
if self.zero_dc:
stft_repr[:, 0] = 0.
stft_window = self.stft_window_fn(device=stft_repr.device)
recon_audio = torch.istft(stft_repr, **self.stft_kwargs, window=stft_window, return_complex=False)
recon_audio = rearrange(recon_audio, '(b n s) t -> b n s t', b=batch, s=self.audio_channels, n=num_stems)
if num_stems == 1:
recon_audio = rearrange(recon_audio, 'b 1 s t -> b s t')
return recon_audio
def compile_core(self, **kwargs):
"""Fuse the trunk's elementwise chains; batch dim is marked dynamic so any batch size hits one graph."""
compiled = torch.compile(self.core, **kwargs)
def core(x):
torch._dynamo.mark_dynamic(x, 0)
return compiled(x)
self.core = core
return self
def forward(self, raw_audio):
"""
raw_audio: (b t) or (b s t). Returns (b s t) for a single stem, else (b n s t).
b batch, f freq, t time, s audio channel, n stem, c complex (2), d feature
"""
if raw_audio.ndim == 2:
raw_audio = rearrange(raw_audio, 'b t -> b 1 t')
channels = raw_audio.shape[1]
assert (not self.stereo and channels == 1) or (self.stereo and channels == 2), \
'stereo model needs 2-channel audio, mono model needs 1-channel audio'
x, stft_repr = self.stft(raw_audio)
masks = self.core(x)
return self.apply_masks(stft_repr, masks, channels)
+158
View File
@@ -0,0 +1,158 @@
import json
import os
import torch
import torch.nn.functional as F
from safetensors import safe_open
from .model import MelBandRoformer
HF_REPO = "ai-toolkit/melbandroformer"
DEFAULT_WEIGHTS = "melbandroformer_vocals_kj.safetensors"
MODEL_SAMPLE_RATE = 44100
def get_weights_path(filename: str = DEFAULT_WEIGHTS) -> str:
"""MODELS_PATH/checkpoints/<filename>, pulled from the HF repo root if missing."""
from toolkit.paths import MODELS_PATH # lazy: read after the CLI has loaded .env
ckpt_dir = os.path.join(MODELS_PATH, "checkpoints")
path = os.path.join(ckpt_dir, filename)
if os.path.exists(path):
return path
from huggingface_hub import hf_hub_download
os.makedirs(ckpt_dir, exist_ok=True)
print(f"Downloading {HF_REPO}/{filename} to {ckpt_dir}")
return hf_hub_download(repo_id=HF_REPO, filename=filename, local_dir=ckpt_dir)
def load_melbandroformer(filename: str = DEFAULT_WEIGHTS, device=None, compile: bool = False) -> MelBandRoformer:
path = get_weights_path(filename)
device = torch.device(device if device is not None else "cpu")
with safe_open(path, framework="pt", device=str(device)) as f:
metadata = f.metadata() or {}
state_dict = {k: f.get_tensor(k) for k in f.keys()}
if "config" not in metadata:
raise ValueError(
f"{path} has no model config in its metadata; convert it with scripts/convert_melbandroformer.py"
)
# meta init + assign: no cpu random init of 228M params, tensors go file -> device directly
with torch.device("meta"):
model = MelBandRoformer(**json.loads(metadata["config"]))
model.load_state_dict(state_dict, strict=True, assign=True)
model.to(device) # index buffers are built on cpu
# chunk_size / num_overlap the weights were validated with
model.inference_config = json.loads(metadata.get("inference", "{}"))
model.eval()
if compile:
model.compile_core()
return model
def _fade_window(chunk_size: int, fade_size: int) -> torch.Tensor:
window = torch.ones(chunk_size)
window[:fade_size] = torch.linspace(0, 1, fade_size)
window[-fade_size:] = torch.linspace(1, 0, fade_size)
return window
@torch.no_grad()
def separate_stems(
model: MelBandRoformer,
mix: torch.Tensor,
chunk_size: int = None,
num_overlap: int = None,
batch_size: int = 4,
dtype=torch.float16,
progress: bool = False,
) -> torch.Tensor:
"""Overlap-add chunked inference (MSST `demix`). mix: [C, T] at MODEL_SAMPLE_RATE. Returns [num_stems, C, T] on cpu."""
cfg = getattr(model, "inference_config", {})
chunk_size = chunk_size or cfg.get("chunk_size", 352800)
num_overlap = num_overlap or cfg.get("num_overlap", 2)
device = next(model.parameters()).device
mix = mix.to(device=device, dtype=torch.float32)
length = mix.shape[-1]
step = chunk_size // num_overlap
fade_size = chunk_size // 10
border = chunk_size - step
# reflect-pad the edges so the first/last chunks are not fading in/out on real audio
padded = length > 2 * border and border > 0
if padded:
mix = F.pad(mix, (border, border), mode="reflect")
total = mix.shape[-1]
window = _fade_window(chunk_size, fade_size).to(device)
result = torch.zeros((model.num_stems, *mix.shape), device=device)
counter = torch.zeros_like(result)
use_autocast = dtype is not None and device.type == "cuda"
positions = list(range(0, total, step))
if progress:
from tqdm import tqdm
batches = tqdm(range(0, len(positions), batch_size), desc="separating", leave=False)
else:
batches = range(0, len(positions), batch_size)
for b in batches:
batch_pos = positions[b:b + batch_size]
parts, lens = [], []
for start in batch_pos:
part = mix[:, start:start + chunk_size]
chunk_len = part.shape[-1]
pad_mode = "reflect" if chunk_len > chunk_size // 2 else "constant"
parts.append(F.pad(part, (0, chunk_size - chunk_len), mode=pad_mode))
lens.append(chunk_len)
if len(parts) == 1:
parts.append(parts[0]) # a batch of 1 would make dynamo specialize (0/1) and recompile
with torch.autocast(device_type="cuda", dtype=dtype, enabled=use_autocast):
out = model(torch.stack(parts))
if out.ndim == 3:
out = out[:, None] # [b, n, c, t]
out = out.float()
for j, (start, seg_len) in enumerate(zip(batch_pos, lens)):
w = window.clone()
if start == 0:
w[:fade_size] = 1
if start + step >= total:
w[-fade_size:] = 1
result[..., start:start + seg_len] += out[j, ..., :seg_len] * w[:seg_len]
counter[..., start:start + seg_len] += w[:seg_len]
stems = torch.nan_to_num(result / counter)
if padded:
stems = stems[..., border:-border]
return stems.cpu()
@torch.no_grad()
def separate(model: MelBandRoformer, wav: torch.Tensor, sample_rate: int, **kwargs):
"""wav: [C, T] or [T] at any sample rate. Returns (vocals, instrumental), same shape/sr/device as wav,
with vocals + instrumental == wav exactly."""
if wav.ndim == 1:
wav = wav[None]
wav = wav.float()
channels, length = wav.shape
if channels > 2:
raise ValueError(f"expected mono or stereo audio, got {channels} channels")
mix = wav.repeat(2, 1) if channels == 1 else wav
if sample_rate != MODEL_SAMPLE_RATE:
import torchaudio
mix = torchaudio.functional.resample(mix, sample_rate, MODEL_SAMPLE_RATE)
vocals = separate_stems(model, mix, **kwargs)[0]
if sample_rate != MODEL_SAMPLE_RATE:
vocals = torchaudio.functional.resample(vocals, MODEL_SAMPLE_RATE, sample_rate)
vocals = F.pad(vocals[..., :length], (0, max(0, length - vocals.shape[-1])))
if channels == 1:
vocals = vocals.mean(0, keepdim=True)
vocals = vocals.to(wav.device)
instrumental = wav - vocals
return vocals, instrumental
+7
View File
@@ -276,6 +276,13 @@ const CaptionSimpleJob: React.FC<Props> = ({ jobConfig, setJobConfig, gpuIDs, se
checked={jobConfig.config.process[0].caption.compile || false}
onChange={value => setJobConfig(value, 'config.process[0].caption.compile')}
/>
{additionalSections.includes('caption.extract_vocals_before_transcribe') && (
<Checkbox
label="Extract Vocals Before Transcribing"
checked={jobConfig.config.process[0].caption.extract_vocals_before_transcribe || false}
onChange={value => setJobConfig(value, 'config.process[0].caption.extract_vocals_before_transcribe')}
/>
)}
{additionalSections.includes('caption.thinking') && (
<Checkbox
label="Thinking"
+2 -1
View File
@@ -1,7 +1,7 @@
import { CloudLora, GroupedSelectOption, SelectOption } from "@/types";
type CaptionGroup = 'image' | 'music' | 'video' | 'image/video/sound';
type AdditionalSections = 'caption.model_name_or_path2' | 'caption.caption_prompt' | 'caption.max_res' | 'caption.max_new_tokens' | 'caption.fixed_caption' | 'caption.caption_format' | 'caption.thinking' | 'caption.batch_size' | 'caption.layer_offloading';
type AdditionalSections = 'caption.model_name_or_path2' | 'caption.caption_prompt' | 'caption.max_res' | 'caption.max_new_tokens' | 'caption.fixed_caption' | 'caption.caption_format' | 'caption.extract_vocals_before_transcribe' | 'caption.thinking' | 'caption.batch_size' | 'caption.layer_offloading';
export interface CaptionOption {
name: string;
@@ -111,6 +111,7 @@ export const captionerTypes: CaptionOption[] = [
'caption.model_name_or_path2',
'caption.fixed_caption',
'caption.caption_format',
'caption.extract_vocals_before_transcribe',
],
},
{
+1
View File
@@ -338,6 +338,7 @@ export interface CaptionProcessConfig {
max_new_tokens?: number;
fixed_caption?: string;
caption_format?: string;
extract_vocals_before_transcribe?: boolean;
caption_extension?: string;
thinking?: boolean;
batch_size?: number;