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.
This commit is contained in:
James Pine
2026-03-13 06:48:06 -07:00
parent 837f8525d8
commit 97292ecef7
7 changed files with 43 additions and 2 deletions
@@ -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<ConnectionFormValues>({
@@ -177,6 +179,29 @@ export function ConnectionForm() {
can improve quality for long outputs. Default is 800.
</p>
</div>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between">
<label htmlFor="crossfadeMs" className="text-sm font-medium leading-none">
Chunk crossfade
</label>
<span className="text-sm tabular-nums text-muted-foreground">
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
</span>
</div>
<Slider
id="crossfadeMs"
value={[crossfadeMs]}
onValueChange={([value]) => setCrossfadeMs(value)}
min={0}
max={200}
step={10}
aria-label="Chunk crossfade duration"
/>
<p className="text-sm text-muted-foreground">
Blends audio between chunks to smooth transitions. Set to 0 for a hard cut.
</p>
</div>
</div>
</CardContent>
</Card>
+1
View File
@@ -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 {
+2
View File
@@ -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<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(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({
+6
View File
@@ -16,6 +16,9 @@ interface ServerStore {
maxChunkChars: number;
setMaxChunkChars: (value: number) => void;
crossfadeMs: number;
setCrossfadeMs: (value: number) => void;
}
export const useServerStore = create<ServerStore>()(
@@ -35,6 +38,9 @@ export const useServerStore = create<ServerStore>()(
maxChunkChars: 800,
setMaxChunkChars: (value) => set({ maxChunkChars: value }),
crossfadeMs: 50,
setCrossfadeMs: (value) => set({ crossfadeMs: value }),
}),
{
name: 'voicebox-server',
+2
View File
@@ -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,
)
+1
View File
@@ -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):
+6 -2
View File
@@ -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