Skip to content

[pipelines] text-to-audio pipeline standardization - #39796

Open
gante wants to merge 8 commits into
huggingface:mainfrom
gante:tts_pipeline_standardization
Open

[pipelines] text-to-audio pipeline standardization#39796
gante wants to merge 8 commits into
huggingface:mainfrom
gante:tts_pipeline_standardization

Conversation

@gante

@gante gante commented Jul 30, 2025

Copy link
Copy Markdown
Contributor

What does this PR do?

⚠️ TODO before merging: after settling on the design, update pipeline usage in model docs accordingly.

This PR standardizes text-to-audio such that the following lines work with most* text-to-audio models:

from transformers import pipeline

synthesiser = pipeline("text-to-audio", "facebook/musicgen-large")
music = synthesiser("A low-fi song with a strong bassline")
synthesiser.save_audio(music, "test.wav")

On most* models where voice control is possible, it is possible to control it through the voice pipeline argument. The valid values for voice are model-dependent, and this argument is documented accordingly

from transformers import pipeline

tts_pipeline = pipeline("text-to-audio", "sesame/csm-1b")
audio = tts_pipeline("I just got bamboozled by my cat.", voice="1")
tts_pipeline.save_audio(audio, "test.wav")

Core changes

Prior to this PR, recent models with text-to-audio capabilities had no pipeline support (e.g. CSM, Dia, Qwen2.5 Omni). There was also not a standardized way to control the voice, if the model generates speech.

With this PR, TextToAudioPipeline:

  • Uses a processor whenever possible, automatically (as opposed to needing a flag to control it);
  • Takes a voice argument, which a few models can use out of the box. Whether a model can take voice in the pipeline is specified by properties of the model (if future models have the same properties, they will also have voice support);
  • Standardizes outputs: ALL models using the pipeline will return {"audio": <np.array with shape (audio_channels, sequence_length)>, "sampling_rate": <int>}. Different models return different array formats, and as a result we can see different saving scripts in the model cards -- the pipeline standardizes it;
  • Adds a function to save the audio, for convenience. This way, users don't need to learn about soundfile or alternatives. Uses the processor's save_audio whenever it is available.

Model support

Models with out-of-the-box pipeline support:

  • TTS Models with voice support:
    • CSM
    • Qwen2.5 Omni
    • Bark
  • TTS Models w/o voice support:
    • Dia -- voice is set in the prompt; needs chat templates? (we can hardcode it in the pipeline, though 🤔 )
    • FastSpeech2Conformer (model has no voice control)
    • SeamlessM4T and variants (model has no voice control)
    • Vits (model has no voice control)
  • TTA Models:
    • Musicgen and variants

Models that have special requirements:

  • SpeechT5 (requires speaker_embeddings argument; we could hide the complexity and accept voice: int, but the voice dataset most commonly used to pull the embeddings from isn't compatible with datasets==4.0.0 💔 )

@github-actions

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: auto, bark, csm, dia, fastspeech2_conformer, qwen2_5_omni, speecht5

@gante
gante requested review from ebezzam and eustlb July 30, 2025 16:17
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@ebezzam ebezzam added the Audio label Jul 30, 2025
audio (`dict[str, Any]` or `list[dict[str, Any]]`):
The audio returned by the pipeline. The dictionary (or each dictionary, if it is a list) should
contain two keys:
- `"audio"`: The audio waveform.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool to have saving function 👍

Maybe useful to mention that the audio is expected to have shape (nb_channels, audio_length)? (unless self.processor expects something else...)

And are we sure it isn't a tensor? Otherwise can also add something like this?

I guess synthesiser and tts_pipeline try to ensure this dimension order, but maybe useful in case people gets issues saving.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cf this comment

Comment on lines +257 to +259
voice (`str`, *optional*):
The voice to use for the generation, if the model is a text-to-speech model that supports multiple
voices. Please refer to the model docs in transformers for model-specific examples.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about something more generic like preset or style? At the moment, TTS models mainly make use of this feature (Bark calls it voice_preset) but maybe music generation models may use this feature at one point?

wdyt @eustlb ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree! preset is good IMO

# Or we need to postprocess to get the waveform
raise ValueError(
f"Unexpected keys in the audio output format: {audio.keys()}. Expected one of "
"`waveform` or `audio`"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and "sequences"

waveform = waveform.unsqueeze(0)
if len(waveform.shape) == 2: # (bsz, audio_length) -> (bsz, audio_channels=1, audio_length)
waveform = waveform.unsqueeze(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need a condition if self.audio_channels > 1 but missing batch dimension?

@eustlb eustlb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @gante for the good work! 🤗
My comments (open to discussion!!!, more details in the review):

  1. move from voice to preset, that should be passed blindly to the processor. This way, we offload all the logic to the processor, with the advantage of reducing maintenance and keeping in general enough to include new approaches in the future
  2. centralize saving function in audio_utils

self.parent.assertTrue(torch.allclose(output_from_past_slice, output_from_no_past_slice, atol=1e-3))


class BarkModelTester:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange this was here, that's an easy unbloat! 😅


@slow
@require_torch
def test_csm_model(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes yes yes! Thanks for adding it

output_audio: Optional[bool] = False,
**kwargs,
) -> Union[GenerateNonBeamOutput, torch.LongTensor]:
) -> Union[CsmGenerateOutput, torch.LongTensor, list[torch.FloatTensor]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch!

Comment on lines +195 to +197
if audio_value.ndim == 2 and audio_value.shape[0] in (1, 2):
# (nb_channels, audio_length) -> (audio_length, nb_channels), as expected by `soundfile`
audio_value = np.transpose(audio_value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get why you're adding this for CSM and Dia. The fact that the models are mono is more of a design choice than a parameter, and such things should not change.

Nevertheless, if we add a save_audio in audio_utils and rely on this function for saving audio in the processor, it will make sense to have it there, WDYT?

@ebezzam ebezzam Jul 31, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with centralizing/standardizing a save_audio function in audio_utils to avoid code duplication in different processors.

Another idea @eustlb, in several audio libraries I've seen the use of an Audio objects to group together useful properties and methods. WDYT? Here's an example below (quickly drafted with ChatGPT):

import soundfile as sf
import numpy as np
import os

class Audio:
    def __init__(self, waveform: np.ndarray, sample_rate: int, path: str = None):
        """
        Args:
            waveform: NumPy array of shape (channels, samples).
            sample_rate: Sampling rate in Hz.
            path: Optional original file path.
        """
        if waveform.ndim == 1:
            waveform = waveform[np.newaxis, :]  # Convert to (1, samples)
        elif waveform.shape[0] > waveform.shape[1]:
            # Assume it's (samples, channels) — transpose to (channels, samples)
            waveform = waveform.T

        self.waveform = waveform
        self.sample_rate = sample_rate
        self.path = path

    @classmethod
    def load(cls, path: str):
        """
        Load audio from file (converted to (channels, samples)).
        """
        if not os.path.isfile(path):
            raise FileNotFoundError(f"Audio file not found: {path}")

        data, sample_rate = sf.read(path, always_2d=True)  # (samples, channels)
        waveform = data.T  # Transpose to (channels, samples)
        return cls(waveform, sample_rate, path)

    def save(self, path: str):
        """
        Save audio to file (converted to (samples, channels)).
        """
        data = self.waveform.T  # Convert to (samples, channels)
        sf.write(path, data, self.sample_rate)
        self.path = path

    def duration_seconds(self) -> float:
        return self.waveform.shape[1] / self.sample_rate

    # can have others things for spectrogram, MFCC, augmentations, etc which can call functional versions

It can make working audio with much more convenient (not having to pass sample rate each time and channel dimension consistency). And can be used alongside functional approach

Comment on lines +142 to +146
def _get_sampling_rate(self, sampling_rate: Optional[int] = None) -> Optional[int]:
"""
Get the sampling rate from the model config, generation config, processor, or vocoder. Can be overridden by
`sampling_rate` in `__init__`.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be able to override it. Like the number of channels, that's more of a fixed parameter for a certain model than something that users would be able to play with.

Moreover, I don't see a model where it would make sense to have it in generation config (bark has it but it's silent 🤔).

Sampling rate should live in two places:

  1. processor → to prepare the inputs in the correct sampling rate
  2. config → to indicate on which sampling rate the model has been trained

priority order should therefore be model config > processor (+ special case of the vocoder)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with priority being in model config

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with not allowing overwrites! (the option to overwrite it was present before, so I left it -> I will remove it then 👍 )

sampling_rate = self.vocoder.config.sampling_rate

if self.sampling_rate is None:
if sampling_rate is None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detail but can be safely removed

Suggested change
if sampling_rate is None:

@@ -43,7 +43,7 @@

from ...cache_utils import Cache

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trusting you on that one!

Comment on lines +184 to +190
for attr in ["audio_channels", "num_audio_channels"]:
audio_channels = getattr(obj, attr, None)
if audio_channels is not None:
break
# WARNING: this default may cause issues in the future. We may need a more precise way of detecting the number
# of channels.
audio_channels = audio_channels or 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ok I think, stereo is exceptional. It's almost always not specified, with convention of being mono in this situation.

Comment on lines +211 to +218
voice = str(kwargs.pop("voice", "0"))
if not voice.isdigit():
logger.warning(
f"With {self.model.name_or_path}, the voice pipeline argument must be a digit. Got voice={voice}, "
"using voice=0 instead."
)
voice = "0"
conversation = [{"role": voice, "content": [{"type": "text", "text": text[0]}]}]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hum that's a bit misleading for CSM since the voice id does not refer to a predefined voice but rather a voice given a conventional context (cf this doc example - the model can be fined-tuned to do predefined voices though! cf here).

Here, setting a voice ID without providing a conversational context will just pick a random voice.

In the future I see multiple different ways of setting this voice, mainly either via a custom way of doing voice cloning, either by using a predefined voice identifier (number/ name/ etc). In both situations that's something that will end up being done in the processor.

What we can do is move from voice to preset, which will directly be passed to the processor and actualise current processors __call__ so that they can handle this preset kwarg.

output_list.append(output_dict)
return output_list

def save_audio(self, audio: Union[dict[str, Any], list[dict[str, Any]]], path: Union[str, list[str]]):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this to audio_utils.py?
The best would be to redefine AudioInput type as a dict with keys audio (typed as former AudioInput) and sampling_rate (float) so that we finally move from having audio in the lib as an array with sampling rate. This should not be breaking as it's only type hinting. This might require removing AudioInput in favor of the current type hinting to places where it's currently been used.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #39796 (comment) for idea on moving away from audio as independent array

@vasqu vasqu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for me so I can make a proper review after vacay :D

Regarding dia with voice - it does not use the voice (encoded into codebooks) within the encoder but as prefix to the decoder. Since it's an encoder-decoder architecture I'm not sure whether a chat template would really be suitable here. However, the processor should be able to take in audio and text to process it into the correct parameters, e.g. input ids and decoder input ids

@require_torch
def test_dia_model(self):
"""Tests Dia with the text-to-audio pipeline"""
speech_generator = pipeline(task="text-to-audio", model="buttercrab/dia-v1-1.6b", framework="pt")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
speech_generator = pipeline(task="text-to-audio", model="buttercrab/dia-v1-1.6b", framework="pt")
speech_generator = pipeline(task="text-to-audio", model="nari-labs/Dia-1.6B-0626", framework="pt")

Let's use the official ckpt imo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants