From 2fb15cdb18250c0ce8897ba08a45e005f6268698 Mon Sep 17 00:00:00 2001 From: Lee-take <210963840+Lee-take@users.noreply.github.com> Date: Sat, 23 May 2026 19:58:39 +0800 Subject: [PATCH 1/4] add long-form generation mode --- README.md | 24 ++++++ README_zh.md | 22 +++++ src/voxcpm/cli.py | 67 +++++++++++---- src/voxcpm/core.py | 156 +++++++++++++++++++++++++++++++++++ tests/test_cli.py | 34 ++++++++ tests/test_core_long_form.py | 102 +++++++++++++++++++++++ 6 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 tests/test_core_long_form.py diff --git a/README.md b/README.md index dc1a5448..66c47c65 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,22 @@ sf.write("streaming.wav", wav, model.tts_model.sample_rate) ``` +#### 📖 Long-Form Generation + +For long scripts, `generate_long_form()` splits text into shorter segments, uses the first generated segment as the +VoxCPM2 reference when no external reference is provided, and chains later segments with the previous segment as the +continuation prompt. + +```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 @@ -215,6 +231,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." \ diff --git a/README_zh.md b/README_zh.md index ec781e46..78ec45a9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -200,6 +200,20 @@ sf.write("streaming.wav", wav, model.tts_model.sample_rate) ``` +#### 📖 长文本生成 + +对于较长文稿,`generate_long_form()` 会将文本切分为较短片段;在没有外部参考音频时,会使用首段生成结果作为 VoxCPM2 参考音色,并让后续片段以上一段为续写提示,从而降低长句一次性自回归生成带来的音色漂移风险。 + +```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 @@ -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 "这是一个声音克隆的演示。" \ diff --git a/src/voxcpm/cli.py b/src/voxcpm/cli.py index f6d40d62..b5c3ad9e 100644 --- a/src/voxcpm/cli.py +++ b/src/voxcpm/cli.py @@ -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") @@ -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 @@ -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 ) @@ -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=90, + help="Maximum characters per long-form segment (default: 90)", + ) + parser.add_argument( + "--long-form-silence-ms", + type=int, + default=300, + help="Inserted silence between long-form segments in milliseconds (default: 300)", + ) def _add_prompt_reference_args(parser): diff --git a/src/voxcpm/core.py b/src/voxcpm/core.py index 81692c9c..498212f8 100644 --- a/src/voxcpm/core.py +++ b/src/voxcpm/core.py @@ -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, @@ -177,6 +228,111 @@ 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 = 90, + silence_ms: int = 300, + ) -> np.ndarray: + """Generate long-form speech as short anchored segments. + + The first generated segment becomes a stable reference voice for + VoxCPM2 when no external reference is supplied. Later segments use the + previous segment as the continuation prompt so long inputs do not rely + on one very long autoregressive pass. + """ + 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) + + stable_reference_path = reference_wav_path + if stable_reference_path is None and can_use_reference: + stable_reference_path = write_segment("seed_reference.wav", first_wav) + + previous_prompt_path = write_segment("segment_001.wav", first_wav) + previous_prompt_text = segments[0] + + for index, segment in enumerate(segments[1:], start=2): + wav = _as_mono_float32( + self.generate( + text=segment, + prompt_wav_path=previous_prompt_path, + prompt_text=previous_prompt_text, + reference_wav_path=stable_reference_path if can_use_reference else None, + denoise=False, + **common_kwargs, + ) + ) + if silence_len: + outputs.append(silence) + outputs.append(wav) + previous_prompt_path = write_segment(f"segment_{index:03d}.wav", wav) + previous_prompt_text = segment + + return np.concatenate(outputs).astype(np.float32, copy=False) + def _generate( self, text: str, diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b568b13..69cb0e55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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]) @@ -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" diff --git a/tests/test_core_long_form.py b/tests/test_core_long_form.py new file mode 100644 index 00000000..ea299364 --- /dev/null +++ b/tests/test_core_long_form.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +CORE_PATH = ROOT / "src" / "voxcpm" / "core.py" +PKG_NAME = "voxcpm_core_long_form_under_test" + + +pkg = types.ModuleType(PKG_NAME) +pkg.__path__ = [str(ROOT / "src" / "voxcpm")] +sys.modules[PKG_NAME] = pkg + +model_pkg = types.ModuleType(f"{PKG_NAME}.model") +model_pkg.__path__ = [str(ROOT / "src" / "voxcpm" / "model")] +sys.modules[f"{PKG_NAME}.model"] = model_pkg + +hf_stub = types.ModuleType("huggingface_hub") +hf_stub.snapshot_download = lambda *args, **kwargs: "/tmp/fake" +sys.modules.setdefault("huggingface_hub", hf_stub) + +model_stub = types.ModuleType(f"{PKG_NAME}.model.voxcpm") +model_stub.LoRAConfig = object +model_stub.VoxCPMModel = type("VoxCPMModel", (), {}) +sys.modules[f"{PKG_NAME}.model.voxcpm"] = model_stub + +model2_stub = types.ModuleType(f"{PKG_NAME}.model.voxcpm2") +model2_stub.VoxCPM2Model = type("VoxCPM2Model", (), {}) +sys.modules[f"{PKG_NAME}.model.voxcpm2"] = model2_stub + +utils_stub = types.ModuleType(f"{PKG_NAME}.model.utils") +utils_stub.next_and_close = lambda gen: next(gen) +sys.modules[f"{PKG_NAME}.model.utils"] = utils_stub + +spec = importlib.util.spec_from_file_location(f"{PKG_NAME}.core", CORE_PATH) +core = importlib.util.module_from_spec(spec) +sys.modules[f"{PKG_NAME}.core"] = core +assert spec.loader is not None +spec.loader.exec_module(core) + + +class _DummyTTSModel(core.VoxCPM2Model): + sample_rate = 10 + + +class _FakeVoxCPM(core.VoxCPM): + def __init__(self): + self.tts_model = _DummyTTSModel() + self.calls = [] + + def generate(self, text, **kwargs): + self.calls.append((text, kwargs.copy())) + return np.full(10, len(self.calls), dtype=np.float32) + + +def test_generate_long_form_uses_seed_reference_and_previous_prompt(): + model = _FakeVoxCPM() + + wav = model.generate_long_form( + text="First! Second! Third!", + control="steady narrator", + max_chars=7, + silence_ms=100, + cfg_value=1.5, + inference_timesteps=4, + ) + + assert len(model.calls) == 3 + assert model.calls[0][0] == "(steady narrator)First!" + assert model.calls[0][1]["prompt_wav_path"] is None + assert model.calls[0][1]["reference_wav_path"] is None + + assert model.calls[1][0] == "Second!" + assert model.calls[1][1]["prompt_text"] == "First!" + assert model.calls[1][1]["prompt_wav_path"] + assert model.calls[1][1]["reference_wav_path"] + + assert model.calls[2][0] == "Third!" + assert model.calls[2][1]["prompt_text"] == "Second!" + assert model.calls[2][1]["reference_wav_path"] == model.calls[1][1]["reference_wav_path"] + + assert wav.dtype == np.float32 + assert wav.shape == (32,) + + +def test_generate_long_form_delegates_single_segment_once(): + model = _FakeVoxCPM() + + wav = model.generate_long_form( + text="Short!", + control="steady narrator", + max_chars=20, + ) + + assert len(model.calls) == 1 + assert model.calls[0][0] == "(steady narrator)Short!" + assert wav.shape == (10,) From bddc8a2de042eb448a1bf11b446f973688427896 Mon Sep 17 00:00:00 2001 From: Lee-take <210963840+Lee-take@users.noreply.github.com> Date: Sun, 24 May 2026 10:00:47 +0800 Subject: [PATCH 2/4] stabilize long-form segment anchoring --- README.md | 5 ++--- README_zh.md | 2 +- src/voxcpm/core.py | 12 ++++++------ tests/test_core_long_form.py | 7 ++++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 66c47c65..5dfa3941 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,8 @@ sf.write("streaming.wav", wav, model.tts_model.sample_rate) #### 📖 Long-Form Generation -For long scripts, `generate_long_form()` splits text into shorter segments, uses the first generated segment as the -VoxCPM2 reference when no external reference is provided, and chains later segments with the previous segment as the -continuation prompt. +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( diff --git a/README_zh.md b/README_zh.md index 78ec45a9..4ecfd365 100644 --- a/README_zh.md +++ b/README_zh.md @@ -202,7 +202,7 @@ sf.write("streaming.wav", wav, model.tts_model.sample_rate) #### 📖 长文本生成 -对于较长文稿,`generate_long_form()` 会将文本切分为较短片段;在没有外部参考音频时,会使用首段生成结果作为 VoxCPM2 参考音色,并让后续片段以上一段为续写提示,从而降低长句一次性自回归生成带来的音色漂移风险。 +对于较长文稿,`generate_long_form()` 会将文本切分为较短片段,并使用首段生成结果作为后续片段的稳定 prompt/reference 锚点,从而降低长句一次性自回归生成带来的音色漂移风险。 ```python wav = model.generate_long_form( diff --git a/src/voxcpm/core.py b/src/voxcpm/core.py index 498212f8..8363c09b 100644 --- a/src/voxcpm/core.py +++ b/src/voxcpm/core.py @@ -256,6 +256,7 @@ def generate_long_form( """ 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 = { @@ -311,15 +312,15 @@ def write_segment(name: str, wav: np.ndarray) -> str: if stable_reference_path is None and can_use_reference: stable_reference_path = write_segment("seed_reference.wav", first_wav) - previous_prompt_path = write_segment("segment_001.wav", first_wav) - previous_prompt_text = segments[0] + seed_prompt_path = write_segment("segment_001.wav", first_wav) + seed_prompt_text = first_text for index, segment in enumerate(segments[1:], start=2): wav = _as_mono_float32( self.generate( text=segment, - prompt_wav_path=previous_prompt_path, - prompt_text=previous_prompt_text, + prompt_wav_path=seed_prompt_path, + prompt_text=seed_prompt_text, reference_wav_path=stable_reference_path if can_use_reference else None, denoise=False, **common_kwargs, @@ -328,8 +329,7 @@ def write_segment(name: str, wav: np.ndarray) -> str: if silence_len: outputs.append(silence) outputs.append(wav) - previous_prompt_path = write_segment(f"segment_{index:03d}.wav", wav) - previous_prompt_text = segment + write_segment(f"segment_{index:03d}.wav", wav) return np.concatenate(outputs).astype(np.float32, copy=False) diff --git a/tests/test_core_long_form.py b/tests/test_core_long_form.py index ea299364..428d4b80 100644 --- a/tests/test_core_long_form.py +++ b/tests/test_core_long_form.py @@ -58,7 +58,7 @@ def generate(self, text, **kwargs): return np.full(10, len(self.calls), dtype=np.float32) -def test_generate_long_form_uses_seed_reference_and_previous_prompt(): +def test_generate_long_form_reuses_seed_prompt_with_control_context(): model = _FakeVoxCPM() wav = model.generate_long_form( @@ -76,12 +76,13 @@ def test_generate_long_form_uses_seed_reference_and_previous_prompt(): assert model.calls[0][1]["reference_wav_path"] is None assert model.calls[1][0] == "Second!" - assert model.calls[1][1]["prompt_text"] == "First!" + assert model.calls[1][1]["prompt_text"] == "(steady narrator)First!" assert model.calls[1][1]["prompt_wav_path"] assert model.calls[1][1]["reference_wav_path"] assert model.calls[2][0] == "Third!" - assert model.calls[2][1]["prompt_text"] == "Second!" + assert model.calls[2][1]["prompt_text"] == "(steady narrator)First!" + assert model.calls[2][1]["prompt_wav_path"] == model.calls[1][1]["prompt_wav_path"] assert model.calls[2][1]["reference_wav_path"] == model.calls[1][1]["reference_wav_path"] assert wav.dtype == np.float32 From 24c53101015127b2676ad6da8c690740f9ca7d91 Mon Sep 17 00:00:00 2001 From: Lee-take <210963840+Lee-take@users.noreply.github.com> Date: Sun, 24 May 2026 10:19:59 +0800 Subject: [PATCH 3/4] fix long-form seed prompt transcript --- src/voxcpm/core.py | 10 ++++++---- tests/test_core_long_form.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/voxcpm/core.py b/src/voxcpm/core.py index 8363c09b..f5cc96fc 100644 --- a/src/voxcpm/core.py +++ b/src/voxcpm/core.py @@ -250,9 +250,9 @@ def generate_long_form( """Generate long-form speech as short anchored segments. The first generated segment becomes a stable reference voice for - VoxCPM2 when no external reference is supplied. Later segments use the - previous segment as the continuation prompt so long inputs do not rely - on one very long autoregressive pass. + VoxCPM2 when no external reference is supplied. Later segments use that + same seed segment as both reference and continuation prompt so long + inputs do not rely on one very long autoregressive pass. """ segments = _split_long_text_for_tts(text, max_chars=max_chars) control = (control or "").strip() @@ -313,7 +313,9 @@ def write_segment(name: str, wav: np.ndarray) -> str: stable_reference_path = write_segment("seed_reference.wav", first_wav) seed_prompt_path = write_segment("segment_001.wav", first_wav) - seed_prompt_text = first_text + # 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( diff --git a/tests/test_core_long_form.py b/tests/test_core_long_form.py index 428d4b80..28d2b17f 100644 --- a/tests/test_core_long_form.py +++ b/tests/test_core_long_form.py @@ -76,12 +76,12 @@ def test_generate_long_form_reuses_seed_prompt_with_control_context(): assert model.calls[0][1]["reference_wav_path"] is None assert model.calls[1][0] == "Second!" - assert model.calls[1][1]["prompt_text"] == "(steady narrator)First!" + assert model.calls[1][1]["prompt_text"] == "First!" assert model.calls[1][1]["prompt_wav_path"] assert model.calls[1][1]["reference_wav_path"] assert model.calls[2][0] == "Third!" - assert model.calls[2][1]["prompt_text"] == "(steady narrator)First!" + assert model.calls[2][1]["prompt_text"] == "First!" assert model.calls[2][1]["prompt_wav_path"] == model.calls[1][1]["prompt_wav_path"] assert model.calls[2][1]["reference_wav_path"] == model.calls[1][1]["reference_wav_path"] From 4225e1dd4b95e9c0d0b96171b9c2c2a4c8a2d172 Mon Sep 17 00:00:00 2001 From: Lee-take <210963840+Lee-take@users.noreply.github.com> Date: Sun, 24 May 2026 10:34:59 +0800 Subject: [PATCH 4/4] use prompt-only seed for long-form defaults --- src/voxcpm/cli.py | 8 ++++---- src/voxcpm/core.py | 18 +++++++----------- tests/test_core_long_form.py | 4 ++-- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/voxcpm/cli.py b/src/voxcpm/cli.py index b5c3ad9e..2977b890 100644 --- a/src/voxcpm/cli.py +++ b/src/voxcpm/cli.py @@ -418,14 +418,14 @@ def _add_common_generation_args(parser): parser.add_argument( "--long-form-max-chars", type=int, - default=90, - help="Maximum characters per long-form segment (default: 90)", + default=55, + help="Maximum characters per long-form segment (default: 55)", ) parser.add_argument( "--long-form-silence-ms", type=int, - default=300, - help="Inserted silence between long-form segments in milliseconds (default: 300)", + default=180, + help="Inserted silence between long-form segments in milliseconds (default: 180)", ) diff --git a/src/voxcpm/core.py b/src/voxcpm/core.py index f5cc96fc..e5377492 100644 --- a/src/voxcpm/core.py +++ b/src/voxcpm/core.py @@ -244,15 +244,15 @@ def generate_long_form( retry_badcase: bool = True, retry_badcase_max_times: int = 3, retry_badcase_ratio_threshold: float = 6.0, - max_chars: int = 90, - silence_ms: int = 300, + max_chars: int = 55, + silence_ms: int = 180, ) -> np.ndarray: """Generate long-form speech as short anchored segments. - The first generated segment becomes a stable reference voice for - VoxCPM2 when no external reference is supplied. Later segments use that - same seed segment as both reference and continuation prompt so long - inputs do not rely on one very long autoregressive pass. + 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() @@ -308,10 +308,6 @@ def write_segment(name: str, wav: np.ndarray) -> str: ) outputs.append(first_wav) - stable_reference_path = reference_wav_path - if stable_reference_path is None and can_use_reference: - stable_reference_path = write_segment("seed_reference.wav", 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. @@ -323,7 +319,7 @@ def write_segment(name: str, wav: np.ndarray) -> str: text=segment, prompt_wav_path=seed_prompt_path, prompt_text=seed_prompt_text, - reference_wav_path=stable_reference_path if can_use_reference else None, + reference_wav_path=reference_wav_path if can_use_reference else None, denoise=False, **common_kwargs, ) diff --git a/tests/test_core_long_form.py b/tests/test_core_long_form.py index 28d2b17f..2467d96e 100644 --- a/tests/test_core_long_form.py +++ b/tests/test_core_long_form.py @@ -78,12 +78,12 @@ def test_generate_long_form_reuses_seed_prompt_with_control_context(): assert model.calls[1][0] == "Second!" assert model.calls[1][1]["prompt_text"] == "First!" assert model.calls[1][1]["prompt_wav_path"] - assert model.calls[1][1]["reference_wav_path"] + assert model.calls[1][1]["reference_wav_path"] is None assert model.calls[2][0] == "Third!" assert model.calls[2][1]["prompt_text"] == "First!" assert model.calls[2][1]["prompt_wav_path"] == model.calls[1][1]["prompt_wav_path"] - assert model.calls[2][1]["reference_wav_path"] == model.calls[1][1]["reference_wav_path"] + assert model.calls[2][1]["reference_wav_path"] is None assert wav.dtype == np.float32 assert wav.shape == (32,)