Files
SoundGame/tools/stable-audio-3/stable_audio_3/training/utils.py
T
SoundGame Dev f0f0a9b50e 初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线
- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
2026-08-29 13:00:34 +08:00

504 lines
19 KiB
Python

from pytorch_lightning.loggers import WandbLogger, CometLogger
from ..interface.aeiou import pca_point_cloud
import math
import wandb
import torch
import torch.nn.functional as F
import os
import typing as tp
def get_rank():
"""Get rank of current process."""
if "SLURM_PROCID" in os.environ:
return int(os.environ["SLURM_PROCID"])
if not torch.distributed.is_available() or not torch.distributed.is_initialized():
return 0
return torch.distributed.get_rank()
class InverseLR(torch.optim.lr_scheduler._LRScheduler):
"""Implements an inverse decay learning rate schedule with an optional exponential
warmup. When last_epoch=-1, sets initial lr as lr.
inv_gamma is the number of steps/epochs required for the learning rate to decay to
(1 / 2)**power of its original value.
Args:
optimizer (Optimizer): Wrapped optimizer.
inv_gamma (float): Inverse multiplicative factor of learning rate decay. Default: 1.
power (float): Exponential factor of learning rate decay. Default: 1.
warmup (float): Exponential warmup factor (0 <= warmup < 1, 0 to disable)
Default: 0.
final_lr (float): The final learning rate. Default: 0.
last_epoch (int): The index of last epoch. Default: -1.
"""
def __init__(self, optimizer, inv_gamma=1., power=1., warmup=0., final_lr=0.,
last_epoch=-1):
self.inv_gamma = inv_gamma
self.power = power
if not 0. <= warmup < 1:
raise ValueError('Invalid value for warmup')
self.warmup = warmup
self.final_lr = final_lr
super().__init__(optimizer, last_epoch)
def get_lr(self):
if not self._get_lr_called_within_step:
import warnings
warnings.warn("To get the last learning rate computed by the scheduler, "
"please use `get_last_lr()`.")
return self._get_closed_form_lr()
def _get_closed_form_lr(self):
warmup = 1 - self.warmup ** (self.last_epoch + 1)
lr_mult = (1 + self.last_epoch / self.inv_gamma) ** -self.power
return [warmup * max(self.final_lr, base_lr * lr_mult)
for base_lr in self.base_lrs]
def create_optimizer_from_config(optimizer_config, parameters):
"""Create optimizer from config.
Args:
parameters (iterable): parameters to optimize.
optimizer_config (dict): optimizer config.
Returns:
torch.optim.Optimizer: optimizer.
"""
optimizer_type = optimizer_config["type"]
if optimizer_type == "FusedAdam":
from deepspeed.ops.adam import FusedAdam
optimizer = FusedAdam(parameters, **optimizer_config["config"])
elif optimizer_type == "CAdamW":
from stable_audio_tools.training.optims import CAdamW
optimizer = CAdamW(parameters, **optimizer_config["config"])
elif optimizer_type == "CLion":
from stable_audio_tools.training.optims import CLion
optimizer = CLion(parameters, **optimizer_config["config"])
elif optimizer_type == "AdamW8bit":
from bitsandbytes.optim import AdamW8bit
optimizer = AdamW8bit(parameters, **optimizer_config["config"])
elif optimizer_type == "MuonAdamW":
from stable_audio_tools.training.optims import MuonAdamW
optimizer = MuonAdamW(parameters, **optimizer_config["config"])
else:
optimizer_fn = getattr(torch.optim, optimizer_type)
optimizer = optimizer_fn(parameters, **optimizer_config["config"])
return optimizer
def create_scheduler_from_config(scheduler_config, optimizer):
"""Create scheduler from config.
Args:
scheduler_config (dict): scheduler config.
optimizer (torch.optim.Optimizer): optimizer.
Returns:
torch.optim.lr_scheduler._LRScheduler: scheduler.
"""
if scheduler_config["type"] == "InverseLR":
scheduler_fn = InverseLR
else:
scheduler_fn = getattr(torch.optim.lr_scheduler, scheduler_config["type"])
scheduler = scheduler_fn(optimizer, **scheduler_config["config"])
return scheduler
def logger_project_name(logger) -> str:
if isinstance(logger, WandbLogger):
return logger.experiment.project
elif isinstance(logger, CometLogger):
return logger.name
def log_metric(logger, key, value, step=None):
from pytorch_lightning.loggers import WandbLogger, CometLogger
if isinstance(logger, WandbLogger):
logger.experiment.log({key: value})
elif isinstance(logger, CometLogger):
logger.experiment.log_metrics({key: value}, step=step)
def log_audio(logger, key, audio_path, sample_rate, caption=None, step=None):
if isinstance(logger, WandbLogger):
logger.experiment.log({key: wandb.Audio(audio_path, sample_rate=sample_rate, caption=caption)})
elif isinstance(logger, CometLogger):
logger.experiment.log_audio(audio_path, file_name=key, sample_rate=sample_rate, step=step)
def log_image(logger, key, img_data, step=None):
if isinstance(logger, WandbLogger):
logger.experiment.log({key: wandb.Image(img_data)})
elif isinstance(logger, CometLogger):
logger.experiment.log_image(img_data, name=key, step=step)
def log_point_cloud(logger, key, tokens, caption=None):
if isinstance(logger, WandbLogger):
point_cloud = pca_point_cloud(tokens)
logger.experiment.log({key: point_cloud})
elif isinstance(logger, CometLogger):
point_cloud = pca_point_cloud(tokens, rgb_float=True, output_type="points")
#logger.experiment.log_points_3d(scene_name=key, points=point_cloud)
def compute_per_elem_trim(conditioning, sample_rate, margin_seconds=5.0):
"""Compute per-element trim lengths from seconds_total in conditioning dicts.
Returns a list of trim lengths (in audio samples) or None if no seconds_total found.
"""
if not any("seconds_total" in c for c in conditioning):
return None
margin_samples = int(margin_seconds * sample_rate)
per_elem_trim = []
for c in conditioning:
if "seconds_total" in c:
per_elem_trim.append(int(c["seconds_total"] * sample_rate) + margin_samples)
else:
per_elem_trim.append(None)
return per_elem_trim
def trim_and_concat(x, per_elem_trim):
"""Per-element trim and concatenate along time axis.
Trims each batch element to its own length (from seconds_total + margin),
removing trailing padding silence before concatenation.
Args:
x: (b, d, n) tensor or list of (d, n) tensors
per_elem_trim: list of trim lengths per element, or None for no trimming
"""
items = [x[i] for i in range(x.shape[0])] if isinstance(x, torch.Tensor) and x.dim() == 3 else x
if per_elem_trim is None:
return torch.cat(items, dim=-1)
parts = []
for i, elem in enumerate(items):
if per_elem_trim[i] is not None:
parts.append(elem[..., :min(per_elem_trim[i], elem.shape[-1])])
else:
parts.append(elem)
return torch.cat(parts, dim=-1)
class StaggeredLogger:
"""Accumulates log values over N steps and flushes averaged metrics.
Avoids per-step CUDA syncs (.item()) and prevents PL from caching
stale values between flush intervals by logging directly to the
experiment logger.
Usage in a PL module::
# In __init__:
self._staggered_logger = StaggeredLogger(every_n_steps=10)
# At end of training_step (pass detached tensors or Python scalars):
self._staggered_logger.log(log_dict, self)
"""
def __init__(self, every_n_steps: int = 10):
self.every_n_steps = every_n_steps
self._accum: dict = {}
self._counts: dict = {}
self._steps: int = 0
def log(self, log_dict: dict, module) -> None:
"""Accumulate *log_dict* and flush every *every_n_steps* calls.
NaN/inf values are handled at flush time: if an accumulated sum
is non-finite (due to a NaN step poisoning the window), that
metric is simply omitted from the flush. This avoids the
expensive per-step CUDA syncs that an eager ``isfinite`` check
would require.
Args:
log_dict: Mapping of metric names to detached GPU tensors or
Python scalars. Tensors are kept on-device until flush.
module: The ``pl.LightningModule`` instance (used for
``log_dict``, ``logger``, and ``global_step``).
"""
for k, v in log_dict.items():
val = v.detach() if torch.is_tensor(v) else v
if k not in self._accum:
self._accum[k] = val
self._counts[k] = 1
else:
self._accum[k] = self._accum[k] + val
self._counts[k] += 1
self._steps += 1
if self._steps >= self.every_n_steps:
flushed = {}
for k, v in self._accum.items():
c = self._counts[k]
if c > 0:
val = v.item() / c if torch.is_tensor(v) else v / c
# Skip non-finite values (NaN from a bad step poisoned this window)
if math.isfinite(val):
flushed[k] = val
# Update progress bar only (logger=False avoids PL caching stale values)
if flushed:
module.log_dict(flushed, prog_bar=True, on_step=True, logger=False)
# Log directly to experiment (bypasses PL metric caching)
if module.logger is not None:
module.logger.log_metrics(flushed, step=module.global_step)
self._accum = {}
self._counts = {}
self._steps = 0
def resize_padding_mask(padding_mask: torch.Tensor, target_length: int) -> torch.Tensor:
"""Resize a padding mask to target_length using ceiling-based length scaling.
Unlike F.interpolate(mode="nearest"), this ensures any target position
that partially overlaps valid audio is marked valid (rounds up).
Args:
padding_mask: (B, T) or (T,) tensor where 1/True = valid, 0/False = padding
target_length: desired output length
Returns:
Resized boolean mask with same ndim as input
"""
if padding_mask.ndim == 1:
valid_length = padding_mask.sum()
source_length = padding_mask.shape[0]
valid_target_length = torch.ceil(
valid_length.float() * target_length / source_length
).long().clamp(max=target_length)
positions = torch.arange(target_length, device=padding_mask.device)
return positions < valid_target_length
else:
valid_lengths = padding_mask.sum(dim=-1) # (B,)
source_length = padding_mask.shape[-1]
valid_target_lengths = torch.ceil(
valid_lengths.float() * target_length / source_length
).long().clamp(max=target_length)
positions = torch.arange(target_length, device=padding_mask.device).unsqueeze(0)
return positions < valid_target_lengths.unsqueeze(1)
def create_augmented_padding_mask(
padding_mask: torch.Tensor,
silence_extension_scale_seconds: float = 0.0,
sample_rate: int = 44100,
downsampling_ratio: int = 1,
) -> torch.Tensor:
"""
Augment padding mask by randomly extending the valid region to include silence.
This helps the model learn to handle silence at the end of audio by randomly
including some of the padded (silent) region as valid.
Uses an exponential distribution so most extensions are small (within ~scale seconds)
but can occasionally extend to the full sequence length.
Args:
padding_mask: Boolean tensor of shape (batch, seq_len) where True = valid audio
silence_extension_scale_seconds: Scale (mean) of exponential distribution in seconds.
~63% of samples within 1x scale, ~86% within 2x, ~95% within 3x.
sample_rate: Audio sample rate (used to convert seconds to tokens)
downsampling_ratio: Downsampling ratio from pretransform (to convert samples to latent tokens)
Returns:
Augmented padding mask of shape (batch, seq_len) where True = valid region (audio + extended silence)
"""
if silence_extension_scale_seconds <= 0:
return padding_mask
batch_size, seq_len = padding_mask.shape
device = padding_mask.device
# Find the last valid position for each batch element
valid_lengths = padding_mask.sum(dim=-1) # (batch,)
# Calculate scale in tokens for exponential distribution
scale_tokens = silence_extension_scale_seconds * sample_rate / downsampling_ratio
# Sample from exponential distribution (biased toward smaller values)
# Exponential: ~63% within 1x scale, ~86% within 2x, ~95% within 3x
random_extensions = torch.empty(batch_size, device=device).exponential_(lambd=1.0/scale_tokens)
random_extensions = random_extensions.long()
# Extend valid lengths, clamping to sequence length (allows extension to full padding)
augmented_lengths = torch.clamp(valid_lengths + random_extensions, max=seq_len)
# Create augmented mask: True where position < augmented_length
positions = torch.arange(seq_len, device=device).unsqueeze(0) # (1, seq_len)
augmented_mask = positions < augmented_lengths.unsqueeze(1) # (batch, seq_len)
return augmented_mask
def compute_normalized_mse(
pred: torch.Tensor,
target: torch.Tensor,
loss_mask: torch.Tensor,
loss_normalization: str = "none",
loss_norm_eps: float = 1e-6,
) -> torch.Tensor:
"""
Compute MSE normalized by detached target magnitude.
Prevents high-magnitude latents from dominating the loss,
ensuring quiet passages receive equal learning signal.
Normalization is only applied to valid (non-padded) regions.
Padded regions retain raw MSE for compatibility with mask_loss_weight.
Args:
pred: [B, C, T] predicted tensor
target: [B, C, T] target tensor
loss_mask: [B, T] boolean, True = valid (non-padded), or None
loss_normalization: One of "none", "timestep", "sample", "sample_channel"
loss_norm_eps: Epsilon for numerical stability
Returns:
Per-element MSE [B, C, T], normalized for signal, raw for padding
"""
mse = (pred - target) ** 2 # [B, C, T]
if loss_normalization == "none":
return mse
if loss_mask is None:
mask_expanded = torch.ones(pred.shape[0], 1, pred.shape[2], device=pred.device, dtype=torch.bool)
else:
mask_expanded = loss_mask.unsqueeze(1) # [B, 1, T]
with torch.no_grad():
if loss_normalization == "timestep":
# Var across channels at each timestep [B, 1, T]
mag_sq = torch.mean((target - torch.mean(target, dim=1, keepdim=True)) ** 2, dim=1, keepdim=True) + loss_norm_eps
else:
# For sample/sample_channel modes, exclude padding via NaN masking
masked_targets = torch.where(mask_expanded, target, float('nan'))
if loss_normalization == "sample":
sample_mean = torch.nanmean(masked_targets, dim=(1, 2), keepdim=True)
mag_sq = torch.nanmean((masked_targets - sample_mean) ** 2, dim=(1, 2), keepdim=True) + loss_norm_eps
elif loss_normalization == "sample_channel":
channel_mean = torch.nanmean(masked_targets, dim=2, keepdim=True)
mag_sq = torch.nanmean((masked_targets - channel_mean) ** 2, dim=2, keepdim=True) + loss_norm_eps
else:
raise ValueError(f"Unknown loss normalization mode: {loss_normalization}")
# Handle edge case where entire sample is padding (all NaN -> NaN variance)
mag_sq = torch.where(torch.isnan(mag_sq), torch.ones_like(mag_sq), mag_sq)
# Only normalize signal regions; keep padding MSE raw for mask_loss_weight compatibility
normalized_mse = mse / mag_sq
return torch.where(mask_expanded, normalized_mse, mse)
def compute_masked_loss(
loss_full: torch.Tensor,
loss_mask: torch.Tensor,
mask_padding_attention: bool,
mask_loss_weight: float = 0.0,
) -> tuple:
"""
Compute loss with separate signal and padding contributions.
Args:
loss_full: Full loss tensor of shape (B, C, T)
loss_mask: Boolean mask of shape (B, T) where True = signal, False = padding
mask_padding_attention: If True, only compute loss on signal region
mask_loss_weight: Weight for padding loss when mask_padding_attention is False
Returns:
Tuple of (loss, signal_mean, padding_mean) where:
- loss: The final scalar loss
- signal_mean: Mean loss on signal region (for logging)
- padding_mean: Mean loss on padding region (for logging)
"""
# Compute separate signal and padding losses
signal = torch.where(loss_mask.unsqueeze(1), loss_full, 0.0)
signal_sum = signal.sum(dim=(1, 2))
n_channels = loss_full.shape[1]
signal_count = loss_mask.sum(dim=1) * n_channels
padding = torch.where(~loss_mask.unsqueeze(1), loss_full, 0.0)
padding_sum = padding.sum(dim=(1, 2))
padding_count = (~loss_mask).sum(dim=1) * n_channels
if mask_padding_attention:
# When attention masking is on, only compute loss on signal region
# (padding saw no attention, so loss there is meaningless)
per_sample_loss = signal_sum / (signal_count + 1e-8)
loss = per_sample_loss.mean()
else:
# Weighted loss: signal + mask_loss_weight * padding
w = mask_loss_weight
denom = signal_count + w * padding_count + 1e-8
per_sample_loss = (signal_sum + w * padding_sum) / denom
loss = per_sample_loss.mean()
# Compute separate means for logging
signal_mean = signal.sum() / (signal_count.sum() + 1e-8)
padding_mean = padding.sum() / (padding_count.sum() + 1e-8)
return loss, signal_mean.detach(), padding_mean.detach()
def masked_mean(
values: torch.Tensor,
mask: torch.Tensor,
) -> torch.Tensor:
"""
Compute mean over masked (valid) positions only.
Args:
values: Tensor of shape (B, C, T) or (B, T)
mask: Boolean mask of shape (B, T) where True = valid, False = padding
Returns:
Scalar mean over valid positions only
"""
if values.ndim == 2:
# (B, T) -> add channel dim
values = values.unsqueeze(1)
# Expand mask to match values shape: (B, T) -> (B, 1, T)
mask_expanded = mask.unsqueeze(1)
# Zero out padding positions
masked_values = torch.where(mask_expanded, values, torch.zeros_like(values))
# Count valid positions (per sample, times channels)
n_channels = values.shape[1]
valid_count = mask.sum(dim=1) * n_channels # (B,)
# Sum over all dimensions and divide by valid count
per_sample_sum = masked_values.sum(dim=(1, 2)) # (B,)
per_sample_mean = per_sample_sum / (valid_count + 1e-8)
return per_sample_mean.mean()
def masked_sum(
values: torch.Tensor,
mask: torch.Tensor,
dim: tp.List[int],
) -> torch.Tensor:
"""
Compute sum over masked (valid) positions only.
Args:
values: Tensor of shape (B, C, T) or (B, T)
mask: Boolean mask of shape (B, T) where True = valid, False = padding
dim: Dimensions to sum over (should include the time dimension)
Returns:
Sum over valid positions, reduced over specified dims
"""
if values.ndim == 2:
# (B, T) -> add channel dim for consistency
mask_expanded = mask
else:
# (B, C, T) -> expand mask
mask_expanded = mask.unsqueeze(1)
# Zero out padding positions
masked_values = torch.where(mask_expanded, values, torch.zeros_like(values))
return masked_values.sum(dim=tuple(dim))