mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-10 00:08:42 -04:00
* feat(vibevoice-cpp): true streaming TTSStream via vv_capi_tts_stream Replaces the synth-to-tempfile TTSStream hack with a real streaming path: binds the new vv_capi_tts_stream callback ABI via a single reusable purego callback (CGO_ENABLED=0-safe, no runtime/cgo), copies each int16 PCM window into the gRPC results channel after the streaming WAV header. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * test(vibevoice-cpp): real-model streaming integration test with TTFA measurement Gated behind VIBEVOICE_IT=1, this Ginkgo spec dlopens the engine .so and drives the exact Go->purego->C TTSStream/TTS path against the real vibevoice-realtime-0.5B model. It measures time-to-first-audio for the streaming path versus the batch path and asserts the streaming win: 44-byte WAV header first, >=2 PCM windows, non-silent audio, and TTFA < total_stream. Without the env var the spec skips so CI and normal go test are unaffected. Measured: TTFA 2.38s vs batch deliver-time 39.96s (first audio in 5.9% of the batch time, ~17x faster), 18 stream chunks, non-silent 24kHz PCM. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * chore(vibevoice-cpp): pin streaming-decoder engine build Bumps VIBEVOICE_CPP_VERSION to the streaming-decoder engine commit that adds vv_capi_tts_stream (localai-org/vibevoice.cpp#8). Re-pin to the merged master commit once that PR lands. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * chore(vibevoice-cpp): re-pin to merged streaming-decoder commit localai-org/vibevoice.cpp#8 merged to master as 000e372; move the pin off the PR branch commit onto the merged master commit. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * test(vibevoice-cpp): check writer errors in TTFA report (errcheck) golangci-lint errcheck flagged the unchecked fmt.Fprintf calls that print the streaming TTFA headline. Build the report once with fmt.Sprintf and write it per destination with an explicitly discarded error, matching the GinkgoWriter reporting idiom used by the other backend tests. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-fable-5 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
263 lines
9.0 KiB
Go
263 lines
9.0 KiB
Go
package main
|
|
|
|
// Real end-to-end streaming integration test for the vibevoice-cpp
|
|
// backend. It drives the actual Go -> purego -> C path against the real
|
|
// model, proving that TTSStream delivers audio incrementally (measurable
|
|
// time-to-first-audio) while TTS only yields anything after full
|
|
// synthesis. Gated behind VIBEVOICE_IT=1 so normal `go test` and CI stay
|
|
// unaffected - it needs the built engine .so and ~1.7 GB of model files.
|
|
//
|
|
// Run:
|
|
//
|
|
// VIBEVOICE_IT=1 \
|
|
// VIBEVOICECPP_LIBRARY=<abs path to libgovibevoicecpp-fallback.so> \
|
|
// go test ./... -run TestVibevoiceCpp -v -timeout 600s
|
|
//
|
|
// Optional overrides (default to the staged bundle under
|
|
// ~/_git/vibevoice-models):
|
|
//
|
|
// VIBEVOICE_IT_MODEL vibevoice-realtime-0.5B-q8_0.gguf (abs path)
|
|
// VIBEVOICE_IT_TOKENIZER tokenizer.gguf (abs path)
|
|
// VIBEVOICE_IT_VOICE voice-en-Carter_man.gguf (abs path)
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ebitengine/purego"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// itDefaultModelDir is where Task 6 staged the model bundle. Individual
|
|
// files can be overridden via the VIBEVOICE_IT_* env vars.
|
|
const itDefaultModelDir = "/home/mudler/_git/vibevoice-models"
|
|
|
|
// itLibLoaded guards the one-time purego Dlopen + RegisterLibFunc into
|
|
// the package-level Cpp* vars. purego cannot free a loaded library and
|
|
// the Cpp* symbols are process-global, so we bind exactly once.
|
|
var itLibLoaded sync.Once
|
|
|
|
// integrationOrSkip enforces the VIBEVOICE_IT=1 gate and binds the C ABI
|
|
// symbols into the package vars the backend calls (mirrors main.go's
|
|
// libFuncs list). It Skip()s the spec when the gate is off or the .so /
|
|
// model files are missing, so the suite stays green in every other run.
|
|
func integrationOrSkip() (model, tokenizer, voice string) {
|
|
if os.Getenv("VIBEVOICE_IT") != "1" {
|
|
Skip("VIBEVOICE_IT!=1, skipping real-model streaming integration test")
|
|
}
|
|
|
|
lib := os.Getenv("VIBEVOICECPP_LIBRARY")
|
|
if lib == "" {
|
|
Skip("VIBEVOICECPP_LIBRARY not set, cannot dlopen the engine .so")
|
|
}
|
|
if _, err := os.Stat(lib); err != nil {
|
|
Skip("engine .so not found at " + lib)
|
|
}
|
|
|
|
model = itEnvOr("VIBEVOICE_IT_MODEL", filepath.Join(itDefaultModelDir, "vibevoice-realtime-0.5B-q8_0.gguf"))
|
|
tokenizer = itEnvOr("VIBEVOICE_IT_TOKENIZER", filepath.Join(itDefaultModelDir, "tokenizer.gguf"))
|
|
voice = itEnvOr("VIBEVOICE_IT_VOICE", filepath.Join(itDefaultModelDir, "voice-en-Carter_man.gguf"))
|
|
for _, p := range []string{model, tokenizer, voice} {
|
|
if _, err := os.Stat(p); err != nil {
|
|
Skip("model file missing: " + p)
|
|
}
|
|
}
|
|
|
|
itLibLoaded.Do(func() {
|
|
handle, err := purego.Dlopen(lib, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
Expect(err).ToNot(HaveOccurred(), "dlopen %s", lib)
|
|
// Mirror the libFuncs list in main.go verbatim so the test binds
|
|
// the exact same symbols the production backend binary does.
|
|
purego.RegisterLibFunc(&CppLoad, handle, "vv_capi_load")
|
|
purego.RegisterLibFunc(&CppTTS, handle, "vv_capi_tts")
|
|
purego.RegisterLibFunc(&CppTTSStream, handle, "vv_capi_tts_stream")
|
|
purego.RegisterLibFunc(&CppASR, handle, "vv_capi_asr")
|
|
purego.RegisterLibFunc(&CppUnload, handle, "vv_capi_unload")
|
|
purego.RegisterLibFunc(&CppVersion, handle, "vv_capi_version")
|
|
})
|
|
return model, tokenizer, voice
|
|
}
|
|
|
|
func itEnvOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// itParseWavPCM returns the int16 PCM samples from a standard WAV file by
|
|
// locating the "data" sub-chunk, so it works regardless of how many
|
|
// bytes of header/metadata the engine wrote.
|
|
func itParseWavPCM(b []byte) []int16 {
|
|
// Find the "data" sub-chunk id, then its 4-byte little-endian size.
|
|
idx := -1
|
|
for i := 12; i+8 <= len(b); i += 2 {
|
|
if string(b[i:i+4]) == "data" {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
Expect(idx).To(BeNumerically(">=", 0), "no data chunk in WAV")
|
|
pcmStart := idx + 8
|
|
size := int(b[idx+4]) | int(b[idx+5])<<8 | int(b[idx+6])<<16 | int(b[idx+7])<<24
|
|
if size <= 0 || pcmStart+size > len(b) {
|
|
size = len(b) - pcmStart
|
|
}
|
|
n := size / 2
|
|
out := make([]int16, n)
|
|
for i := 0; i < n; i++ {
|
|
out[i] = int16(uint16(b[pcmStart+i*2]) | uint16(b[pcmStart+i*2+1])<<8)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// itRMS computes the root-mean-square of int16 PCM as a sanity signal:
|
|
// > 0 means non-silent, and NaN/Inf-free means well-formed samples.
|
|
func itRMS(pcm []int16) float64 {
|
|
if len(pcm) == 0 {
|
|
return 0
|
|
}
|
|
var sumSq float64
|
|
for _, s := range pcm {
|
|
v := float64(s)
|
|
sumSq += v * v
|
|
}
|
|
return sqrt(sumSq / float64(len(pcm)))
|
|
}
|
|
|
|
// sqrt via Newton's method to avoid dragging math into the file's tiny
|
|
// need (keeps the dependency surface of the test minimal).
|
|
func sqrt(x float64) float64 {
|
|
if x <= 0 {
|
|
return 0
|
|
}
|
|
z := x
|
|
for i := 0; i < 40; i++ {
|
|
z = 0.5 * (z + x/z)
|
|
}
|
|
return z
|
|
}
|
|
|
|
var _ = Describe("VibeVoice-cpp real-model streaming (VIBEVOICE_IT=1)", Ordered, func() {
|
|
// The paragraph is deliberately long (multiple sentences) so the
|
|
// engine decodes several streaming windows and TTFA is meaningfully
|
|
// earlier than full synthesis.
|
|
const paragraph = "The quick brown fox jumps over the lazy dog near the riverbank. " +
|
|
"A gentle breeze carried the sound of distant bells across the quiet valley. " +
|
|
"Streaming synthesis lets you hear the very first words while the rest is still being generated."
|
|
|
|
var v *VibevoiceCpp
|
|
|
|
BeforeAll(func() {
|
|
model, tokenizer, voice := integrationOrSkip()
|
|
|
|
v = &VibevoiceCpp{}
|
|
err := v.Load(&pb.ModelOptions{
|
|
ModelFile: model,
|
|
ModelPath: filepath.Dir(model),
|
|
Options: []string{
|
|
"tokenizer=" + tokenizer,
|
|
"voice=" + voice,
|
|
},
|
|
Threads: 4,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred(), "Load must succeed with the real model")
|
|
})
|
|
|
|
It("streams audio incrementally and beats the batch path to first audio", func() {
|
|
// ---- Streaming run -------------------------------------------
|
|
results := make(chan []byte, 256)
|
|
streamErr := make(chan error, 1)
|
|
|
|
start := time.Now()
|
|
go func() {
|
|
streamErr <- v.TTSStream(&pb.TTSRequest{Text: paragraph}, results)
|
|
}()
|
|
|
|
var (
|
|
header []byte
|
|
ttfa time.Duration
|
|
totalStream time.Duration
|
|
chunkCount int
|
|
pcmBytes int
|
|
firstPCM bool
|
|
)
|
|
for buf := range results {
|
|
if header == nil {
|
|
header = buf
|
|
continue
|
|
}
|
|
if len(buf) == 0 {
|
|
continue
|
|
}
|
|
if !firstPCM {
|
|
ttfa = time.Since(start)
|
|
firstPCM = true
|
|
}
|
|
chunkCount++
|
|
pcmBytes += len(buf)
|
|
}
|
|
totalStream = time.Since(start)
|
|
Expect(<-streamErr).ToNot(HaveOccurred(), "TTSStream returned an error")
|
|
|
|
// First message must be a 44-byte streaming WAV header.
|
|
Expect(header).To(HaveLen(44), "first stream message must be the WAV header")
|
|
Expect(string(header[0:4])).To(Equal("RIFF"))
|
|
Expect(string(header[8:12])).To(Equal("WAVE"))
|
|
|
|
// Streaming invariants: multiple windows, real audio, early
|
|
// delivery (first audio strictly before the stream completes).
|
|
Expect(chunkCount).To(BeNumerically(">=", 2), "expected multiple streamed PCM windows")
|
|
Expect(pcmBytes).To(BeNumerically(">", 0), "no PCM bytes streamed")
|
|
Expect(firstPCM).To(BeTrue(), "never received a PCM chunk")
|
|
Expect(ttfa).To(BeNumerically("<", totalStream), "TTFA must precede stream completion")
|
|
|
|
// ---- Batch baseline ------------------------------------------
|
|
tmp, err := os.MkdirTemp("", "vv-it-batch-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
DeferCleanup(func() { _ = os.RemoveAll(tmp) })
|
|
dst := filepath.Join(tmp, "batch.wav")
|
|
|
|
batchStart := time.Now()
|
|
Expect(v.TTS(&pb.TTSRequest{Text: paragraph, Dst: dst})).To(Succeed())
|
|
totalBatch := time.Since(batchStart)
|
|
|
|
wav, err := os.ReadFile(dst)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(wav)).To(BeNumerically(">", 44), "batch wav has no PCM payload")
|
|
Expect(string(wav[0:4])).To(Equal("RIFF"))
|
|
Expect(string(wav[8:12])).To(Equal("WAVE"))
|
|
batchPCM := itParseWavPCM(wav)
|
|
Expect(len(batchPCM)).To(BeNumerically(">", 0), "batch produced no samples")
|
|
rms := itRMS(batchPCM)
|
|
Expect(rms).To(BeNumerically(">", 0), "batch audio is silent")
|
|
Expect(rms).To(BeNumerically("<", 40000), "batch rms out of int16 range (corrupt samples)")
|
|
|
|
// ---- Headline numbers ----------------------------------------
|
|
// Emit to both GinkgoWriter and stderr: GinkgoWriter needs
|
|
// -ginkgo.v to surface, stderr is always captured so the headline
|
|
// TTFA vs batch numbers are never lost in an unattended run.
|
|
ratio := float64(ttfa) / float64(totalBatch)
|
|
report := fmt.Sprintf("\n================ vibevoice-cpp streaming TTFA ================\n"+
|
|
"input words : ~%d\n"+
|
|
"TTFA (first audio) : %v\n"+
|
|
"total_stream : %v\n"+
|
|
"total_batch : %v\n"+
|
|
"stream chunks : %d\n"+
|
|
"stream PCM bytes : %d\n"+
|
|
"batch samples/rms : %d / %.1f\n"+
|
|
"TTFA / total_batch : %.3f (first audio in this fraction of batch's deliver time)\n"+
|
|
"==============================================================\n\n",
|
|
len(paragraph)/6, ttfa, totalStream, totalBatch, chunkCount, pcmBytes, len(batchPCM), rms, ratio)
|
|
for _, out := range []io.Writer{GinkgoWriter, os.Stderr} {
|
|
_, _ = io.WriteString(out, report)
|
|
}
|
|
})
|
|
})
|