diff --git a/app/src/components/ServerSettings/ConnectionForm.tsx b/app/src/components/ServerSettings/ConnectionForm.tsx index a5521870..c9de46a6 100644 --- a/app/src/components/ServerSettings/ConnectionForm.tsx +++ b/app/src/components/ServerSettings/ConnectionForm.tsx @@ -36,6 +36,8 @@ export function ConnectionForm() { const setMode = useServerStore((state) => state.setMode); const maxChunkChars = useServerStore((state) => state.maxChunkChars); const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars); + const crossfadeMs = useServerStore((state) => state.crossfadeMs); + const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs); const { toast } = useToast(); const form = useForm({ @@ -177,6 +179,29 @@ export function ConnectionForm() { can improve quality for long outputs. Default is 800.

+ +
+
+ + + {crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`} + +
+ setCrossfadeMs(value)} + min={0} + max={200} + step={10} + aria-label="Chunk crossfade duration" + /> +

+ Blends audio between chunks to smooth transitions. Set to 0 for a hard cut. +

+
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index d8ad6b13..af5a6c15 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -37,6 +37,7 @@ export interface GenerationRequest { engine?: 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo'; instruct?: string; max_chunk_chars?: number; + crossfade_ms?: number; } export interface GenerationResponse { diff --git a/app/src/lib/hooks/useGenerationForm.ts b/app/src/lib/hooks/useGenerationForm.ts index e1244d22..66effd22 100644 --- a/app/src/lib/hooks/useGenerationForm.ts +++ b/app/src/lib/hooks/useGenerationForm.ts @@ -33,6 +33,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { const setAudioWithAutoPlay = usePlayerStore((state) => state.setAudioWithAutoPlay); const setIsGenerating = useGenerationStore((state) => state.setIsGenerating); const maxChunkChars = useServerStore((state) => state.maxChunkChars); + const crossfadeMs = useServerStore((state) => state.crossfadeMs); const [downloadingModelName, setDownloadingModelName] = useState(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); @@ -113,6 +114,7 @@ export function useGenerationForm(options: UseGenerationFormOptions = {}) { engine, instruct: isQwen ? data.instruct || undefined : undefined, max_chunk_chars: maxChunkChars, + crossfade_ms: crossfadeMs, }); toast({ diff --git a/app/src/stores/serverStore.ts b/app/src/stores/serverStore.ts index 9d4ad89c..1795b61c 100644 --- a/app/src/stores/serverStore.ts +++ b/app/src/stores/serverStore.ts @@ -16,6 +16,9 @@ interface ServerStore { maxChunkChars: number; setMaxChunkChars: (value: number) => void; + + crossfadeMs: number; + setCrossfadeMs: (value: number) => void; } export const useServerStore = create()( @@ -35,6 +38,9 @@ export const useServerStore = create()( maxChunkChars: 800, setMaxChunkChars: (value) => set({ maxChunkChars: value }), + + crossfadeMs: 50, + setCrossfadeMs: (value) => set({ crossfadeMs: value }), }), { name: 'voicebox-server', diff --git a/backend/main.py b/backend/main.py index 39e135e5..cb9a2bd3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -840,6 +840,7 @@ async def generate_speech( seed=data.seed, instruct=data.instruct, max_chunk_chars=data.max_chunk_chars, + crossfade_ms=data.crossfade_ms, trim_fn=trim_fn, ) @@ -970,6 +971,7 @@ async def stream_speech( seed=data.seed, instruct=data.instruct, max_chunk_chars=data.max_chunk_chars, + crossfade_ms=data.crossfade_ms, trim_fn=trim_fn, ) diff --git a/backend/models.py b/backend/models.py index 771dfa7b..b462b67a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -59,6 +59,7 @@ class GenerationRequest(BaseModel): instruct: Optional[str] = Field(None, max_length=500) engine: Optional[str] = Field(default="qwen", pattern="^(qwen|luxtts|chatterbox|chatterbox_turbo)$") max_chunk_chars: int = Field(default=800, ge=100, le=5000, description="Max characters per chunk for long text splitting") + crossfade_ms: int = Field(default=50, ge=0, le=500, description="Crossfade duration in ms between chunks (0 for hard cut)") class GenerationResponse(BaseModel): diff --git a/backend/utils/chunked_tts.py b/backend/utils/chunked_tts.py index b9b9f0cc..53a454c6 100644 --- a/backend/utils/chunked_tts.py +++ b/backend/utils/chunked_tts.py @@ -224,6 +224,7 @@ async def generate_chunked( seed: int | None = None, instruct: str | None = None, max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS, + crossfade_ms: int = 50, trim_fn=None, ) -> Tuple[np.ndarray, int]: """Generate audio with automatic chunking for long text. @@ -234,7 +235,7 @@ async def generate_chunked( For longer text the input is split at natural sentence boundaries, each chunk is generated independently, optionally trimmed (useful for Chatterbox engines that hallucinate trailing noise), and the results - are concatenated with a short crossfade. + are concatenated with a crossfade (or hard cut if *crossfade_ms* is 0). Parameters ---------- @@ -246,6 +247,9 @@ async def generate_chunked( Forwarded to ``backend.generate()`` verbatim. max_chunk_chars : int Maximum characters per chunk (default 800). + crossfade_ms : int + Crossfade duration in milliseconds between chunks. 0 for a hard + cut with no overlap (default 50). trim_fn : callable | None Optional ``(audio, sample_rate) -> audio`` post-processing function applied to each chunk before concatenation (e.g. @@ -294,5 +298,5 @@ async def generate_chunked( if sample_rate is None: sample_rate = chunk_sr - audio = concatenate_audio_chunks(audio_chunks, sample_rate) + audio = concatenate_audio_chunks(audio_chunks, sample_rate, crossfade_ms=crossfade_ms) return audio, sample_rate