Skip to content

Commit 0a7bba6

Browse files
committed
fix(vibevoice-cpp): convert non-WAV input via ffmpeg + raise ASR token budget
Confirmed end-to-end against a real LocalAI instance with vibevoice-asr-q4_k loaded and the multi-speaker MP3 sample at vibevoice.cpp/samples/2p_argument.mp3: both /v1/audio/transcriptions and /v1/audio/diarization now succeed and return correctly attributed speaker turns for the full clip. Two latent issues surfaced once the diarization endpoint actually exercised the backend with a non-trivial input: 1. vv_capi_asr only accepts WAV via load_wav_24k_mono. The previous code passed the uploaded path straight through, so anything that wasn't already a 24 kHz mono s16le WAV failed at the C side with rc=-8 and the very unhelpful "vv_capi_asr failed". prepareWavInput shells out to ffmpeg ("-ar 24000 -ac 1 -acodec pcm_s16le") in a per-call temp dir, matching the rate the model was trained on; both AudioTranscription and Diarize now route through it. This is the same shape sherpa-onnx uses (utils.AudioToWav), but vibevoice needs 24 kHz rather than 16 kHz so we don't reuse that helper. 2. The C ABI's max_new_tokens defaults to 256 when 0 is passed. That's fine for a five-second clip but not for anything past ~10 s — vibevoice stops mid-JSON, the parse fails, and the caller sees a hard error. Pass a much larger budget (16 384 ≈ ~9 minutes of speech at the model's ~30 tok/s rate); generation stops at EOS so this is a cap rather than a target. 3. As a defensive belt-and-braces, mirror AudioTranscription's existing "fall back to a single segment if the model emits non-JSON text" pattern in Diarize, so partial / unusual model output never produces a 500. This kept the endpoint usable while diagnosing (1) and (2), and is the right behaviour to keep. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code]
1 parent b0ad8da commit 0a7bba6

1 file changed

Lines changed: 79 additions & 3 deletions

File tree

backend/go/vibevoice-cpp/govibevoicecpp.go

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"os/exec"
78
"path/filepath"
89
"strings"
910

@@ -12,6 +13,54 @@ import (
1213
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
1314
)
1415

16+
// vv_capi_asr loads audio with load_wav_24k_mono — a 24 kHz mono s16le
17+
// WAV is the format the model was trained on. Generic utils.AudioToWav
18+
// targets 16 kHz, which would cause silent quality degradation, so we
19+
// shell out to ffmpeg directly here.
20+
const vibevoiceASRSampleRate = 24000
21+
22+
// prepareWavInput accepts any audio format ffmpeg can decode and produces
23+
// a 24 kHz mono s16le WAV file in a fresh temp dir. Returns the resolved
24+
// WAV path plus a cleanup func — both must be honoured by the caller.
25+
// vibevoice.cpp's vv_capi_asr expects WAV input only; feeding it MP3/OGG/
26+
// FLAC fails at load_wav, so without conversion the endpoint only worked
27+
// when the user happened to upload a WAV.
28+
func prepareWavInput(src string) (string, func(), error) {
29+
if src == "" {
30+
return "", func() {}, fmt.Errorf("empty audio path")
31+
}
32+
dir, err := os.MkdirTemp("", "vibevoice-asr")
33+
if err != nil {
34+
return "", func() {}, fmt.Errorf("mkdtemp: %w", err)
35+
}
36+
cleanup := func() { _ = os.RemoveAll(dir) }
37+
wavPath := filepath.Join(dir, "input.wav")
38+
39+
// -y: overwrite, -ar 24000: target sample rate, -ac 1: mono,
40+
// -acodec pcm_s16le: signed 16-bit little-endian PCM (load_wav_24k_mono
41+
// only accepts s16le).
42+
cmd := exec.Command("ffmpeg",
43+
"-y", "-i", src,
44+
"-ar", fmt.Sprintf("%d", vibevoiceASRSampleRate),
45+
"-ac", "1",
46+
"-acodec", "pcm_s16le",
47+
wavPath,
48+
)
49+
cmd.Env = []string{}
50+
if out, err := cmd.CombinedOutput(); err != nil {
51+
cleanup()
52+
return "", func() {}, fmt.Errorf("ffmpeg convert to 24k mono wav: %w (output: %s)", err, string(out))
53+
}
54+
return wavPath, cleanup, nil
55+
}
56+
57+
// asrMaxNewTokens caps the ASR generation budget. The C ABI defaults to
58+
// 256 when 0 is passed — far too small for anything past ~10s of speech.
59+
// Vibevoice generates ~30 tokens per second of audio, so 16 384 covers
60+
// roughly 9 minutes of dialogue, well past any normal /v1/audio/diarization
61+
// upload. Going higher costs little since generation stops at EOS.
62+
const asrMaxNewTokens = 16384
63+
1564
// vibevoice.cpp synthesizes 24 kHz mono 16-bit PCM. Hardcoded - the
1665
// model itself is fixed-rate; if the upstream ever changes this we'll
1766
// pick it up via vv_capi_version().
@@ -302,7 +351,13 @@ func (v *VibevoiceCpp) AudioTranscription(req *pb.TranscriptRequest) (pb.Transcr
302351
return pb.TranscriptResult{}, fmt.Errorf("vibevoice-cpp: TranscriptRequest.dst (audio path) is required")
303352
}
304353

305-
out, err := v.callASR(req.Dst, 0)
354+
wavPath, cleanup, err := prepareWavInput(req.Dst)
355+
if err != nil {
356+
return pb.TranscriptResult{}, fmt.Errorf("vibevoice-cpp: %w", err)
357+
}
358+
defer cleanup()
359+
360+
out, err := v.callASR(wavPath, asrMaxNewTokens)
306361
if err != nil {
307362
return pb.TranscriptResult{}, err
308363
}
@@ -363,7 +418,13 @@ func (v *VibevoiceCpp) Diarize(req *pb.DiarizeRequest) (pb.DiarizeResponse, erro
363418
return pb.DiarizeResponse{}, fmt.Errorf("vibevoice-cpp: DiarizeRequest.dst (audio path) is required")
364419
}
365420

366-
out, err := v.callASR(req.Dst, 0)
421+
wavPath, cleanup, err := prepareWavInput(req.Dst)
422+
if err != nil {
423+
return pb.DiarizeResponse{}, fmt.Errorf("vibevoice-cpp: %w", err)
424+
}
425+
defer cleanup()
426+
427+
out, err := v.callASR(wavPath, asrMaxNewTokens)
367428
if err != nil {
368429
return pb.DiarizeResponse{}, err
369430
}
@@ -373,7 +434,22 @@ func (v *VibevoiceCpp) Diarize(req *pb.DiarizeRequest) (pb.DiarizeResponse, erro
373434

374435
var segs []asrSegment
375436
if err := json.Unmarshal([]byte(out), &segs); err != nil {
376-
return pb.DiarizeResponse{}, fmt.Errorf("vibevoice-cpp: vv_capi_asr returned non-JSON for diarization: %w", err)
437+
// Mirror AudioTranscription's fallback: vibevoice's ASR sometimes
438+
// emits free-form text instead of JSON for short or unusual audio.
439+
// Surface a single unknown-speaker segment carrying the full text
440+
// (when include_text is set) so the caller still gets coverage of
441+
// the whole clip rather than a hard failure.
442+
fmt.Fprintf(os.Stderr,
443+
"[vibevoice-cpp] WARNING: vv_capi_asr returned non-JSON for diarization, falling back to single segment: %v\n", err)
444+
text := strings.TrimSpace(out)
445+
seg := &pb.DiarizeSegment{Id: 0, Speaker: "0"}
446+
if req.IncludeText {
447+
seg.Text = text
448+
}
449+
return pb.DiarizeResponse{
450+
Segments: []*pb.DiarizeSegment{seg},
451+
NumSpeakers: 1,
452+
}, nil
377453
}
378454

379455
speakers := make(map[int]struct{})

0 commit comments

Comments
 (0)