Feat -- Stable Audio 3#14119
Conversation
…n mask The diffusers Stable Audio 3 pipeline produced noise instead of music because the cross-attention conditioning was built differently from the reference: - The reference text conditioner uses padding_mode="learned": padded text positions (~245 of 256 for a short prompt) are filled with a trained `padding_embedding`, and the DiT attends to *all* positions (its cross-attention mask is intentionally disabled). Our pipeline instead zeroed padded positions and masked them out, wiping ~95% of the conditioning signal. Changes: - transformer_stable_audio3: add learned `prompt_padding_embedding`; in forward, replace masked text positions with it (in cond_token_dim space, before to_cond_embed) then attend to the full context, matching the reference. - pipeline_stable_audio_3: stop zeroing padded positions (the DiT now handles them); default `silence_padding_duration` to 6.0 (reference headroom default). - convert_..._to_diffusers: convert `conditioner.conditioners.prompt.padding_embedding` into the DiT. - scheduling_ping_pong: pin schedule endpoints (sigmas[0]=1.0, sigmas[-1]=0.0) to match the reference LogSNRShift endpoint preservation. - run_..._inference: coerce float16->float32 on CPU. - tests: update transformer state-dict expectations (523 tensors, add prompt_padding_embedding) and ping-pong sigma endpoint assertions. Verified against the reference with identical noise + identical conditioning: full 8-step ping-pong trajectories agree to 4e-6 (final latent). Removed the dev-only parity scripts (verify_*_dit_parity, verify_*_vae_parity) from the tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7f9ee7e to
1a6af0d
Compare
There was a problem hiding this comment.
🤗 Serge says:
Adds Stable Audio 3 end-to-end (SAME VAE, DiT, duration embedder, two schedulers, text-to-audio + inpaint pipelines, conversion script, docs, and tests). The modeling code is clean and well-documented, but there are a few correctness bugs, repo-convention violations, and dead-code issues to resolve before merging.
Correctness
StableAudio3InpaintPipeline: passingmask_start_seconds/mask_end_secondsas ints (e.g.mask_start_seconds=2) crashes with aTypeError— the code only checksisinstance(..., float)and then callslist(2).StableAudio3InpaintPipeline.__call__hardcodesnum_inference_steps: int = 8, while the baseStableAudio3Pipelinedeliberately defaults toNoneand falls back to the scheduler config (8 for ping-pong, 100 for Euler — the base pipeline even has a test asserting this). The inpaint default will silently under-sample the non-distilled Euler checkpoint.- The inpaint pipeline silently falls back to plain text-to-audio when no
audio/maskis given. This contradicts "do not guess user intent and silently correct behavior" — it should raise instead of degrading behind a warning. encode_durationaccepts per-prompt duration lists, butcheck_inputs(duration <= 0) and the latent-length computation (duration + silence_padding_duration) assume a scalar — a list either crashes or silently misbehaves. Remove the list path or make it consistent.
Packaging / imports
src/diffusers/pipelines/stable_audio_3/__init__.pyuses plain eager imports instead of the lazy-module +OptionalDependencyNotAvailablepattern used by every sibling package (e.g.stable_audio/__init__.py). Sincepipeline_stable_audio_3.pyimportstransformersat module level, importing this subpackage withouttransformersraises instead of surfacing the dummy objects.
Pipeline API
StableAudio3Pipeline.__call__uses the deprecatedcallback/callback_stepspattern; new pipelines should implementcallback_on_step_end.EXAMPLE_DOC_STRINGis defined but__call__lacks the@replace_example_docstringdecorator, so it's dead.
Duplication
PingPongSchedulerandStableAudio3EulerSchedulershare identicalset_timesteps,add_noise,scale_model_input,__len__, and near-identical docstrings. Link one to the other with# Copied fromheaders (andmake fix-copies) so they don't drift.
Dead / stray code
docs/source/en/api/pipelines/autodoc.mdis a stray two-line artifact not referenced in_toctree.yml— remove it.- Conversion script:
_check_shapesis never called;convert_ditreturns askippedlist that is never appended to yet reported in the summary print; redundantimport mathinsideconvert_duration_embedder; thesys.path.insert(0, repo_root / "src")hack is unnecessary (other conversion scripts assume an installeddiffusers). Also, the writtenmodel_index.jsonhard-codes_diffusers_version: "0.0.0.dev0"instead of the actual version. AutoencoderSAME.encode_audio/decode_audiochunked helpers aren't used by any pipeline — only by their own tests. Wire them into the pipelines or drop them._SoftNormBottleneck.encodecarries a training-time running-std update ported from the research repo; this is inference-only code and should be deleted.scripts/run_stable_audio_3_inference.pyis a one-off smoke test duplicating the docs example; there's no precedent forrun_*_inference.pyscripts inscripts/, and the conversion script already does a round-trip sanity check — drop it.
Tests
TestStableAudio3DiTProductionStructureinstantiates the full production config (embed_dim=1536,depth=24→ ~1.4B params, ~5.5 GB fp32) in the fast CPU suite. Mark it@slowor useinit_empty_weights/meta device for the structural check.test_speed_vs_referencein the SAME autoencoder tests imports the externalstable_audio_3research package, asserts nothing, and just prints a benchmark table — this is tooling, not a test, and should be removed.- The model tests (
AutoencoderSAME,StableAudio3DiTModel) are hand-rolledunittestclasses; deriving fromModelTesterMixin(andSchedulerCommonTestfor the schedulers) would get save/load, dtype, and offloading coverage for free. - The pipeline fast tests using
PipelineTesterMixinlook good, and the dummy-object/__init__registrations are consistent.
serge v0.1.0 · model: claude-fable-5 · 12 LLM turns · 17 tool calls · 450.2s · 881430 in / 34077 out tokens
| @@ -0,0 +1,3 @@ | |||
| from .modeling_stable_audio_3 import StableAudio3DurationEmbedder | |||
There was a problem hiding this comment.
Every other pipeline subpackage (e.g. stable_audio/__init__.py) uses the lazy _LazyModule structure with an OptionalDependencyNotAvailable guard for transformers. Because pipeline_stable_audio_3.py does from transformers import GemmaTokenizer, ... at module level, importing diffusers.pipelines.stable_audio_3 in an environment without transformers raises an ImportError instead of resolving to the dummy objects. Please follow the same guarded lazy-import pattern as the sibling packages.
| @@ -0,0 +1,2 @@ | |||
| # autodoc | |||
There was a problem hiding this comment.
This looks like an accidentally committed artifact — it contains only # autodoc and is not referenced in _toctree.yml. Please remove the file.
| # ────────────────────────────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| def _check_shapes(converted_sd: dict, model_sd: dict, label: str) -> tuple[int, int]: |
There was a problem hiding this comment.
_check_shapes is defined but never called anywhere in the script — dead code. Per the repo's coding rules (no unused code paths), either use it in the conversion flow or delete it.
| Returns (state_dict, list_of_skipped_keys). | ||
| """ | ||
| out = {} | ||
| skipped = [] |
There was a problem hiding this comment.
skipped is initialized here and returned, but nothing in convert_dit ever appends to it, so the "{len(dit_skipped)} skipped/no-op" message in convert() always prints 0 and is misleading. Either track keys that don't match any rename (as convert_vae does via _convert_trb_block) or drop the list and the tuple return.
|
|
||
| def convert_duration_embedder(ref_sd: dict, min_freq: float = 0.5, max_freq: float = 10000.0) -> dict: | ||
| """Build diffusers StableAudio3DurationEmbedder state dict.""" | ||
| import math |
There was a problem hiding this comment.
math is already imported at module top (line 54); this local re-import is redundant.
| import math |
| mask: Optional[torch.Tensor] = None, | ||
| mask_start_seconds: Optional[Union[float, List[float]]] = None, | ||
| mask_end_seconds: Optional[Union[float, List[float]]] = None, | ||
| num_inference_steps: int = 8, |
There was a problem hiding this comment.
This hardcoded default of 8 is inconsistent with the base StableAudio3Pipeline.__call__, which defaults num_inference_steps=None and falls back to self.scheduler.config.num_inference_steps (8 for PingPongScheduler, 100 for StableAudio3EulerScheduler). There is even a base-pipeline test (test_stable_audio_3_default_steps_follow_scheduler) asserting that behavior. With the Euler base checkpoint, this default will silently run only 8 steps. Use Optional[int] = None and the same scheduler-config fallback here.
| Returns: | ||
| [`~pipelines.AudioPipelineOutput`] with ``.audios``. | ||
| """ | ||
| if audio is None and mask is None and mask_start_seconds is None: |
There was a problem hiding this comment.
Per the repo rules ("Do not guess user intent and silently correct behavior... raise a concise error for unsupported cases rather than adding complex fallback logic"), an inpaint pipeline called without any inpainting inputs should raise a ValueError, not silently degrade to plain text-to-audio behind a warning. Users who want text-to-audio should use StableAudio3Pipeline directly. Also, the warning text says "unconditional" generation, which is inaccurate — it is still text-conditioned.
| self.sigmas: Optional[torch.Tensor] = None | ||
| self.timesteps: Optional[torch.Tensor] = None | ||
|
|
||
| def set_timesteps( |
There was a problem hiding this comment.
set_timesteps, add_noise, scale_model_input, and __len__ are byte-for-byte identical to PingPongScheduler (only step differs). Diffusers keeps this kind of duplication in sync via # Copied from diffusers.schedulers.scheduling_ping_pong.PingPongScheduler.set_timesteps headers + make fix-copies — please add them so the two schedules can't drift apart.
| exactly, so converted SA3 Medium weights load with ``strict=True``.""" | ||
|
|
||
| def test_state_dict_matches_checkpoint(self): | ||
| model = StableAudio3DiTModel(**PROD_CFG) |
There was a problem hiding this comment.
This instantiates the full production config (embed_dim=1536, depth=24 → roughly 1.4B parameters, ~5.5 GB in fp32) inside the fast CPU test suite, and all of those weights get randomly initialized just to inspect the state-dict keys/shapes. This will be very slow and memory-heavy in CI. Either mark the test @slow, or build the model on the meta device (e.g. accelerate.init_empty_weights()) since only names and shapes are checked.
| """ | ||
|
|
||
| @slow | ||
| def test_speed_vs_reference(self): |
There was a problem hiding this comment.
This is a benchmark, not a test: it imports the external stable_audio_3 research package, asserts nothing, and only prints timing tables (with a bare except Exception: pass at the end). Per the repo guidelines, research-repo comparison scaffolding should not be carried into the library — please remove this test (and the now-unused SMALL_CFG alias at line 72).
|
Hi, I just updated the code, fixing serge review's suggestion, thanks! |
|
Hi @buffett0323 , thanks for working on Stable Audio 3 support. The PR already covers a lot of important ground- conversion, SAME autoencoder, transformer, schedulers, text-to-audio, inpainting, docs, and tests! One workflow gap I noticed is native audio-to-audio variation. Stability’s reference library exposes this via model.generate(
init_audio=init_audio,
init_noise_level=0.5,
prompt="...",
duration=30,
)In this PR, I see text-to-audio and inpainting via Suggested improvement: either add native audio-to-audio support, e.g. One related base-checkpoint question: the PR docs mention Also, while testing the converter, I noticed two implementation issues:
|
dg845
left a comment
There was a problem hiding this comment.
Thanks for the PR! Left an initial design review :).
| # ────────────────────────────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| class _DynamicTanh(nn.Module): |
There was a problem hiding this comment.
| class _DynamicTanh(nn.Module): | |
| class DynamicTanh(nn.Module): |
nit: I don't think we need to prefix all of the helper module names with _, as the modeling code for other autoencoders doesn't do this. For example, see AutoencoderOobleck:
| − Attn(Q2,K2,V) — so that attention patterns common to both heads cancel out, improving focus. | ||
| """ | ||
|
|
||
| def __init__(self, dim: int, dim_heads: int = 128, use_differential: bool = True, qk_norm_eps: float = 1e-3): |
There was a problem hiding this comment.
The qk_norm_eps argument here doesn't appear to be used, is this intentional? If it isn't needed we should remove it.
| # ────────────────────────────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| class _Attention(nn.Module): |
There was a problem hiding this comment.
Would it be possible to refactor _Attention to use the diffusers attention module (AttentionModuleMixin) + attention processor pattern? For example, this is how Flux 2 implements the attention module:
diffusers/src/diffusers/models/transformers/transformer_flux2.py
Lines 494 to 496 in 208704a
and an attention processor:
diffusers/src/diffusers/models/transformers/transformer_flux2.py
Lines 326 to 328 in 208704a
| k1, k2 = self.k_norm(k1), self.k_norm(k2) | ||
| q1, q2 = _apply_rotary(q1, freqs), _apply_rotary(q2, freqs) | ||
| k1, k2 = _apply_rotary(k1, freqs), _apply_rotary(k2, freqs) | ||
| out = F.scaled_dot_product_attention(q1, k1, v, attn_mask=attn_mask) - F.scaled_dot_product_attention( |
There was a problem hiding this comment.
Could we use dispatch_attention_fn instead of F.scaled_dot_product_attention here? This would allow us to support different attention backends such as Flash Attention (with torch native SDPA still supported as the default "native" backend). For example, this is what Flux 2 does:
diffusers/src/diffusers/models/transformers/transformer_flux2.py
Lines 369 to 376 in 208704a
For reference, you can also look at the attention backend docs here.
| self.sampling_rate = sampling_rate | ||
| self.latent_dim = latent_dim |
There was a problem hiding this comment.
| self.sampling_rate = sampling_rate | |
| self.latent_dim = latent_dim |
I don't think we need to define these attributes as they are already registered to the config and available via e.g. self.config.sampling_rate.
| if output_type == "latent": | ||
| return AudioPipelineOutput(audios=latents) |
There was a problem hiding this comment.
nit: I think we should also support return_dict=False in the output_type="latent" case here. For example, we could do something like
if output_type == "latent":
audio = latents
else:
audio = self.vae.decode(latents).sample
...
...
if not return_dict:
return (audio,)
return AudioPipelineOutput(audios=audio)| from .pipeline_stable_audio_3 import StableAudio3Pipeline | ||
|
|
||
|
|
||
| class StableAudio3InpaintPipeline(StableAudio3Pipeline): |
There was a problem hiding this comment.
| class StableAudio3InpaintPipeline(StableAudio3Pipeline): | |
| class StableAudio3InpaintPipeline(DiffusionPipeline): |
StableAudio3InpaintPipeline should not inherit from StableAudio3Pipeline but rather should inherit directly from DiffusionPipeline. We can copy over shared methods such as encode_prompt, encode_duration, etc. with the # Copied from mechanism to sync the implementations:
class StableAudio3InpaintPipeline(StableAudio3Pipeline):
...
# Copied from diffusers.pipelines.stable_audio_3.pipeline_stable_audio3.StableAudio3Pipeline.encode_prompt
def encode_prompt(
self,
prompt: Optional[Union[str, List[str]]],
...,
):
...
...| @torch.no_grad() | ||
| def __call__( |
There was a problem hiding this comment.
nit: could we also have an example for the inpainting pipeline?
| pred_original_sample: Optional[torch.Tensor] = None | ||
|
|
||
|
|
||
| class StableAudio3EulerScheduler(SchedulerMixin, ConfigMixin): |
There was a problem hiding this comment.
Can we reuse the existing FlowMatchEulerDiscreteScheduler in place of StableAudio3EulerScheduler? I think supplying the log-SNR sigma schedule as a custom sigmas schedule to FlowMatchEulerDiscreteScheduler.set_timesteps could potentially be equivalent.
| pred_original_sample: Optional[torch.Tensor] = None | ||
|
|
||
|
|
||
| class PingPongScheduler(SchedulerMixin, ConfigMixin): |
There was a problem hiding this comment.
Similarly, could we also reuse FlowMatchEulerDiscreteScheduler in place of PingPongScheduler? I think using FlowMatchEulerDiscreteScheduler with stochastic_sampling=True might be equivalent as it does a similar denoise + re-noise to the next noise level operation like PingPongScheduler does:
diffusers/src/diffusers/schedulers/scheduling_flow_match_euler_discrete.py
Lines 506 to 509 in 208704a
What does this PR do?
Adds Stable Audio 3 Medium to Diffusers.
Fixes #13793
Before submitting
.ai/review-rules.md?documentation guidelines, and
here are tips on formatting docstrings.
Who can review?
@yiyixuxu @DN6 @sayakpaul