From 97292ecef732dd64af1a8ee583e6bd0a210dacaf Mon Sep 17 00:00:00 2001
From: James Pine
Date: Fri, 13 Mar 2026 06:48:06 -0700
Subject: [PATCH] feat: add chunk crossfade slider (0ms = hard cut)
Persisted setting (default 50ms) controls how audio chunks are blended
together. Set to 0 for a clean hard cut with no overlap.
---
.../ServerSettings/ConnectionForm.tsx | 25 +++++++++++++++++++
app/src/lib/api/types.ts | 1 +
app/src/lib/hooks/useGenerationForm.ts | 2 ++
app/src/stores/serverStore.ts | 6 +++++
backend/main.py | 2 ++
backend/models.py | 1 +
backend/utils/chunked_tts.py | 8 ++++--
7 files changed, 43 insertions(+), 2 deletions(-)
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