Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,21 @@ sf.write("streaming.wav", wav, model.tts_model.sample_rate)
```
</details>

#### 📖 Long-Form Generation

For long scripts, `generate_long_form()` splits text into shorter segments and reuses the first generated segment as the
stable prompt/reference anchor for later segments.

```python
wav = model.generate_long_form(
text="First paragraph. Second paragraph. Third paragraph.",
control="Warm, steady narrator voice",
max_chars=120,
silence_ms=300,
)
sf.write("long_form.wav", wav, model.tts_model.sample_rate)
```

### CLI Usage

```bash
Expand All @@ -215,6 +230,14 @@ voxcpm design \
--control "Young female voice, warm and gentle, slightly smiling" \
--output out.wav

# Long-form voice design
voxcpm design \
--text "First paragraph. Second paragraph. Third paragraph." \
--control "Warm, steady narrator voice" \
--long-form \
--long-form-max-chars 120 \
--output long_form.wav

# Voice cloning (reference audio)
voxcpm clone \
--text "This is a voice cloning demo." \
Expand Down
22 changes: 22 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@ sf.write("streaming.wav", wav, model.tts_model.sample_rate)
```
</details>

#### 📖 长文本生成

对于较长文稿,`generate_long_form()` 会将文本切分为较短片段,并使用首段生成结果作为后续片段的稳定 prompt/reference 锚点,从而降低长句一次性自回归生成带来的音色漂移风险。

```python
wav = model.generate_long_form(
text="第一段内容。第二段内容。第三段内容。",
control="温暖稳定的旁白男声",
max_chars=120,
silence_ms=300,
)
sf.write("long_form.wav", wav, model.tts_model.sample_rate)
```

### 命令行使用

```bash
Expand All @@ -214,6 +228,14 @@ voxcpm design \
--control "年轻女声,温暖温柔,略带微笑" \
--output out.wav

# 长文本音色设计
voxcpm design \
--text "第一段内容。第二段内容。第三段内容。" \
--control "温暖稳定的旁白男声" \
--long-form \
--long-form-max-chars 120 \
--output long_form.wav

# 声音克隆(参考音频)
voxcpm clone \
--text "这是一个声音克隆的演示。" \
Expand Down
67 changes: 52 additions & 15 deletions src/voxcpm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ def validate_ranges(args, parser):
if not (1 <= args.inference_timesteps <= 100):
parser.error("--inference-timesteps must be between 1 and 100 (recommended: 4–30)")

if getattr(args, "long_form", False):
if args.long_form_max_chars <= 0:
parser.error("--long-form-max-chars must be a positive integer")
if args.long_form_silence_ms < 0:
parser.error("--long-form-silence-ms must be non-negative")

if args.lora_r <= 0:
parser.error("--lora-r must be a positive integer")

Expand Down Expand Up @@ -251,17 +257,33 @@ def _run_single(args, parser, *, text: str, output: str, prompt_text: str | None

model = load_model(args)

audio_array = model.generate(
text=text,
prompt_wav_path=args.prompt_audio,
prompt_text=prompt_text,
reference_wav_path=args.reference_audio,
cfg_value=args.cfg_value,
inference_timesteps=args.inference_timesteps,
normalize=args.normalize,
denoise=args.denoise
and (args.prompt_audio is not None or args.reference_audio is not None),
)
if args.long_form:
audio_array = model.generate_long_form(
text=text,
control=args.control,
prompt_wav_path=args.prompt_audio,
prompt_text=prompt_text,
reference_wav_path=args.reference_audio,
cfg_value=args.cfg_value,
inference_timesteps=args.inference_timesteps,
normalize=args.normalize,
denoise=args.denoise
and (args.prompt_audio is not None or args.reference_audio is not None),
max_chars=args.long_form_max_chars,
silence_ms=args.long_form_silence_ms,
)
else:
audio_array = model.generate(
text=build_final_text(text, args.control),
prompt_wav_path=args.prompt_audio,
prompt_text=prompt_text,
reference_wav_path=args.reference_audio,
cfg_value=args.cfg_value,
inference_timesteps=args.inference_timesteps,
normalize=args.normalize,
denoise=args.denoise
and (args.prompt_audio is not None or args.reference_audio is not None),
)

import soundfile as sf

Expand All @@ -273,17 +295,15 @@ def _run_single(args, parser, *, text: str, output: str, prompt_text: str | None

def cmd_design(args, parser):
validate_design_args(args, parser)
final_text = build_final_text(args.text, args.control)
return _run_single(
args, parser, text=final_text, output=args.output, prompt_text=None
args, parser, text=args.text, output=args.output, prompt_text=None
)


def cmd_clone(args, parser):
prompt_text = validate_clone_args(args, parser)
final_text = build_final_text(args.text, args.control)
return _run_single(
args, parser, text=final_text, output=args.output, prompt_text=prompt_text
args, parser, text=args.text, output=args.output, prompt_text=prompt_text
)


Expand Down Expand Up @@ -390,6 +410,23 @@ def _add_common_generation_args(parser):
parser.add_argument(
"--normalize", action="store_true", help="Enable text normalization"
)
parser.add_argument(
"--long-form",
action="store_true",
help="Generate long text as short prompted segments to reduce voice drift",
)
parser.add_argument(
"--long-form-max-chars",
type=int,
default=55,
help="Maximum characters per long-form segment (default: 55)",
)
parser.add_argument(
"--long-form-silence-ms",
type=int,
default=180,
help="Inserted silence between long-form segments in milliseconds (default: 180)",
)


def _add_prompt_reference_args(parser):
Expand Down
154 changes: 154 additions & 0 deletions src/voxcpm/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,57 @@
from .model.utils import next_and_close


def _split_long_text_for_tts(text: str, max_chars: int = 90) -> list[str]:
"""Split long spoken text on natural punctuation while keeping chunks short."""
if not isinstance(text, str) or not text.strip():
raise ValueError("target text must be a non-empty string")
if max_chars <= 0:
raise ValueError("max_chars must be a positive integer")

text = text.replace("\n", " ")
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= max_chars:
return [text]

pieces = re.findall(r"[^。!?!?;;.]+[。!?!?;;.]?", text)
if not pieces:
pieces = [text]

segments = []
current = ""
for piece in pieces:
piece = piece.strip()
if not piece:
continue
subpieces = [piece]
if len(piece) > max_chars:
subpieces = re.findall(r"[^,,、::]+[,,、::]?", piece) or [piece]

for subpiece in subpieces:
subpiece = subpiece.strip()
while len(subpiece) > max_chars:
if current:
segments.append(current)
current = ""
segments.append(subpiece[:max_chars])
subpiece = subpiece[max_chars:].strip()
if not subpiece:
continue
if current and len(current) + len(subpiece) > max_chars:
segments.append(current)
current = subpiece
else:
current += subpiece

if current:
segments.append(current)
return segments or [text]


def _as_mono_float32(wav: np.ndarray) -> np.ndarray:
return np.asarray(wav, dtype=np.float32).reshape(-1)


class VoxCPM:
def __init__(
self,
Expand Down Expand Up @@ -177,6 +228,109 @@ def generate(self, *args, **kwargs) -> np.ndarray:
def generate_streaming(self, *args, **kwargs) -> Generator[np.ndarray, None, None]:
return self._generate(*args, streaming=True, **kwargs)

def generate_long_form(
self,
text: str,
control: str = None,
prompt_wav_path: str = None,
prompt_text: str = None,
reference_wav_path: str = None,
cfg_value: float = 2.0,
inference_timesteps: int = 10,
min_len: int = 2,
max_len: int = 4096,
normalize: bool = False,
denoise: bool = False,
retry_badcase: bool = True,
retry_badcase_max_times: int = 3,
retry_badcase_ratio_threshold: float = 6.0,
max_chars: int = 55,
silence_ms: int = 180,
) -> np.ndarray:
"""Generate long-form speech as short anchored segments.

Later segments use the first generated segment as a fixed continuation
prompt so long inputs do not rely on one very long autoregressive pass.
An explicit external reference, when supplied, is kept as the stable
reference voice.
"""
segments = _split_long_text_for_tts(text, max_chars=max_chars)
control = (control or "").strip()

first_text = f"({control}){segments[0]}" if control else segments[0]

common_kwargs = {
"cfg_value": cfg_value,
"inference_timesteps": inference_timesteps,
"min_len": min_len,
"max_len": max_len,
"normalize": normalize,
"retry_badcase": retry_badcase,
"retry_badcase_max_times": retry_badcase_max_times,
"retry_badcase_ratio_threshold": retry_badcase_ratio_threshold,
}

if len(segments) == 1:
return _as_mono_float32(
self.generate(
text=first_text,
prompt_wav_path=prompt_wav_path,
prompt_text=prompt_text,
reference_wav_path=reference_wav_path,
denoise=denoise,
**common_kwargs,
)
)

import soundfile as sf

sample_rate = self.tts_model.sample_rate
silence_len = max(0, int(sample_rate * silence_ms / 1000.0))
silence = np.zeros(silence_len, dtype=np.float32)
outputs = []
can_use_reference = isinstance(self.tts_model, VoxCPM2Model)

with tempfile.TemporaryDirectory(prefix="voxcpm_long_form_") as tmp_dir:
def write_segment(name: str, wav: np.ndarray) -> str:
path = os.path.join(tmp_dir, name)
sf.write(path, _as_mono_float32(wav), sample_rate)
return path

first_wav = _as_mono_float32(
self.generate(
text=first_text,
prompt_wav_path=prompt_wav_path,
prompt_text=prompt_text,
reference_wav_path=reference_wav_path,
denoise=denoise,
**common_kwargs,
)
)
outputs.append(first_wav)

seed_prompt_path = write_segment("segment_001.wav", first_wav)
# prompt_text must match the spoken prompt audio. Voice-design control
# text guides generation but is not part of the spoken transcript.
seed_prompt_text = segments[0]

for index, segment in enumerate(segments[1:], start=2):
wav = _as_mono_float32(
self.generate(
text=segment,
prompt_wav_path=seed_prompt_path,
prompt_text=seed_prompt_text,
reference_wav_path=reference_wav_path if can_use_reference else None,
denoise=False,
**common_kwargs,
)
)
if silence_len:
outputs.append(silence)
outputs.append(wav)
write_segment(f"segment_{index:03d}.wav", wav)

return np.concatenate(outputs).astype(np.float32, copy=False)

def _generate(
self,
text: str,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def generate(self, **kwargs):
self.calls.append(kwargs)
return np.zeros(160, dtype=np.float32)

def generate_long_form(self, **kwargs):
self.calls.append({"long_form": True, **kwargs})
return np.zeros(160, dtype=np.float32)


def run_main(monkeypatch, argv):
monkeypatch.setattr(sys, "argv", ["voxcpm", *argv])
Expand Down Expand Up @@ -198,6 +202,36 @@ def test_design_subcommand_applies_control(monkeypatch, tmp_path):
assert dummy_model.calls[0]["reference_wav_path"] is None


def test_design_long_form_routes_control_and_segment_options(monkeypatch, tmp_path):
dummy_model = DummyModel()
monkeypatch.setattr(cli, "load_model", lambda args: dummy_model)
patch_soundfile_write(monkeypatch)

run_main(
monkeypatch,
[
"design",
"--text",
"hello",
"--control",
"warm female voice",
"--long-form",
"--long-form-max-chars",
"12",
"--long-form-silence-ms",
"250",
"--output",
str(tmp_path / "out.wav"),
],
)

assert dummy_model.calls[0]["long_form"] is True
assert dummy_model.calls[0]["text"] == "hello"
assert dummy_model.calls[0]["control"] == "warm female voice"
assert dummy_model.calls[0]["max_chars"] == 12
assert dummy_model.calls[0]["silence_ms"] == 250


def test_clone_subcommand_reads_prompt_file(monkeypatch, tmp_path):
dummy_model = DummyModel()
prompt_audio = tmp_path / "prompt.wav"
Expand Down
Loading