diff --git a/extensions_built_in/captioner/AceStepCaptioner.py b/extensions_built_in/captioner/AceStepCaptioner.py index 1f7f78f..80bc9ce 100644 --- a/extensions_built_in/captioner/AceStepCaptioner.py +++ b/extensions_built_in/captioner/AceStepCaptioner.py @@ -425,6 +425,14 @@ class AceStepCaptioner(BaseCaptioner): ) return item + @staticmethod + def _place_inputs(model, inputs): + """Move a processor batch onto the model; the thinker also reads its + 2D pad mask from the model. Called again before every decode pass.""" + inputs = inputs.to(model.device).to(model.dtype) + model._pad_mask_2d = inputs.get("attention_mask", None) + return inputs + def _generate( self, model, @@ -434,17 +442,17 @@ class AceStepCaptioner(BaseCaptioner): retries: tuple = (), break_loops: bool = False, file_path: str = "", + max_new_tokens: int = MAX_NEW_TOKENS, ) -> str: - inputs = inputs.to(model.device).to(model.dtype) + inputs = self._place_inputs(model, inputs) # a generate that dies between static-cache creation and its first # forward leaves a half-built cache that breaks every later call stale_cache = getattr(model, "_cache", None) if stale_cache is not None and not stale_cache.is_initialized: del model._cache - model._pad_mask_2d = inputs.get("attention_mask", None) input_len = inputs["input_ids"].shape[1] # greedy decode loops on repeated phrases without a penalty - gen_kwargs = {"max_new_tokens": MAX_NEW_TOKENS, "repetition_penalty": repetition_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 @@ -453,7 +461,7 @@ class AceStepCaptioner(BaseCaptioner): "repetition_penalty": repetition_penalty, "max_length": STATIC_MAX_LENGTH, "stopping_criteria": StoppingCriteriaList( - [MaxLengthCriteria(max_length=min(input_len + MAX_NEW_TOKENS, STATIC_MAX_LENGTH))] + [MaxLengthCriteria(max_length=min(input_len + max_new_tokens, STATIC_MAX_LENGTH))] ), } else: @@ -473,7 +481,7 @@ class AceStepCaptioner(BaseCaptioner): kwargs = dict(gen_kwargs) if breaker is not None: kwargs["logits_processor"] = LogitsProcessorList([breaker]) - model._pad_mask_2d = inputs.get("attention_mask", None) + self._place_inputs(model, inputs) generated = model.generate(**inputs, **kwargs) return strip_eos(generated[0, input_len:]), breaker @@ -533,25 +541,29 @@ class AceStepCaptioner(BaseCaptioner): file_path=file_path, ) - def get_audio_caption(self, inputs) -> str: + def get_audio_caption(self, inputs, file_path: str = "") -> 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, CAPTIONER_REPETITION_PENALTY) + def _transcribe_item(self, item: dict) -> str: + """Raw transcriber text for a prepped item: '# Languages ... # Lyrics ...'.""" + return self.get_audio_lyrics(item["lyrics_inputs"], item["file"]) + def _caption_item(self, item: dict) -> str: if self.caption_config.caption_format == "yue2": - lyrics = clean_lyrics(self.get_audio_lyrics(item["lyrics_inputs"], item["file"])) + lyrics = clean_lyrics(self._transcribe_item(item)) if self.caption_config.fixed_caption is not None: caption = self.caption_config.fixed_caption else: - caption = self.get_audio_caption(item["caption_inputs"]) + caption = self.get_audio_caption(item["caption_inputs"], item["file"]) caption = " ".join(caption.split()) return f"{caption}\n[Lyrics]\n{lyrics}" analysis = item["analysis"] - lyrics = self.get_audio_lyrics(item["lyrics_inputs"], item["file"]) + lyrics = self._transcribe_item(item) language = "en" if "# Languages" in lyrics and "# Lyrics" in lyrics: @@ -563,7 +575,7 @@ class AceStepCaptioner(BaseCaptioner): if self.caption_config.fixed_caption is not None: caption = self.caption_config.fixed_caption else: - caption = self.get_audio_caption(item["caption_inputs"]) + caption = self.get_audio_caption(item["caption_inputs"], item["file"]) output = f"\n{caption}\n\n" output += f"\n{lyrics}\n\n" diff --git a/extensions_built_in/captioner/MossMusicCaptioner.py b/extensions_built_in/captioner/MossMusicCaptioner.py new file mode 100644 index 0000000..d7559f3 --- /dev/null +++ b/extensions_built_in/captioner/MossMusicCaptioner.py @@ -0,0 +1,169 @@ +"""MOSS-Music captioner: one 8B audio-language model (OpenMOSS-Team/MOSS-Music-8B-Instruct) +does both the lyric transcription and the description that AceStepCaptioner +runs two Qwen2.5-Omni checkpoints for. Output layouts, loop breaking and the +worker pipeline are inherited from AceStepCaptioner.""" + +import re + +from optimum.quanto import freeze + +from toolkit.audio.moss_music import HF_REPO, load_moss_music +from toolkit.basic import flush +from toolkit.util.quantize import get_qtype, quantize + +from .AceStepCaptioner import ( + CAPTIONER_REPETITION_PENALTY, + TRANSCRIBER_REPETITION_PENALTY, + TRANSCRIBER_RETRIES, + AceStepCaptionConfig, + AceStepCaptioner, + analyze_audio, +) + +# timestamps are requested on purpose: each line is anchored to the input time +# markers, so decode has to advance through the song instead of looping on a +# repeated phrase. They are stripped from the saved caption. +TRANSCRIBE_PROMPT = "Transcribe the lyrics of this song with timestamps." +# the model writes multi-section essays with timings, chords and lyric quotes +# unless framed as a generator prompt; this keeps it to one plain paragraph +CAPTION_PROMPT = ( + "Write a prompt that a text-to-music generator could use to recreate this track. One paragraph, " + "under 80 words: genre, mood, instrumentation, tempo feel, production style, vocal style. " + "No timestamps, section timings, chord names, or lyric quotes." +) +LANGUAGE_PROMPT = ( + "What language are the lyrics sung in? Answer with only the two-letter " + "ISO 639-1 language code, or none if there are no lyrics." +) +LANGUAGE_MAX_NEW_TOKENS = 8 + +# the model writes "[00:18.00 - 00:22.00] line"; a looping decode can run the +# seconds field past 59 ("[00:100.00 - ...]"), so any bracketed run of time +# characters is treated as a timestamp, plus bare mm:ss spans at a line start +_TIME = r"(?:\d{1,2}:)?\d{1,3}:\d{1,3}(?:[.:]\d{1,3})?|\d+(?:\.\d+)?\s*s" +_SPAN = rf"(?:{_TIME})(?:\s*(?:-{{1,3}}>?|–|—|~|to)\s*(?:{_TIME}))?" +_TIME_CHARS = r"[\d:.\s\-–—>~s]*\d[\d:.\s\-–—>~s]*" +TIMESTAMP_RE = re.compile( + rf"\[{_TIME_CHARS}\]|\({_TIME_CHARS}\)|<{_TIME_CHARS}>|^\s*{_SPAN}\s*[:\-–—|]?", + re.MULTILINE, +) + + +def strip_timestamps(text: str) -> str: + """Remove time annotations and tidy the line layout they leave behind.""" + text = TIMESTAMP_RE.sub("", text) + lines = [" ".join(line.split()) for line in text.splitlines()] + out = [] + for line in lines: + if line == "" and (not out or out[-1] == ""): + continue + out.append(line) + return "\n".join(out).strip() + + +class MossMusicCaptionConfig(AceStepCaptionConfig): + def __init__(self, **kwargs): + kwargs.setdefault("model_name_or_path", HF_REPO) + super().__init__(**kwargs) + self.caption_prompt: str = kwargs.get("caption_prompt") or CAPTION_PROMPT + self.transcribe_prompt: str = kwargs.get("transcribe_prompt") or TRANSCRIBE_PROMPT + # MOSS transcribes the full mix better than a separated vocal stem + self.extract_vocals_before_transcribe = False + # leave the model's "[00:18.00 - 00:22.00]" line times in the saved lyrics + self.keep_timestamps: bool = bool(kwargs.get("keep_timestamps", False)) + + +class MossMusicCaptioner(AceStepCaptioner): + caption_config_class = MossMusicCaptionConfig + caption_config: MossMusicCaptionConfig + + def load_model(self): + self.print_and_status_update("Loading MOSS-Music model") + self.model, self.processor = load_moss_music( + self.caption_config.model_name_or_path, + dtype=self.torch_dtype, + device=str(self.device_torch), + ) + if self.caption_config.quantize: + self.print_and_status_update("Quantizing MOSS-Music model") + quantize(self.model, weights=get_qtype(self.caption_config.qtype)) + freeze(self.model) + flush() + # one model does both jobs; AceStepCaptioner's second slot stays empty + self.model2 = None + self.processor2 = None + flush() + + @staticmethod + def _place_inputs(model, inputs): + inputs = inputs.to(model.device) + inputs["audio_data"] = inputs["audio_data"].to(model.dtype) + return inputs + + def _make_inputs(self, audio_data, prompt_text: str): + inputs = self.processor(text=prompt_text, audios=[audio_data]) + inputs["audio_input_mask"] = inputs["input_ids"] == self.processor.audio_token_id + return inputs + + def _prep_file(self, file_path: str) -> dict: + audio_data = self._load_audio(file_path) + is_ace_step = self.caption_config.caption_format == "ace_step" + item = { + "file": file_path, + "analysis": analyze_audio(file_path) if is_ace_step else None, + "lyrics_inputs": self._make_inputs(audio_data, self.caption_config.transcribe_prompt), + } + if is_ace_step: + item["language_inputs"] = self._make_inputs(audio_data, LANGUAGE_PROMPT) + if self.caption_config.fixed_caption is None: + item["caption_inputs"] = self._make_inputs( + audio_data, self.caption_config.caption_prompt + ) + return item + + def get_audio_lyrics(self, inputs, file_path: str = "") -> str: + text = self._generate( + self.model, + self.processor, + inputs, + TRANSCRIBER_REPETITION_PENALTY, + retries=TRANSCRIBER_RETRIES, + break_loops=True, + file_path=file_path, + ) + return text.strip() if self.caption_config.keep_timestamps else strip_timestamps(text) + + def get_audio_language(self, inputs) -> str: + answer = self._generate( + self.model, + self.processor, + inputs, + repetition_penalty=1.0, + max_new_tokens=LANGUAGE_MAX_NEW_TOKENS, + ) + match = re.search(r"[a-z]{2,3}", answer.lower()) + code = match.group(0) if match else "" + # instrumentals answer none; ACE-Step's default language is en + return code if len(code) == 2 and code != "no" else "en" + + def _transcribe_item(self, item: dict) -> str: + lyrics = self.get_audio_lyrics(item["lyrics_inputs"], item["file"]) + if "language_inputs" not in item: + return lyrics + # the layout AceStepCaptioner._caption_item parses from the ACE transcriber + language = self.get_audio_language(item["language_inputs"]) + return f"# Languages\n{language}\n# Lyrics\n{lyrics}" + + def get_audio_caption(self, inputs, file_path: str = "") -> str: + # a tag-list prompt can degenerate into a repeated tag; same loop + # breaking as transcription + caption = self._generate( + self.model, + self.processor, + inputs, + CAPTIONER_REPETITION_PENALTY, + retries=TRANSCRIBER_RETRIES, + break_loops=True, + file_path=file_path, + ) + return strip_timestamps(caption) diff --git a/extensions_built_in/captioner/__init__.py b/extensions_built_in/captioner/__init__.py index e838757..e42683f 100644 --- a/extensions_built_in/captioner/__init__.py +++ b/extensions_built_in/captioner/__init__.py @@ -13,6 +13,18 @@ class AceStepCaptionerExtension(Extension): return AceStepCaptioner +class MossMusicCaptionerExtension(Extension): + uid = "MossMusicCaptioner" + name = "MOSS-Music Captioner" + + @classmethod + def get_process(cls): + # import your process class here so it is only loaded when needed and return it + from .MossMusicCaptioner import MossMusicCaptioner + + return MossMusicCaptioner + + class Qwen3VLCaptionerExtension(Extension): uid = "Qwen3VLCaptioner" name = "Qwen 3VL Captioner" @@ -63,6 +75,7 @@ class Ideogram4CaptionerExtension(Extension): AI_TOOLKIT_EXTENSIONS = [ AceStepCaptionerExtension, + MossMusicCaptionerExtension, Qwen3VLCaptionerExtension, Qwen3OmniCaptionerExtension, Qwen25OmniCaptionerExtension, diff --git a/toolkit/audio/moss_music/LICENSE b/toolkit/audio/moss_music/LICENSE new file mode 100644 index 0000000..52d5be3 --- /dev/null +++ b/toolkit/audio/moss_music/LICENSE @@ -0,0 +1,21 @@ +The code in this directory is adapted from MOSS-Music by the OpenMOSS Team +(https://github.com/OpenMOSS/MOSS-Music), licensed under the Apache License, +Version 2.0. Modifications for ai-toolkit: transformers 5.x compatibility +(WhisperEncoderLayer tensor return, generate input preparation), removal of the +chat-template loader, and direct Qwen2Tokenizer loading. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/toolkit/audio/moss_music/__init__.py b/toolkit/audio/moss_music/__init__.py new file mode 100644 index 0000000..a5ebf73 --- /dev/null +++ b/toolkit/audio/moss_music/__init__.py @@ -0,0 +1,35 @@ +import torch + +from .configuration import MossMusicConfig, MossMusicEncoderConfig +from .modeling import MossMusicModel +from .processing import MelConfig, MossMusicProcessor + +HF_REPO = "OpenMOSS-Team/MOSS-Music-8B-Instruct" + + +def load_moss_music( + name_or_path: str = HF_REPO, + dtype: torch.dtype = torch.bfloat16, + device="cpu", + enable_time_marker: bool = True, +): + """Model + processor from a hub id or local checkpoint dir. Time markers + (elapsed seconds interleaved into the audio tokens) are what the model was + trained with and keep long transcriptions from looping; leave them on.""" + model = MossMusicModel.from_pretrained(name_or_path, dtype=dtype, device_map=device) + model.eval() + processor = MossMusicProcessor.from_pretrained( + name_or_path, enable_time_marker=enable_time_marker + ) + return model, processor + + +__all__ = [ + "HF_REPO", + "MelConfig", + "MossMusicConfig", + "MossMusicEncoderConfig", + "MossMusicModel", + "MossMusicProcessor", + "load_moss_music", +] diff --git a/toolkit/audio/moss_music/configuration.py b/toolkit/audio/moss_music/configuration.py new file mode 100644 index 0000000..f47e47b --- /dev/null +++ b/toolkit/audio/moss_music/configuration.py @@ -0,0 +1,135 @@ +from typing import List, Optional +from dataclasses import dataclass, field + +from transformers import PretrainedConfig, Qwen3Config + + +@dataclass +class MossMusicEncoderConfig: + d_model: int = 1280 + output_dim: int = 1280 + num_mel_bins: int = 128 + encoder_layers: int = 32 + encoder_attention_heads: int = 20 + encoder_ffn_dim: int = 5120 + downsample_rate: int = 8 + downsample_hidden_size: int = 480 + encoder_attention_window_size: int = 100 + max_source_positions: int = 1500 + dropout: float = 0.1 + attention_dropout: float = 0.1 + activation_dropout: float = 0.0 + activation_function: str = "gelu" + layer_norm_eps: float = 1e-5 + _attn_implementation: str = "eager" + pretrained_path: str = "" + + n_window: int = 200 + conv_chunksize: int = 64 + + deepstack_encoder_layer_indexes: List[int] = field( + default_factory=lambda: [8, 16, 24] + ) + + @classmethod + def from_dict(cls, config_dict): + if config_dict is None: + return cls() + allowed_keys = set(cls.__dataclass_fields__.keys()) + filtered = {k: v for k, v in config_dict.items() if k in allowed_keys} + return cls(**filtered) + + def to_dict(self): + return { + "d_model": self.d_model, + "output_dim": self.output_dim, + "num_mel_bins": self.num_mel_bins, + "encoder_layers": self.encoder_layers, + "encoder_attention_heads": self.encoder_attention_heads, + "encoder_ffn_dim": self.encoder_ffn_dim, + "downsample_rate": self.downsample_rate, + "downsample_hidden_size": self.downsample_hidden_size, + "encoder_attention_window_size": self.encoder_attention_window_size, + "max_source_positions": self.max_source_positions, + "dropout": self.dropout, + "attention_dropout": self.attention_dropout, + "activation_dropout": self.activation_dropout, + "activation_function": self.activation_function, + "layer_norm_eps": self.layer_norm_eps, + "_attn_implementation": self._attn_implementation, + "pretrained_path": self.pretrained_path, + "n_window": self.n_window, + "conv_chunksize": self.conv_chunksize, + "deepstack_encoder_layer_indexes": list( + self.deepstack_encoder_layer_indexes or [] + ), + } + + +class MossMusicConfig(PretrainedConfig): + model_type = "moss_music" + is_composition = True + + def __init__( + self, + audio_config=None, + language_config=None, + adapter_hidden_size=8192, + ignore_index=-100, + deepstack_num_inject_layers: Optional[int] = None, + **kwargs, + ): + if isinstance(audio_config, dict): + audio_config = MossMusicEncoderConfig.from_dict(audio_config) + elif audio_config is None: + audio_config = MossMusicEncoderConfig() + + if isinstance(language_config, dict): + language_config = Qwen3Config(**language_config) + elif language_config is None: + language_config = Qwen3Config() + + self.audio_config = audio_config + self.language_config = language_config + self.adapter_hidden_size = adapter_hidden_size + self.ignore_index = ignore_index + self.deepstack_num_inject_layers = deepstack_num_inject_layers + + _propagate_keys = { + "num_hidden_layers", + "eos_token_id", + "bos_token_id", + "vocab_size", + "tie_word_embeddings", + } + for key in ("num_hidden_layers", "eos_token_id", "bos_token_id", "vocab_size"): + kwargs.setdefault(key, getattr(language_config, key, None)) + kwargs.setdefault("tie_word_embeddings", False) + + if hasattr(language_config, "to_dict"): + _lang_keys = set(language_config.to_dict().keys()) + for key in list(kwargs.keys()): + if key in _lang_keys and key not in _propagate_keys: + kwargs.pop(key) + + super().__init__(**kwargs) + + def to_dict(self): + output = super().to_dict() + output["audio_config"] = ( + self.audio_config.to_dict() + if hasattr(self.audio_config, "to_dict") + else self.audio_config + ) + output["language_config"] = ( + self.language_config.to_dict() + if hasattr(self.language_config, "to_dict") + else self.language_config + ) + output["adapter_hidden_size"] = self.adapter_hidden_size + output["ignore_index"] = self.ignore_index + output["deepstack_num_inject_layers"] = self.deepstack_num_inject_layers + return output + + +__all__ = ["MossMusicEncoderConfig", "MossMusicConfig"] diff --git a/toolkit/audio/moss_music/modeling.py b/toolkit/audio/moss_music/modeling.py new file mode 100644 index 0000000..4487678 --- /dev/null +++ b/toolkit/audio/moss_music/modeling.py @@ -0,0 +1,568 @@ +from typing import Any, List, Optional, Tuple +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F +from transformers.generation.utils import GenerationMixin +from transformers.modeling_outputs import BaseModelOutputWithPast, CausalLMOutputWithPast +from transformers.modeling_utils import PreTrainedModel +from transformers.models.qwen3.modeling_qwen3 import Qwen3DecoderLayer, Qwen3Model +from transformers.models.whisper.modeling_whisper import WhisperEncoderLayer + +from .configuration import MossMusicConfig, MossMusicEncoderConfig + + +class SinusoidsPositionEmbedding(nn.Module): + def __init__(self, num_positions: int, embedding_dim: int): + super().__init__() + max_timescale = 10000.0 + log_timescale_increment = math.log(max_timescale) / (embedding_dim // 2 - 1) + inv_timescales = torch.exp( + -log_timescale_increment * torch.arange(embedding_dim // 2).float() + ) + self.register_buffer("inv_timescales", inv_timescales, persistent=False) + + def forward(self, seq_len: int, device: torch.device): + scaled_time = torch.arange( + seq_len, device=device, dtype=self.inv_timescales.dtype + ).unsqueeze(1) * self.inv_timescales.unsqueeze(0) + sin_emb = torch.sin(scaled_time) + cos_emb = torch.cos(scaled_time) + pos_emb = torch.cat([sin_emb, cos_emb], dim=1) + return pos_emb.unsqueeze(0) + + +class MossMusicEncoder(nn.Module): + """Audio encoder with conv-stem downsampling and Whisper transformer layers.""" + + def __init__(self, config: MossMusicEncoderConfig): + super().__init__() + self.config = config + self.gelu = nn.GELU() + + self.conv1 = nn.Conv2d( + 1, + config.downsample_hidden_size, + kernel_size=(3, 3), + stride=(2, 2), + padding=(1, 1), + ) + self.conv2 = nn.Conv2d( + config.downsample_hidden_size, + config.downsample_hidden_size, + kernel_size=(3, 3), + stride=(2, 2), + padding=(1, 1), + ) + self.conv3 = nn.Conv2d( + config.downsample_hidden_size, + config.downsample_hidden_size, + kernel_size=(3, 3), + stride=(2, 2), + padding=(1, 1), + ) + + # 128 mel bins / 8 = 16 after 3 convs with stride=2 + self.stem_proj = nn.Linear(config.downsample_hidden_size * 16, config.d_model) + self.embed_positions = SinusoidsPositionEmbedding( + config.max_source_positions, config.d_model + ) + self.layers = nn.ModuleList( + [WhisperEncoderLayer(config) for _ in range(config.encoder_layers)] + ) + self.layer_norm = nn.LayerNorm(config.d_model, eps=config.layer_norm_eps) + self.out_proj = ( + nn.Linear(config.d_model, config.output_dim, bias=False) + if config.output_dim != config.d_model + else nn.Identity() + ) + + self.deepstack_encoder_layer_indexes = list( + config.deepstack_encoder_layer_indexes or [] + ) + self._deepstack_capture_map = { + layer_idx: capture_idx + for capture_idx, layer_idx in enumerate(self.deepstack_encoder_layer_indexes) + } + + self.n_window = int(config.n_window) + self.chunk_frames = int(self.n_window * 2) + self.conv_chunksize = int(config.conv_chunksize) + + @property + def dtype(self) -> torch.dtype: + return self.conv1.weight.dtype + + @staticmethod + def _compute_downsampled_length(lengths: torch.Tensor) -> torch.Tensor: + def conv_out_len(L): + return (L - 1) // 2 + 1 + + return conv_out_len(conv_out_len(conv_out_len(lengths))) + + def _encode_chunk_batch( + self, + input_features: torch.Tensor, + seq_lengths: torch.Tensor, + ) -> Tuple[torch.Tensor, List[torch.Tensor]]: + """Encode a batch of (already padded) chunks through the conv stem and + transformer layers. Returns (last_hidden, ordered_deepstack_hidden_states). + """ + if input_features.dim() == 2: + input_features = input_features.unsqueeze(0) + + downsampled_lengths = self._compute_downsampled_length(seq_lengths) + + # [B, n_mels, T] -> [B, 1, n_mels, T] + x = input_features.unsqueeze(1) + x = self.gelu(self.conv1(x)) + x = self.gelu(self.conv2(x)) + x = self.gelu(self.conv3(x)) + + # [B, C, F, T] -> [B, T, C*F] + x = x.permute(0, 3, 1, 2).contiguous().flatten(2) + x = self.stem_proj(x) + + max_len = int(downsampled_lengths.max().item()) + if x.size(1) > max_len: + x = x[:, :max_len, :] + + positions = self.embed_positions(x.shape[1], x.device) + x = x + positions.to(x.dtype) + + padding_mask = ( + torch.arange(x.size(1), device=x.device)[None, :] + >= downsampled_lengths[:, None] + ) + attention_mask = (1.0 - (~padding_mask).to(dtype=x.dtype)) * torch.finfo( + x.dtype + ).min + attention_mask = attention_mask.unsqueeze(1).unsqueeze(1) + + deepstack_hidden_states: List[Optional[torch.Tensor]] = [None] * len( + self.deepstack_encoder_layer_indexes + ) + for layer_idx, layer in enumerate(self.layers): + x = layer(x, attention_mask) + capture_idx = self._deepstack_capture_map.get(layer_idx) + if capture_idx is not None: + deepstack_hidden_states[capture_idx] = x + + x = self.layer_norm(x) + x = self.out_proj(x) + + ordered_deepstack_hidden_states = [ + h for h in deepstack_hidden_states if h is not None + ] + if not isinstance(self.out_proj, nn.Identity): + ordered_deepstack_hidden_states = [ + self.out_proj(h) for h in ordered_deepstack_hidden_states + ] + return x, ordered_deepstack_hidden_states + + def forward( + self, + input_features: torch.Tensor, + feature_lens: Optional[torch.Tensor] = None, + output_deepstack_hidden_states: bool = True, + ) -> BaseModelOutputWithPast: + if input_features.dim() == 3: + if feature_lens is None: + feature_lens = torch.full( + (input_features.size(0),), + input_features.size(-1), + dtype=torch.long, + device=input_features.device, + ) + else: + feature_lens = feature_lens.to( + device=input_features.device, dtype=torch.long + ) + valid_chunks = [ + input_features[i, :, : int(feature_lens[i].item())] + for i in range(int(input_features.shape[0])) + ] + input_features = torch.cat(valid_chunks, dim=1) + elif input_features.dim() != 2: + raise ValueError( + f"Expected [n_mels, T] or [B, n_mels, T], got {tuple(input_features.shape)}." + ) + + if feature_lens is None: + feature_lens = torch.tensor( + [int(input_features.shape[1])], + device=input_features.device, + dtype=torch.long, + ) + else: + feature_lens = feature_lens.to( + device=input_features.device, dtype=torch.long + ) + + chunk_frames = int(self.chunk_frames) + chunk_num = torch.ceil( + feature_lens.to(torch.float32) / float(chunk_frames) + ).long() + chunk_lengths = torch.full( + (int(chunk_num.sum().item()),), + chunk_frames, + dtype=torch.long, + device=feature_lens.device, + ) + tail_chunk_index = F.pad(chunk_num, (1, 0), value=-1).cumsum(0)[1:] + chunk_lengths[tail_chunk_index] = feature_lens % chunk_frames + chunk_lengths[chunk_lengths == 0] = chunk_frames + + chunk_list = input_features.T.split(chunk_lengths.tolist(), dim=0) + padded_feature = nn.utils.rnn.pad_sequence( + chunk_list, batch_first=True + ).transpose(1, 2) + + feature_lens_after_cnn = self._compute_downsampled_length(chunk_lengths) + t_down_max = ( + int(feature_lens_after_cnn.max().item()) + if feature_lens_after_cnn.numel() > 0 + else 0 + ) + padded_mask_after_cnn = nn.utils.rnn.pad_sequence( + [ + torch.ones(int(L.item()), dtype=torch.bool, device=padded_feature.device) + for L in feature_lens_after_cnn + ], + batch_first=True, + ) + if padded_mask_after_cnn.shape[1] < t_down_max: + padded_mask_after_cnn = F.pad( + padded_mask_after_cnn, + (0, t_down_max - padded_mask_after_cnn.shape[1]), + value=False, + ) + + num_deepstack = len(self.deepstack_encoder_layer_indexes) + padded_embeds: List[torch.Tensor] = [] + deepstack_padded_embeds: List[List[torch.Tensor]] = [ + [] for _ in range(num_deepstack) + ] + for feat_chunk, len_chunk in zip( + padded_feature.split(self.conv_chunksize, dim=0), + chunk_lengths.split(self.conv_chunksize, dim=0), + ): + out, deepstack_outs = self._encode_chunk_batch(feat_chunk, len_chunk) + if out.shape[1] < t_down_max: + out = F.pad(out, (0, 0, 0, t_down_max - out.shape[1])) + padded_embeds.append(out) + if output_deepstack_hidden_states and num_deepstack > 0: + if len(deepstack_outs) != num_deepstack: + raise RuntimeError( + "Deepstack output count does not match configured layer indexes." + ) + for capture_idx, ds in enumerate(deepstack_outs): + if ds.shape[1] < t_down_max: + ds = F.pad(ds, (0, 0, 0, t_down_max - ds.shape[1])) + deepstack_padded_embeds[capture_idx].append(ds) + + if padded_embeds: + padded_embed = torch.cat(padded_embeds, dim=0) + else: + padded_embed = torch.empty( + (0, t_down_max, self.config.output_dim), + device=padded_feature.device, + ) + + valid_tokens = padded_embed[padded_mask_after_cnn] # [N_valid, D] + last_hidden_state = valid_tokens.unsqueeze(0) # [1, N_valid, D] + + deepstack_states: Optional[Tuple[torch.Tensor, ...]] = None + if output_deepstack_hidden_states and num_deepstack > 0: + collected: List[torch.Tensor] = [] + for chunks_list in deepstack_padded_embeds: + if chunks_list: + ds = torch.cat(chunks_list, dim=0) + collected.append(ds[padded_mask_after_cnn].unsqueeze(0)) + else: + collected.append( + torch.empty( + (1, 0, self.config.output_dim), + device=padded_feature.device, + dtype=padded_embed.dtype, + ) + ) + deepstack_states = tuple(collected) + + return BaseModelOutputWithPast( + last_hidden_state=last_hidden_state, + hidden_states=deepstack_states, + ) + + +class GatedMLP(nn.Module): + def __init__(self, input_size, hidden_size, output_size): + super().__init__() + self.gate_proj = nn.Linear(input_size, hidden_size, bias=False) + self.up_proj = nn.Linear(input_size, hidden_size, bias=False) + self.down_proj = nn.Linear(hidden_size, output_size, bias=False) + self.act_fn = nn.SiLU() + + def forward(self, x): + return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)) + + +class MossMusicPreTrainedModel(PreTrainedModel): + config_class = MossMusicConfig + config: MossMusicConfig + base_model_prefix = "" + supports_gradient_checkpointing = True + _no_split_modules = ["Qwen3DecoderLayer"] + _skip_keys_device_placement = ["past_key_values"] + _supports_flash_attn = True + _supports_sdpa = True + _supports_flex_attn = True + + _can_compile_fullgraph = False + _supports_attention_backend = True + _can_record_outputs = {"hidden_states": Qwen3DecoderLayer} + + +class MossMusicModel(MossMusicPreTrainedModel, GenerationMixin): + config_class = MossMusicConfig + _tied_weights_keys: List[str] = [] + + def __init__(self, config: MossMusicConfig): + super().__init__(config) + + self.audio_encoder = MossMusicEncoder(config.audio_config) + self.language_model = Qwen3Model(config.language_config) + + self.audio_adapter = GatedMLP( + input_size=config.audio_config.output_dim, + hidden_size=config.adapter_hidden_size, + output_size=config.language_config.hidden_size, + ) + + deepstack_k = len( + getattr(config.audio_config, "deepstack_encoder_layer_indexes", []) or [] + ) + if config.deepstack_num_inject_layers is not None: + deepstack_k = min(deepstack_k, int(config.deepstack_num_inject_layers)) + self.deepstack_audio_merger_list = nn.ModuleList( + [ + GatedMLP( + input_size=config.audio_config.output_dim, + hidden_size=config.adapter_hidden_size, + output_size=config.language_config.hidden_size, + ) + for _ in range(deepstack_k) + ] + ) + + self.vocab_size = config.language_config.vocab_size + self.lm_head = nn.Linear( + config.language_config.hidden_size, self.vocab_size, bias=False + ) + self.post_init() + + def get_input_embeddings(self): + return self.language_model.get_input_embeddings() + + def set_input_embeddings(self, value): + self.language_model.set_input_embeddings(value) + + def get_output_embeddings(self): + return self.lm_head + + def set_output_embeddings(self, new_embeddings): + self.lm_head = new_embeddings + + def get_audio_features(self, input_features, feature_lens): + audio_outputs = self.audio_encoder( + input_features=input_features, + feature_lens=feature_lens, + output_deepstack_hidden_states=True, + ) + deepstack = ( + list(audio_outputs.hidden_states) + if audio_outputs.hidden_states is not None + else None + ) + return audio_outputs.last_hidden_state, deepstack + + def _apply_deepstack_to_hidden_states( + self, + hidden_states: torch.Tensor, + audio_input_mask: torch.Tensor, + deepstack_embeds: torch.Tensor, + ) -> torch.Tensor: + audio_input_mask = audio_input_mask.to(hidden_states.device) + deepstack_embeds = deepstack_embeds.to(hidden_states.device, hidden_states.dtype) + flat = deepstack_embeds.reshape(-1, deepstack_embeds.shape[-1]) + hs = hidden_states.clone() + hs[audio_input_mask] = hs[audio_input_mask] + flat + return hs + + def _register_llm_deepstack_hooks( + self, + audio_input_mask: torch.Tensor, + deepstack_audio_embeds: List[torch.Tensor], + ): + if deepstack_audio_embeds is None or len(deepstack_audio_embeds) == 0: + return [] + + layers = getattr(self.language_model, "layers", None) + if layers is None: + raise RuntimeError( + "Qwen3Model does not expose `.layers`; cannot register DeepStack hooks." + ) + + num_inject = len(deepstack_audio_embeds) + handles = [] + + for layer_idx, layer in enumerate(layers): + if layer_idx >= num_inject: + break + + def _make_llm_hook(k: int): + def _hook(_module, _inputs, _output): + if isinstance(_output, (tuple, list)): + hs = _output[0] + new_hs = self._apply_deepstack_to_hidden_states( + hs, audio_input_mask, deepstack_audio_embeds[k] + ) + return (new_hs,) + tuple(_output[1:]) + else: + return self._apply_deepstack_to_hidden_states( + _output, audio_input_mask, deepstack_audio_embeds[k] + ) + + return _hook + + handles.append(layer.register_forward_hook(_make_llm_hook(layer_idx))) + + return handles + + def forward( + self, + input_ids: torch.LongTensor = None, + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[List[torch.FloatTensor]] = None, + inputs_embeds: Optional[torch.FloatTensor] = None, + labels: Optional[torch.LongTensor] = None, + use_cache: Optional[bool] = None, + audio_data: Optional[torch.FloatTensor] = None, + audio_data_seqlens: Optional[torch.Tensor] = None, + audio_input_mask: Optional[torch.Tensor] = None, + **kwargs: Any, + ) -> CausalLMOutputWithPast: + # generate() still passes return_dict; transformers 5 models ignore it + kwargs.pop("return_dict", None) + if inputs_embeds is None: + inputs_embeds = self.get_input_embeddings()(input_ids) + + hook_handles = [] + if audio_data is not None: + if audio_input_mask is None: + raise ValueError( + "audio_input_mask is required when audio_data is provided." + ) + + audio_embeds, deepstack = self.get_audio_features(audio_data, audio_data_seqlens) + audio_embeds = self.audio_adapter(audio_embeds) + + audio_token_count = int(audio_input_mask.to(torch.int32).sum().item()) + if audio_token_count != int(audio_embeds.shape[1]): + raise ValueError( + f"Audio token count mismatch: audio_input_mask has {audio_token_count} audio tokens, " + f"but audio_embeds has length {int(audio_embeds.shape[1])}." + ) + + mask_expanded = audio_input_mask.unsqueeze(-1).expand_as(inputs_embeds) + inputs_embeds = inputs_embeds.clone() + inputs_embeds.masked_scatter_(mask_expanded, audio_embeds) + + if deepstack is not None and len(self.deepstack_audio_merger_list) > 0: + deepstack_audio_embeds = [] + for i, x in enumerate(deepstack[: len(self.deepstack_audio_merger_list)]): + ds = self.deepstack_audio_merger_list[i](x) + if int(ds.shape[1]) != audio_token_count: + raise ValueError( + f"DeepStack audio seq_len mismatch at index {i}: " + f"expected {audio_token_count}, got {int(ds.shape[1])}." + ) + deepstack_audio_embeds.append(ds) + + try: + hook_handles = self._register_llm_deepstack_hooks( + audio_input_mask, deepstack_audio_embeds + ) + except Exception: + for h in hook_handles: + h.remove() + raise + + try: + outputs = self.language_model( + input_ids=None, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + inputs_embeds=inputs_embeds, + use_cache=use_cache, + **kwargs, + ) + finally: + for h in hook_handles: + h.remove() + + hidden_states = outputs[0] + logits = self.lm_head(hidden_states) + + loss = None + if labels is not None: + shift_logits = logits[..., :-1, :].contiguous() + shift_labels = labels[..., 1:].contiguous() + loss_fct = nn.CrossEntropyLoss(ignore_index=self.config.ignore_index) + shift_logits = shift_logits.view(-1, self.config.language_config.vocab_size) + shift_labels = shift_labels.view(-1) + shift_labels = shift_labels.to(shift_logits.device) + loss = loss_fct(shift_logits, shift_labels) + + return CausalLMOutputWithPast( + loss=loss, + logits=logits, + past_key_values=outputs.past_key_values, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) + + def prepare_inputs_for_generation( + self, + input_ids, + next_sequence_length=None, + past_key_values=None, + attention_mask=None, + inputs_embeds=None, + is_first_iteration=False, + **kwargs, + ): + # audio is encoded once into the prompt embeds; re-sending it on decode + # steps would re-run the encoder against a stale audio_input_mask + if not is_first_iteration: + for key in ("audio_data", "audio_data_seqlens", "audio_input_mask"): + kwargs.pop(key, None) + return super().prepare_inputs_for_generation( + input_ids, + next_sequence_length=next_sequence_length, + past_key_values=past_key_values, + attention_mask=attention_mask, + inputs_embeds=inputs_embeds, + is_first_iteration=is_first_iteration, + **kwargs, + ) + + +__all__ = [ + "MossMusicEncoderConfig", + "MossMusicConfig", + "MossMusicModel", +] diff --git a/toolkit/audio/moss_music/processing.py b/toolkit/audio/moss_music/processing.py new file mode 100644 index 0000000..46e017c --- /dev/null +++ b/toolkit/audio/moss_music/processing.py @@ -0,0 +1,250 @@ +import re +from dataclasses import dataclass +from typing import List, Optional, Sequence, Union + +import numpy as np +import torch +from transformers import BatchEncoding, Qwen2Tokenizer + + +@dataclass +class MelConfig: + mel_sr: int = 16000 + mel_dim: int = 128 + mel_n_fft: int = 400 + mel_hop_length: int = 160 + mel_dtype: torch.dtype = torch.bfloat16 + + +class MossMusicProcessor: + """Builds MOSS-Music inputs: Whisper log-mel features plus a Qwen chat + prompt whose audio span is one placeholder token per 80ms of audio, with + the elapsed seconds written in as digit tokens every 2s (time markers).""" + + _AUDIO_SPAN_RE = re.compile(r"<\|audio_bos\|>(?:<\|AUDIO\|>)+<\|audio_eos\|>") + + def __init__( + self, + tokenizer, + *, + mel_config: Optional[MelConfig] = None, + enable_time_marker: bool = True, + audio_token_id: int = 151654, + audio_start_id: int = 151669, + audio_end_id: int = 151670, + ): + self.tokenizer = tokenizer + self.audio_token_id = int(audio_token_id) + self.audio_start_id = int(audio_start_id) + self.audio_end_id = int(audio_end_id) + self.enable_time_marker = bool(enable_time_marker) + self.config = mel_config or MelConfig() + self._whisper_feature_extractor = None + + self._digit_token_ids = {str(d): 15 + d for d in range(10)} + self.audio_tokens_per_second = 12.5 + self.time_marker_every_seconds = 2 + self.time_marker_every_audio_tokens = int( + self.audio_tokens_per_second * self.time_marker_every_seconds + ) + self.model_input_names = [ + "input_ids", + "attention_mask", + "audio_data", + "audio_data_seqlens", + ] + + @classmethod + def from_pretrained(cls, pretrained_model_name_or_path, **kwargs): + tokenizer_kwargs = {} + for key in ["cache_dir", "revision", "token", "local_files_only"]: + if key in kwargs: + tokenizer_kwargs[key] = kwargs[key] + # the checkpoint's tokenizer_config names Qwen2Tokenizer; loading it + # directly skips AutoTokenizer's remote-code prompt for the auto_map + tokenizer = Qwen2Tokenizer.from_pretrained( + pretrained_model_name_or_path, **tokenizer_kwargs + ) + return cls( + tokenizer, + mel_config=kwargs.pop("mel_config", None), + enable_time_marker=kwargs.pop("enable_time_marker", True), + audio_token_id=kwargs.pop("audio_token_id", 151654), + audio_start_id=kwargs.pop("audio_start_id", 151669), + audio_end_id=kwargs.pop("audio_end_id", 151670), + ) + + @staticmethod + def _conv3_downsample_len(raw_mel_len: int) -> int: + def conv_out_len(length: int) -> int: + return (length - 1) // 2 + 1 + + return conv_out_len(conv_out_len(conv_out_len(int(raw_mel_len)))) + + def _get_whisper_feature_extractor(self): + if self._whisper_feature_extractor is not None: + return self._whisper_feature_extractor + + from transformers.models.whisper.feature_extraction_whisper import ( + WhisperFeatureExtractor, + ) + + self._whisper_feature_extractor = WhisperFeatureExtractor( + feature_size=int(self.config.mel_dim), + sampling_rate=int(self.config.mel_sr), + hop_length=int(self.config.mel_hop_length), + n_fft=int(self.config.mel_n_fft), + ) + return self._whisper_feature_extractor + + def _extract_mel(self, audio: Union[np.ndarray, torch.Tensor]) -> torch.Tensor: + if isinstance(audio, torch.Tensor): + audio = audio.detach().cpu().numpy() + wav_np = np.ascontiguousarray(audio, dtype=np.float32) + if wav_np.ndim == 2: + wav_np = wav_np[0] + # the raw fbank pass has no 30s chunking, so full songs come through whole + fe = self._get_whisper_feature_extractor() + feats = fe._np_extract_fbank_features(wav_np[None, ...], device="cpu") + return torch.from_numpy(feats[0]).to(dtype=self.config.mel_dtype) + + def _get_time_marker_token_ids(self, second: int) -> List[int]: + return [self._digit_token_ids[digit] for digit in str(second)] + + def _build_audio_tokens_with_time_markers(self, audio_seq_len: int) -> List[int]: + total_duration_seconds = audio_seq_len / self.audio_tokens_per_second + num_full_seconds = int(total_duration_seconds) + + token_ids: List[int] = [] + audio_tokens_consumed = 0 + for second in range( + self.time_marker_every_seconds, + num_full_seconds + 1, + self.time_marker_every_seconds, + ): + marker_pos = ( + second // self.time_marker_every_seconds + ) * self.time_marker_every_audio_tokens + audio_segment_len = marker_pos - audio_tokens_consumed + if audio_segment_len > 0: + token_ids.extend([self.audio_token_id] * audio_segment_len) + audio_tokens_consumed += audio_segment_len + token_ids.extend(self._get_time_marker_token_ids(second)) + + remaining = audio_seq_len - audio_tokens_consumed + if remaining > 0: + token_ids.extend([self.audio_token_id] * remaining) + return token_ids + + def _build_audio_placeholder_ids(self, num_audio_tokens: int) -> List[int]: + if self.enable_time_marker: + return self._build_audio_tokens_with_time_markers(num_audio_tokens) + return [self.audio_token_id] * num_audio_tokens + + def _build_default_prompt(self, text: str, has_audio: bool) -> str: + if has_audio: + return ( + "<|im_start|>system\n" + "You are a helpful assistant.<|im_end|>\n" + "<|im_start|>user\n" + "<|audio_bos|><|AUDIO|><|audio_eos|>\n" + f"{text}<|im_end|>\n" + "<|im_start|>assistant\n" + ) + return ( + "<|im_start|>system\n" + "You are a helpful assistant.<|im_end|>\n" + "<|im_start|>user\n" + f"{text}<|im_end|>\n" + "<|im_start|>assistant\n" + ) + + def _build_input_from_prompt(self, prompt: str, token_lens: List[int]) -> List[int]: + spans = list(self._AUDIO_SPAN_RE.finditer(prompt)) + if len(spans) != len(token_lens): + raise ValueError( + f"Audio placeholder count mismatch: found {len(spans)} spans in text, " + f"but got {len(token_lens)} audio inputs." + ) + + input_ids: List[int] = [] + cursor = 0 + for index, match in enumerate(spans): + prefix = prompt[cursor : match.start()] + if prefix: + input_ids.extend(self.tokenizer.encode(prefix, add_special_tokens=False)) + + input_ids.append(self.audio_start_id) + input_ids.extend(self._build_audio_placeholder_ids(int(token_lens[index]))) + input_ids.append(self.audio_end_id) + cursor = match.end() + + suffix = prompt[cursor:] + if suffix: + input_ids.extend(self.tokenizer.encode(suffix, add_special_tokens=False)) + return input_ids + + def __call__( + self, + *, + text: Union[str, Sequence[str]], + audios: Optional[Sequence[Union[np.ndarray, torch.Tensor]]] = None, + audio: Optional[Sequence[Union[np.ndarray, torch.Tensor]]] = None, + return_tensors: str = "pt", + **kwargs, + ): + if isinstance(text, (list, tuple)): + if len(text) != 1: + raise ValueError(f"Expected text batch size 1, got {len(text)}") + prompt_text = text[0] + else: + prompt_text = text + + audio_list = audios if audios is not None else audio + audio_list = [] if audio_list is None else list(audio_list) + + mels: List[torch.Tensor] = [] + raw_lengths: List[int] = [] + token_lens: List[int] = [] + for one_audio in audio_list: + mel = self._extract_mel(one_audio) + raw_len = int(mel.shape[-1]) + mels.append(mel) + raw_lengths.append(raw_len) + token_lens.append(self._conv3_downsample_len(raw_len)) + + if mels: + max_length = max(raw_lengths) + audio_batch = torch.zeros( + (len(mels), self.config.mel_dim, max_length), + dtype=self.config.mel_dtype, + ) + for index, mel in enumerate(mels): + audio_batch[index, :, : mel.shape[-1]] = mel + seqlens_tensor = torch.tensor(raw_lengths, dtype=torch.long) + else: + audio_batch = None + seqlens_tensor = None + + if self._AUDIO_SPAN_RE.search(prompt_text) is None: + prompt_text = self._build_default_prompt(prompt_text, has_audio=bool(audio_list)) + input_ids_list = self._build_input_from_prompt(prompt_text, token_lens) + + input_ids_tensor = torch.tensor([input_ids_list], dtype=torch.long) + data = { + "input_ids": input_ids_tensor, + "attention_mask": torch.ones_like(input_ids_tensor), + } + if audio_batch is not None: + data["audio_data"] = audio_batch + data["audio_data_seqlens"] = seqlens_tensor + return BatchEncoding(data=data, tensor_type=return_tensors) + + def batch_decode(self, *args, **kwargs): + return self.tokenizer.batch_decode(*args, **kwargs) + + def decode(self, *args, **kwargs): + return self.tokenizer.decode(*args, **kwargs) + + +__all__ = ["MelConfig", "MossMusicProcessor"] diff --git a/ui/src/components/CaptionSimpleJob.tsx b/ui/src/components/CaptionSimpleJob.tsx index ef05ec1..4c0ee6d 100644 --- a/ui/src/components/CaptionSimpleJob.tsx +++ b/ui/src/components/CaptionSimpleJob.tsx @@ -283,6 +283,13 @@ const CaptionSimpleJob: React.FC = ({ jobConfig, setJobConfig, gpuIDs, se onChange={value => setJobConfig(value, 'config.process[0].caption.extract_vocals_before_transcribe')} /> )} + {additionalSections.includes('caption.keep_timestamps') && ( + setJobConfig(value, 'config.process[0].caption.keep_timestamps')} + /> + )} {additionalSections.includes('caption.thinking') && (