[Bugfix] Fix Breeze-TTS-2 repeated silence and audio EOS handling
This commit is contained in:
1 parent
a8434a60da
commit
5aadcbb0ab
10 files changed
+437
-33
No files matched your search
@@ -30,10 +30,20 @@ Four prompt modes are selected automatically from the request fields:
|
||||
| `ref_clone_tata` (clone) | `ref_audio` + `ref_text` + `input` | reference clip and its exact transcript |
|
||||
| `ref_edit_tata` (voice direction) | reference trio + `instructions` | keep the reference timbre, direct the delivery |
|
||||
|
||||
Current scope: greedy sampling with `cfg_scale=1.0`; CFG ≠ 1.0 and
|
||||
Current scope: greedy sampling with `cfg_scale=1.0` and a default
|
||||
`repetition_penalty=1.1` on generated codebook-0 tokens; CFG ≠ 1.0 and
|
||||
`negative_prompt` are rejected with explicit client errors until companion
|
||||
request support lands.
|
||||
|
||||
The [official inference example](https://github.com/breezeblue-ai/breeze-tts/blob/main/infer.py)
|
||||
also uses repetition penalty `1.1`, but its
|
||||
[runtime defaults](https://github.com/breezeblue-ai/breeze-tts/blob/main/breeze_infer/runtime.py)
|
||||
sample both the backbone and depth decoder with temperature `0.9` and top-k
|
||||
`50`. The [official voice-design examples](https://huggingface.co/BreezeBlue/Breeze-TTS-2#-voice-design)
|
||||
use CFG `4`. Those sampling and guidance settings are outside the current
|
||||
port's scope, so the examples below do not establish parity with the official
|
||||
voice-design outputs.
|
||||
|
||||
## Supported model contract
|
||||
|
||||
| Item | Value |
|
||||
@@ -42,9 +52,9 @@ request support lands.
|
||||
| Modes | plain, voice design (`instructions`), voice clone (`ref_audio` + `ref_text`), voice direction (reference + `instructions`) |
|
||||
| Languages | English and Chinese (model card) |
|
||||
| Reference audio | exactly one clip per request with its exact transcript; encoded by the bundled Qwen3-TTS tokenizer to 16 codebooks at 12.5 Hz |
|
||||
| Output | 24 kHz mono; `wav` or `pcm`; streaming SSE `speech.audio.delta` |
|
||||
| Output | 24 kHz mono; `wav` or `pcm`; raw audio streaming (`stream_format="audio"`) or SSE `speech.audio.delta` (`stream_format="sse"`) |
|
||||
| Length | prompt bounded by stage-0 `max_model_len=4096`; synthesis bounded by `max_new_tokens` codec frames (default 2048, about 164 s) |
|
||||
| Sampling | greedy (`temperature=0`) with `cfg_scale=1.0`; other guidance values and `negative_prompt` are rejected |
|
||||
| Sampling | greedy (`temperature=0`) with `cfg_scale=1.0` and `repetition_penalty=1.1`; other guidance values and `negative_prompt` are rejected |
|
||||
| Deployment profile | `vllm_omni/deploy/breeze_tts_2.yaml`: two stages on one GPU, async-chunk streaming with 8-frame codec chunks, stage 0 `gpu_memory_utilization=0.80`, stage 1 `0.15` |
|
||||
|
||||
## References
|
||||
@@ -106,7 +116,7 @@ Voice design (instruction only, no reference audio):
|
||||
curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input": "[laughs] Welcome to tonight's story time.",
|
||||
"input": "(laugh) Welcome to our story time.",
|
||||
"instructions": "A warm young woman, clear voice, lively delivery.",
|
||||
"response_format": "wav"
|
||||
}' --output breeze_design.wav
|
||||
@@ -139,7 +149,7 @@ curl -X POST http://localhost:8091/v1/audio/speech \
|
||||
}' --output breeze_direction.wav
|
||||
```
|
||||
|
||||
Streaming PCM (SSE `speech.audio.delta` events):
|
||||
Streaming raw PCM (`stream_format="audio"`):
|
||||
|
||||
```bash
|
||||
curl -N -X POST http://localhost:8091/v1/audio/speech \
|
||||
@@ -153,8 +163,9 @@ curl -N -X POST http://localhost:8091/v1/audio/speech \
|
||||
}' --output breeze_stream.pcm
|
||||
```
|
||||
|
||||
Expected results: finite, non-silent 24 kHz mono WAV files; streaming requests
|
||||
return incremental PCM deltas. On 1x L20 (greedy, single request after
|
||||
Expected results: finite, non-silent 24 kHz mono WAV files; the streaming example
|
||||
returns raw PCM bytes. Use `stream_format="sse"` for `speech.audio.delta`
|
||||
events containing base64-encoded audio. On 1x L20 (greedy, single request after
|
||||
warm-up) a few seconds of speech completes in roughly 3.4–3.8 s wall time.
|
||||
|
||||
## Notes
|
||||
@@ -166,6 +177,12 @@ warm-up) a few seconds of speech completes in roughly 3.4–3.8 s wall time.
|
||||
supported yet.
|
||||
- **CFG**: `guidance_scale`/`cfg_scale` must be `1.0`. Non-1.0 values and
|
||||
`negative_prompt` return a client error.
|
||||
- **Repetition penalty**: the default `1.1` matches upstream's penalty on
|
||||
generated codebook-0 tokens and discourages repeated silence. Override it
|
||||
with `extra_params: {"repetition_penalty": 1.0}` to disable it, or another
|
||||
finite positive value. Text and reference audio are excluded from its history.
|
||||
The legacy `tools/breeze_tts_2_upstream_golden.py` uses penalty `1.0`; set
|
||||
the same serving override when comparing against those golden frames.
|
||||
- **Length budget**: prompt length is bounded by stage 0 `max_model_len=4096`;
|
||||
synthesis length by `max_new_tokens` (default 2048 frames ≈ 164 s at the
|
||||
12.5 Hz codec frame rate) — set `max_new_tokens` for shorter caps.
|
||||
@@ -181,7 +198,7 @@ warm-up) a few seconds of speech completes in roughly 3.4–3.8 s wall time.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
| ------- | ------ | ----- |
|
||||
| Streaming output | ✓ | `async_chunk: true`, 8-frame inter-stage chunks, SSE `speech.audio.delta` PCM |
|
||||
| Streaming output | ✓ | `async_chunk: true`, 8-frame inter-stage chunks; raw audio or SSE `speech.audio.delta` |
|
||||
| Voice cloning | ✓ | one reference clip plus transcript per request |
|
||||
| Voice design / voice direction | ✓ | `instructions` field, with or without a reference |
|
||||
| Classifier-free guidance | ✗ | `cfg_scale` must be `1.0`; `negative_prompt` rejected (follow-up) |
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm import SamplingParams
|
||||
|
||||
from vllm_omni.entrypoints.openai.tts_adapters.breeze_tts_2 import BreezeTTS2Adapter
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.prompt_builder import (
|
||||
@@ -133,3 +134,24 @@ def test_non_greedy_sampling_overrides_are_rejected():
|
||||
assert "greedy" in adapter.validate(_request({"top_p": 0.9}))
|
||||
assert "greedy" in adapter.validate(_request({"top_k": 50}))
|
||||
assert "temperature must be a number" in adapter.validate(_request({"temperature": "warm"}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf"), "invalid"])
|
||||
def test_invalid_repetition_penalties_are_rejected(value):
|
||||
assert "repetition_penalty" in _adapter().validate(_request({"repetition_penalty": value}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0.9, 1.0, 1.1, "1.2"])
|
||||
@pytest.mark.parametrize("max_new_tokens", [None, 32])
|
||||
def test_repetition_penalty_override_reaches_standard_sampling_metadata(value, max_new_tokens):
|
||||
adapter = _adapter()
|
||||
request = _request({"repetition_penalty": value}, max_new_tokens=max_new_tokens)
|
||||
original = [SamplingParams(max_tokens=128, repetition_penalty=1.1)]
|
||||
|
||||
assert adapter.validate(request) is None
|
||||
updated = adapter.apply_sampling_overrides(original, request)
|
||||
|
||||
assert updated[0].repetition_penalty == float(value)
|
||||
assert updated[0].max_tokens == (128 if max_new_tokens is None else max_new_tokens)
|
||||
assert original[0].repetition_penalty == 1.1
|
||||
assert original[0].max_tokens == 128
|
||||
@@ -0,0 +1,122 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.sample.logits_processor import LogitsProcessors
|
||||
from vllm.v1.sample.metadata import SamplingMetadata
|
||||
|
||||
from tests.model_executor.models.breeze_tts_2.test_talker_state_machine import _depth_codes, _talker
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _model(first_score=11.0, second_score=10.5):
|
||||
model = _talker()
|
||||
model._async_chunk = True
|
||||
logits = torch.full((1, 9), -100.0)
|
||||
logits[0, 2:4] = torch.tensor([first_score, second_score])
|
||||
model._main_head_logits = lambda hidden: logits.expand(hidden.shape[0], -1).clone()
|
||||
model._generate_depth_codes = lambda hidden, code0: _depth_codes(model, hidden, code0)
|
||||
return model
|
||||
|
||||
|
||||
def _step(model, infos, penalties, *, no_penalties=False):
|
||||
return model.make_omni_output(
|
||||
torch.zeros(len(infos), 16),
|
||||
model_intermediate_buffer=infos,
|
||||
request_token_spans=[(i, i + 1) for i in range(len(infos))],
|
||||
sampling_metadata=SamplingMetadata(
|
||||
temperature=None,
|
||||
all_greedy=True,
|
||||
all_random=False,
|
||||
top_p=None,
|
||||
top_k=None,
|
||||
generators={},
|
||||
max_num_logprobs=None,
|
||||
no_penalties=no_penalties,
|
||||
frequency_penalties=torch.zeros(len(infos)),
|
||||
presence_penalties=torch.zeros(len(infos)),
|
||||
repetition_penalties=torch.tensor(penalties),
|
||||
# Text/reference conditioning must never be counted as generated
|
||||
# codec history, even if the scheduler ids overlap the codec ids.
|
||||
prompt_token_ids=torch.tensor([[2, 2]] * len(infos)),
|
||||
output_token_ids=[list(info.get("breeze_generated_code0_ids", [])) for info in infos],
|
||||
allowed_token_ids_mask=None,
|
||||
bad_words_token_ids={},
|
||||
logitsprocs=LogitsProcessors(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scores", [(11.0, 10.5), (-1.0, -1.05)])
|
||||
def test_penalty_applies_once_per_generated_token_and_respects_logit_sign(scores):
|
||||
model = _model(*scores)
|
||||
info = {"prompt_ids": torch.tensor([2]), "input_values": torch.tensor([[2, 3, 4, 5]])}
|
||||
chosen = []
|
||||
|
||||
for _ in range(4):
|
||||
output = _step(model, [info], [1.1])
|
||||
chosen.append(output.multimodal_outputs["codes"]["audio"][0][0, 0].item())
|
||||
|
||||
assert chosen == [2, 3, 2, 2]
|
||||
|
||||
|
||||
def test_request_history_and_penalty_are_independent():
|
||||
model = _model()
|
||||
first: dict[str, Any] = {}
|
||||
second: dict[str, Any] = {}
|
||||
_step(model, [first], [1.1])
|
||||
output = _step(model, [first, second], [1.1, 1.1])
|
||||
assert [codes[0, 0].item() for codes in output.multimodal_outputs["codes"]["audio"]] == [3, 2]
|
||||
|
||||
# Reverse the batch order; a request's history follows its state, while
|
||||
# each penalty follows the current sampling-metadata row.
|
||||
output = _step(model, [second, first], [1.1, 1.0])
|
||||
assert [codes[0, 0].item() for codes in output.multimodal_outputs["codes"]["audio"]] == [3, 2]
|
||||
|
||||
|
||||
def test_disabled_penalties_do_not_read_uninitialized_sampling_buffer():
|
||||
model = _model()
|
||||
info: dict[str, Any] = {}
|
||||
for _ in range(2):
|
||||
output = _step(model, [info], [float("nan")], no_penalties=True)
|
||||
assert output.multimodal_outputs["codes"]["audio"][0][0, 0].item() == 2
|
||||
|
||||
|
||||
def test_scheduler_logits_preserve_selected_frame_under_further_sampling_transforms():
|
||||
model = _model()
|
||||
info: dict[str, Any] = {}
|
||||
_step(model, [info], [1.1])
|
||||
output = _step(model, [info], [1.1])
|
||||
selected = output.multimodal_outputs["codes"]["audio"][0][0, 0].item()
|
||||
logits = model.compute_logits(torch.zeros(1, 16))
|
||||
|
||||
assert selected == 3
|
||||
assert logits.argmax(dim=-1).item() == selected
|
||||
assert torch.isfinite(logits).sum().item() == 1
|
||||
assert logits[0, selected].item() == 0.0
|
||||
# The scheduler may apply repetition again and rescale temperature. A
|
||||
# finite zero for the selected token preserves a valid point mass.
|
||||
for temperature in (0.1, 0.9, 2.0):
|
||||
probabilities = torch.softmax(logits / 1.1 / temperature, dim=-1)
|
||||
assert torch.isfinite(probabilities).all()
|
||||
assert probabilities[0, selected].item() == 1.0
|
||||
|
||||
|
||||
def test_repetition_penalty_can_select_eos_without_emitting_a_frame():
|
||||
model = _model()
|
||||
info: dict[str, Any] = {}
|
||||
_step(model, [info], [1.1])
|
||||
logits = torch.full((1, 9), -100.0)
|
||||
logits[0, 2] = 11.0
|
||||
logits[0, 8] = 10.5
|
||||
model._main_head_logits = lambda hidden: logits.expand(hidden.shape[0], -1).clone()
|
||||
|
||||
output = _step(model, [info], [1.1])
|
||||
|
||||
assert output.multimodal_outputs["codes"]["audio"][0].numel() == 0
|
||||
assert info["breeze_generated_frames"] == 1
|
||||
assert model.compute_logits(torch.zeros(1, 16)).argmax(dim=-1).item() == 8
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import yaml
|
||||
from vllm import SamplingParams
|
||||
from vllm.v1.core.sched.utils import check_stop
|
||||
from vllm.v1.request import Request, RequestStatus
|
||||
from vllm.v1.sample.logits_processor import BatchUpdate, MinTokensLogitsProcessor
|
||||
|
||||
from vllm_omni.config.stage_config import merge_sampling_constraints
|
||||
from vllm_omni.model_executor.models.breeze_tts_2.pipeline import BREEZE_TTS_2_PIPELINE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def talker_sampling_params():
|
||||
deploy_path = Path(__file__).parents[4] / "vllm_omni" / "deploy" / "breeze_tts_2.yaml"
|
||||
deploy = yaml.safe_load(deploy_path.read_text(encoding="utf-8"))
|
||||
params = SamplingParams(
|
||||
**merge_sampling_constraints(
|
||||
deploy["stages"][0]["default_sampling_params"],
|
||||
BREEZE_TTS_2_PIPELINE.stages[0].sampling_constraints,
|
||||
)
|
||||
)
|
||||
# Mirror the input processor: Breeze's text tokenizer uses EOS 1,
|
||||
# which is also an ordinary token in the generated codec vocabulary.
|
||||
params.update_from_generation_config({"eos_token_id": [1]}, eos_token_id=1)
|
||||
return params
|
||||
|
||||
|
||||
@pytest.mark.parametrize("codec_token, should_stop", [(0, False), (1, False), (2047, False), (2051, True)])
|
||||
def test_scheduler_stops_only_on_codec_eos(talker_sampling_params, codec_token, should_stop):
|
||||
request = Request(
|
||||
request_id="breeze-eos-test",
|
||||
prompt_token_ids=[7, 8, 9],
|
||||
sampling_params=talker_sampling_params,
|
||||
pooling_params=None,
|
||||
)
|
||||
request.append_output_token_ids([42, codec_token])
|
||||
request.status = RequestStatus.RUNNING
|
||||
|
||||
assert check_stop(request, max_model_len=4096) is should_stop
|
||||
if should_stop:
|
||||
assert request.status == RequestStatus.FINISHED_STOPPED
|
||||
assert request.stop_reason == 2051
|
||||
else:
|
||||
assert request.status == RequestStatus.RUNNING
|
||||
|
||||
|
||||
@pytest.mark.parametrize("codec_token", [1, 2051])
|
||||
def test_first_step_keeps_codec_token_and_eos_logits(talker_sampling_params, codec_token, monkeypatch):
|
||||
# vLLM's tensor helper uses a global flag rather than is_pin_memory,
|
||||
# and pinned allocations require a CUDA device even for this CPU test.
|
||||
monkeypatch.setattr("vllm.utils.torch_utils.PIN_MEMORY", False)
|
||||
# ignore_eos still leaves text EOS in all_stop_token_ids. Exercise the
|
||||
# actual processor so the first codec frame and immediate EOS stay valid.
|
||||
processor = MinTokensLogitsProcessor(None, device=torch.device("cpu"), is_pin_memory=False)
|
||||
processor.update_state(
|
||||
BatchUpdate(batch_size=1, removed=[], added=[(0, talker_sampling_params, None, [])], moved=[])
|
||||
)
|
||||
logits = torch.full((1, 2052), -float("inf"))
|
||||
logits[0, codec_token] = 0.0
|
||||
|
||||
processed = processor.apply(logits.clone())
|
||||
|
||||
assert torch.equal(processed, logits)
|
||||
assert processed.argmax(dim=-1).item() == codec_token
|
||||
|
||||
|
||||
def test_pipeline_disallows_minimum_tokens_that_mask_selected_codec_ids():
|
||||
params = merge_sampling_constraints({"min_tokens": 10}, BREEZE_TTS_2_PIPELINE.stages[0].sampling_constraints)
|
||||
assert params["min_tokens"] == 0
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import copy
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -20,7 +21,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
class _TransferManager:
|
||||
def __init__(self, chunk_frames: int = 4):
|
||||
self.connector = SimpleNamespace(config={"breeze_codec_chunk_frames": chunk_frames})
|
||||
self.code_prompt_token_ids = defaultdict(list)
|
||||
self.code_prompt_token_ids: defaultdict[str, list[torch.Tensor]] = defaultdict(list)
|
||||
|
||||
|
||||
class _Request:
|
||||
@@ -120,7 +121,8 @@ def test_stateful_codec_rejects_partial_frame_chunk():
|
||||
assert decoder.calls == []
|
||||
|
||||
|
||||
def test_stateful_codec_terminal_marker_pops_state_without_decoding():
|
||||
@pytest.mark.parametrize("token_counts", [(4, 0), (0, 4), (0, 4, 0, 8, 0)])
|
||||
def test_stateful_codec_terminal_marker_pops_state_without_decoding(token_counts):
|
||||
decoder = _Decoder()
|
||||
codec = object.__new__(BreezeTTS2MimiCodec)
|
||||
codec._async_chunk = True
|
||||
@@ -128,24 +130,114 @@ def test_stateful_codec_terminal_marker_pops_state_without_decoding():
|
||||
codec._codebook_size = 8
|
||||
codec._sample_rate = 24_000
|
||||
codec._audio_tokenizer = SimpleNamespace(model=SimpleNamespace(decoder=decoder))
|
||||
codec._decoder_state_cache = {"scheduler-live": {}, "scheduler-done": {}}
|
||||
request_ids = [f"scheduler-{index}" for index in range(len(token_counts))]
|
||||
codec._decoder_state_cache = {request_id: {} for request_id in request_ids}
|
||||
|
||||
output = codec.forward(
|
||||
torch.tensor([0, 1, 2, 3], dtype=torch.long),
|
||||
torch.arange(sum(token_counts), dtype=torch.long) % codec._codebook_size,
|
||||
runtime_additional_information=[
|
||||
# Plain ``finished`` covers the fallback for direct in-process
|
||||
# callers that do not go through the connector receiver.
|
||||
{"meta": {"request_id": "live", "finished": False}},
|
||||
{"meta": {"request_id": "done", "finished": True}},
|
||||
{"meta": {"finished": count == 0}}
|
||||
for count in token_counts
|
||||
],
|
||||
seq_token_counts=[4, 0],
|
||||
request_ids=["scheduler-live", "scheduler-done"],
|
||||
seq_token_counts=token_counts,
|
||||
request_ids=request_ids,
|
||||
)
|
||||
|
||||
# The empty terminal marker decodes nothing and releases its state; the
|
||||
# live request still decodes its own chunk.
|
||||
# Empty terminal markers release their state without consuming an output
|
||||
# slot belonging to another request, regardless of their batch position.
|
||||
assert len(decoder.calls) == 1
|
||||
assert decoder.calls[0][0][0] == 1
|
||||
assert [item.numel() for item in output.multimodal_outputs["model_outputs"]] == [1920, 0]
|
||||
assert list(codec._decoder_state_cache) == ["scheduler-live"]
|
||||
assert codec._decoder_state_cache["scheduler-live"]["prefix_frames"] == 0
|
||||
assert decoder.calls[0][0][0] == sum(count > 0 for count in token_counts)
|
||||
assert [item.numel() for item in output.multimodal_outputs["model_outputs"]] == [
|
||||
count // codec._num_codebooks * 1920 for count in token_counts
|
||||
]
|
||||
live_request_ids = [request_id for request_id, count in zip(request_ids, token_counts) if count]
|
||||
assert list(codec._decoder_state_cache) == live_request_ids
|
||||
assert all(codec._decoder_state_cache[request_id]["prefix_frames"] == 0 for request_id in live_request_ids)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_chunk_frames", [None, 4])
|
||||
def test_stateful_codec_advances_decoder_cache_after_sliding_window(monkeypatch, tmp_path, configured_chunk_frames):
|
||||
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_tokenizer import Qwen3TTSTokenizer
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import (
|
||||
Qwen3TTSTokenizerV2DecoderConfig,
|
||||
)
|
||||
from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import (
|
||||
Qwen3TTSTokenizerV2Decoder,
|
||||
)
|
||||
|
||||
torch.manual_seed(0)
|
||||
config = Qwen3TTSTokenizerV2DecoderConfig(
|
||||
codebook_size=32,
|
||||
hidden_size=16,
|
||||
latent_dim=16,
|
||||
codebook_dim=16,
|
||||
num_attention_heads=2,
|
||||
num_key_value_heads=2,
|
||||
intermediate_size=32,
|
||||
num_hidden_layers=1,
|
||||
num_quantizers=2,
|
||||
decoder_dim=32,
|
||||
upsample_rates=(2,),
|
||||
upsampling_ratios=(2,),
|
||||
sliding_window=72,
|
||||
)
|
||||
decoder = Qwen3TTSTokenizerV2Decoder(config).eval()
|
||||
# Codebooks initialize to zeros before checkpoint loading; populate them
|
||||
# so different codec IDs produce distinguishable cached frame histories.
|
||||
with torch.no_grad():
|
||||
for name, parameter in decoder.quantizer.named_parameters():
|
||||
if name.endswith("embedding_sum"):
|
||||
parameter.normal_()
|
||||
tokenizer = SimpleNamespace(model=SimpleNamespace(decoder=decoder, get_output_sample_rate=lambda: 24_000))
|
||||
monkeypatch.setattr(Qwen3TTSTokenizer, "from_pretrained", lambda *args, **kwargs: tokenizer)
|
||||
connector_extra = {} if configured_chunk_frames is None else {"breeze_codec_chunk_frames": configured_chunk_frames}
|
||||
chunk_frames = configured_chunk_frames or 8
|
||||
codec = object.__new__(BreezeTTS2MimiCodec)
|
||||
codec.vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(stage_connector_config={"extra": connector_extra}),
|
||||
device_config=SimpleNamespace(device="cpu"),
|
||||
)
|
||||
codec._tokenizer_path = tmp_path / "audio_tokenizer"
|
||||
codec._audio_tokenizer = None
|
||||
codec._async_chunk = True
|
||||
codec._num_codebooks = config.num_quantizers
|
||||
codec._codebook_size = config.codebook_size
|
||||
codec._decoder_state_cache = {}
|
||||
codec.load_weights(iter(()))
|
||||
|
||||
# At least two chunks after the decoder's rolling window expose a stale
|
||||
# cache even when the first padded chunk still produces plausible audio.
|
||||
total_frames = config.sliding_window + 2 * chunk_frames
|
||||
tail_frames = 3
|
||||
codes = torch.randint(0, config.codebook_size, (1, config.num_quantizers, total_frames + tail_frames))
|
||||
for end in range(chunk_frames, total_frames + 1, chunk_frames):
|
||||
flat = codes[0, :, end - chunk_frames : end].reshape(-1)
|
||||
codec.forward(
|
||||
flat,
|
||||
runtime_additional_information=[{"meta": {"stream_finished": False}}],
|
||||
seq_token_counts=[flat.numel()],
|
||||
request_ids=["scheduler-live"],
|
||||
)
|
||||
state = codec._decoder_state_cache["scheduler-live"]
|
||||
assert state["suffix_frames"] == min(end - chunk_frames, config.sliding_window) + chunk_frames
|
||||
with torch.inference_mode():
|
||||
expected_quantized = decoder.quantizer.decode(codes[..., max(0, end - config.sliding_window) : end])
|
||||
torch.testing.assert_close(state["suffix_quantized"], expected_quantized)
|
||||
|
||||
# A short terminal chunk must emit only its real frames, then release
|
||||
# state. Its waveform should agree with the unpadded single-request path.
|
||||
with torch.inference_mode():
|
||||
expected_tail = decoder.chunked_decode(codes[..., -tail_frames:], caches=copy.deepcopy(state))
|
||||
flat_tail = codes[0, :, -tail_frames:].reshape(-1)
|
||||
output = codec.forward(
|
||||
flat_tail,
|
||||
runtime_additional_information=[{"meta": {"stream_finished": True}}],
|
||||
seq_token_counts=[flat_tail.numel()],
|
||||
request_ids=["scheduler-live"],
|
||||
)
|
||||
waveform = output.multimodal_outputs["model_outputs"][0]
|
||||
assert waveform.numel() == tail_frames * decoder.total_upsample
|
||||
torch.testing.assert_close(waveform, expected_tail.reshape(-1), atol=1e-5, rtol=1e-4)
|
||||
assert codec._decoder_state_cache == {}
|
||||
@@ -32,8 +32,12 @@ stages:
|
||||
temperature: 0.0
|
||||
top_p: 1.0
|
||||
top_k: -1
|
||||
# Match upstream's penalty on generated codebook-0 history.
|
||||
repetition_penalty: 1.1
|
||||
max_tokens: 2048
|
||||
min_tokens: 1
|
||||
# min_tokens also masks the tokenizer's EOS 1 despite ignore_eos.
|
||||
# Keep valid codec token 1 and an immediate backbone EOS available.
|
||||
min_tokens: 0
|
||||
|
||||
- stage_id: 1
|
||||
max_num_seqs: 8
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"""Breeze-TTS-2 serving adapter for synchronous full-payload generation."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
@@ -49,6 +51,7 @@ class BreezeTTS2Adapter(ARTTSAdapter):
|
||||
return set()
|
||||
|
||||
def _load_codec_frame_rate(self) -> float | None:
|
||||
assert self.ctx.engine_client is not None
|
||||
config = self.ctx.engine_client.model_config.hf_config
|
||||
codec = getattr(config, "codec_config", None)
|
||||
if isinstance(codec, dict):
|
||||
@@ -99,6 +102,14 @@ class BreezeTTS2Adapter(ARTTSAdapter):
|
||||
greedy_error = _validate_greedy_sampling(extra_params)
|
||||
if greedy_error is not None:
|
||||
return greedy_error
|
||||
repetition_penalty = extra_params.get("repetition_penalty")
|
||||
if repetition_penalty is not None:
|
||||
try:
|
||||
repetition_penalty = float(repetition_penalty)
|
||||
except (TypeError, ValueError):
|
||||
return "Breeze-TTS-2 repetition_penalty must be finite and positive"
|
||||
if not math.isfinite(repetition_penalty) or repetition_penalty <= 0:
|
||||
return "Breeze-TTS-2 repetition_penalty must be finite and positive"
|
||||
if request.max_new_tokens is not None:
|
||||
if request.max_new_tokens < self.max_new_tokens_min:
|
||||
return f"max_new_tokens must be at least {self.max_new_tokens_min}"
|
||||
@@ -210,7 +221,16 @@ class BreezeTTS2Adapter(ARTTSAdapter):
|
||||
request_id: str | None = None,
|
||||
) -> list:
|
||||
del prompt, request_id
|
||||
return apply_max_new_tokens(sampling_params_list, request)
|
||||
sampling_params_list = apply_max_new_tokens(sampling_params_list, request)
|
||||
repetition_penalty = (request.extra_params or {}).get("repetition_penalty")
|
||||
if repetition_penalty is not None:
|
||||
# The generic speech handler copies extra_params to extra_args;
|
||||
# promote this value so the worker receives sampling metadata.
|
||||
# Only this scalar changes; preserve the caller's params without
|
||||
# copying nested state or the unchanged codec-stage parameters.
|
||||
sampling_params_list = [copy.copy(sampling_params_list[0]), *sampling_params_list[1:]]
|
||||
sampling_params_list[0].repetition_penalty = float(repetition_penalty)
|
||||
return sampling_params_list
|
||||
|
||||
|
||||
def _validate_greedy_sampling(extra_params: dict[str, Any]) -> str | None:
|
||||
|
||||
@@ -204,6 +204,7 @@ class BreezeTTS2MimiCodec(nn.Module):
|
||||
f"Breeze codec request id count {len(request_ids)} does not match input request count {len(requests)}"
|
||||
)
|
||||
valid: list[tuple[str, torch.Tensor, bool]] = []
|
||||
valid_indices: list[int] = []
|
||||
for index, request_ids_tensor in enumerate(requests):
|
||||
runtime_info = (
|
||||
runtime_additional_information[index]
|
||||
@@ -238,6 +239,7 @@ class BreezeTTS2MimiCodec(nn.Module):
|
||||
)
|
||||
frames = int(flat.numel()) // self._num_codebooks
|
||||
valid.append((state_id, flat.reshape(self._num_codebooks, frames), finished))
|
||||
valid_indices.append(index)
|
||||
|
||||
waveforms = [empty.to(device=ids.device)] * len(requests)
|
||||
sample_rates = [sr] * len(requests)
|
||||
@@ -247,7 +249,6 @@ class BreezeTTS2MimiCodec(nn.Module):
|
||||
multimodal_outputs={"model_outputs": waveforms, "sr": sample_rates},
|
||||
)
|
||||
states: list[dict[str, Any]] = []
|
||||
valid_indices: list[int] = []
|
||||
max_frames = max(codes.shape[-1] for _, codes, _ in valid)
|
||||
batched_codes = valid[0][1].new_zeros((len(valid), self._num_codebooks, max_frames))
|
||||
for row, (state_id, row_codes, _) in enumerate(valid):
|
||||
@@ -258,7 +259,6 @@ class BreezeTTS2MimiCodec(nn.Module):
|
||||
self._decoder_state_cache[state_id] = state
|
||||
state.setdefault("prefix_frames", 0)
|
||||
states.append(state)
|
||||
valid_indices.append(row)
|
||||
|
||||
decoded = decoder.batched_chunked_decode(
|
||||
batched_codes,
|
||||
@@ -353,6 +353,27 @@ class BreezeTTS2MimiCodec(nn.Module):
|
||||
device_map=str(device),
|
||||
)
|
||||
self._sample_rate = int(self._audio_tokenizer.model.get_output_sample_rate())
|
||||
if self._async_chunk:
|
||||
connector_config = getattr(self.vllm_config.model_config, "stage_connector_config", None)
|
||||
extra = (
|
||||
connector_config.get("extra", connector_config)
|
||||
if isinstance(connector_config, Mapping)
|
||||
else getattr(connector_config, "extra", None)
|
||||
)
|
||||
value = extra.get("breeze_codec_chunk_frames") if isinstance(extra, Mapping) else None
|
||||
try:
|
||||
chunk_frames = int(value) if value is not None else 8
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid Breeze codec_chunk_frames={value!r}") from exc
|
||||
if chunk_frames <= 0:
|
||||
raise ValueError(f"Breeze codec_chunk_frames must be positive, got {chunk_frames}")
|
||||
# Qwen treats chunks shorter than its configured window as
|
||||
# terminal tails and does not advance their rolling cache.
|
||||
# Match the producer's chunk size instead of its default 25.
|
||||
decoder = self._audio_tokenizer.model.decoder
|
||||
decoder._initial_codec_chunk_frames = chunk_frames
|
||||
decoder._incremental_chunk_frames = chunk_frames
|
||||
decoder._incremental_chunk_ramp = []
|
||||
for _ in weights:
|
||||
pass
|
||||
tokenizer_module = getattr(self._audio_tokenizer, "model", None)
|
||||
|
||||
@@ -13,6 +13,7 @@ generation order without importing HuggingFace GenerationMixin.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
@@ -367,10 +368,12 @@ class BreezeTTS2TalkerForGeneration(nn.Module):
|
||||
return None
|
||||
logits = self._main_head_logits(hidden_states)
|
||||
|
||||
# ``make_omni_output`` runs immediately before this hook. It records
|
||||
# requests whose previous hidden state selected EOS or reached the
|
||||
# frame budget, so the scheduler receives a real stop token instead
|
||||
# of depending on an arbitrary deploy-level max_tokens timeout.
|
||||
# ``make_omni_output`` already selected codebook 0 and completed its
|
||||
# frame. Return that same decision to the scheduler, including EOS.
|
||||
# A finite zero survives the sampler's repetition/temperature passes
|
||||
# without applying the repetition penalty twice or changing the frame.
|
||||
# These logits describe deterministic codec selection, not model
|
||||
# confidence; sampled-token logprobs are consequently zero.
|
||||
states = self._batch_state or []
|
||||
if states:
|
||||
for state, row_start, row_end in _iter_request_rows(
|
||||
@@ -378,10 +381,11 @@ class BreezeTTS2TalkerForGeneration(nn.Module):
|
||||
getattr(self, "_batch_state_spans", None),
|
||||
int(logits.shape[0]),
|
||||
):
|
||||
if state.get("breeze_force_eos", False):
|
||||
selected_code0 = state.get("breeze_selected_code0")
|
||||
if selected_code0 is not None:
|
||||
row = logits[row_start:row_end]
|
||||
row.fill_(float("-inf"))
|
||||
row[:, self.codebook_vocab_size] = 0.0
|
||||
row[:, int(selected_code0)] = 0.0
|
||||
return logits
|
||||
|
||||
def _main_head_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
@@ -443,6 +447,13 @@ class BreezeTTS2TalkerForGeneration(nn.Module):
|
||||
self._batch_state = info_items
|
||||
self._batch_state_spans = spans
|
||||
per_request_codes: list[torch.Tensor] = []
|
||||
# The runner passes the same per-request sampling metadata to this
|
||||
# hook and compute_logits, including offline SamplingParams overrides.
|
||||
# When no_penalties is true vLLM does not initialize this GPU buffer.
|
||||
sampling_metadata = kwargs.get("sampling_metadata")
|
||||
repetition_penalties = None
|
||||
if sampling_metadata is not None and not sampling_metadata.no_penalties:
|
||||
repetition_penalties = sampling_metadata.repetition_penalties.tolist()
|
||||
|
||||
for index, info in enumerate(info_items):
|
||||
if spans is not None and index < len(spans):
|
||||
@@ -458,11 +469,22 @@ class BreezeTTS2TalkerForGeneration(nn.Module):
|
||||
row_hidden = hidden[row_end - 1]
|
||||
if self._golden_dump_dir and bool(info.get("_omni_is_prefill", False)):
|
||||
self._dump_golden_prefill(info, row_hidden)
|
||||
main_logits = self._main_head_logits(row_hidden.reshape(1, -1))
|
||||
main_logits = self._main_head_logits(row_hidden.reshape(1, -1)).float()
|
||||
penalty = float(repetition_penalties[index]) if repetition_penalties is not None else 1.0
|
||||
if not math.isfinite(penalty) or penalty <= 0:
|
||||
raise ValueError("Breeze repetition_penalty must be finite and positive")
|
||||
# Match upstream: penalize each previously generated codebook-0
|
||||
# id once, excluding the text prompt and reference audio codes.
|
||||
history = info.setdefault("breeze_generated_code0_ids", [])
|
||||
if penalty != 1.0 and history:
|
||||
history_ids = torch.tensor(history, device=main_logits.device, dtype=torch.long)
|
||||
scores = main_logits[0, history_ids]
|
||||
main_logits[0, history_ids] = torch.where(scores < 0, scores * penalty, scores / penalty)
|
||||
code0 = int(torch.argmax(main_logits, dim=-1).item())
|
||||
generated_frames = int(info.get("breeze_generated_frames", 0) or 0)
|
||||
max_new_frames = int(info.get("breeze_max_new_frames", -1))
|
||||
terminal = code0 == self.codebook_vocab_size or (max_new_frames > 0 and generated_frames >= max_new_frames)
|
||||
info["breeze_selected_code0"] = self.codebook_vocab_size if terminal else code0
|
||||
if terminal:
|
||||
info["breeze_force_eos"] = True
|
||||
existing = info.get("breeze_audio_codes")
|
||||
@@ -476,6 +498,7 @@ class BreezeTTS2TalkerForGeneration(nn.Module):
|
||||
raise RuntimeError(f"Breeze main head produced invalid codec id {code0}")
|
||||
|
||||
frame = self._generate_depth_codes(row_hidden, code0)
|
||||
history.append(code0)
|
||||
info["breeze_current_frame"] = frame[0].detach()
|
||||
info["breeze_generated_frames"] = generated_frames + 1
|
||||
info["breeze_force_eos"] = False
|
||||
|
||||
@@ -28,6 +28,12 @@ BREEZE_TTS_2_PIPELINE = PipelineConfig(
|
||||
custom_process_next_stage_input_func=f"{_PROC}.talker2codec_full_payload",
|
||||
sampling_constraints={
|
||||
"detokenize": False,
|
||||
# Text EOS 1 is a valid codec token; only the backbone's
|
||||
# explicit codec EOS below may terminate audio generation.
|
||||
"ignore_eos": True,
|
||||
# The talker has already selected the emitted frame's token;
|
||||
# min_tokens must not mask that token in the sampler.
|
||||
"min_tokens": 0,
|
||||
"stop_token_ids": [2051],
|
||||
},
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user