Skip to content

Commit 46ba706

Browse files
localai-botmudler
andauthored
fix(crispasr): write piper TTS WAV at the model's native sample rate (#10277)
CrispASR's piper backend returns PCM at the voice's native rate (from the GGUF piper.sample_rate key: 16 kHz for x_low/low, 22.05 kHz for medium/high) and does not resample, but the Go WAV encoder hardcoded 24000 Hz. Every piper voice was therefore written with a wrong header and played back at the wrong pitch/speed. Read piper.sample_rate from the model's GGUF metadata at Load via the vendored gguf-parser-go and use it for the WAV header, falling back to the 24 kHz default for the other CrispASR TTS engines (vibevoice/orpheus/chatterbox/qwen3-tts) that emit 24 kHz and carry no such key. Adds unit specs (minimal crafted GGUFs + WAV-header decode) and an env-gated end-to-end spec (CRISPASR_PIPER_MODEL_PATH). Verified e2e: en_GB-cori-medium synthesizes a 22050 Hz WAV through backend:piper. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 60facc7 commit 46ba706

2 files changed

Lines changed: 213 additions & 7 deletions

File tree

backend/go/crispasr/gocrispasr.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/go-audio/audio"
1313
"github.com/go-audio/wav"
14+
gguf "github.com/gpustack/gguf-parser-go"
1415
"github.com/mudler/LocalAI/pkg/grpc/base"
1516
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
1617
"github.com/mudler/LocalAI/pkg/utils"
@@ -37,6 +38,39 @@ var (
3738

3839
type CrispASR struct {
3940
base.SingleThread
41+
// sampleRate is the output rate (Hz) of the loaded TTS engine's PCM, used to
42+
// write a correct WAV header. Most CrispASR TTS backends emit 24 kHz, but
43+
// piper returns its model's native rate (16 kHz for x_low/low voices,
44+
// 22.05 kHz for medium/high), so it is read from the GGUF metadata at Load.
45+
sampleRate int
46+
}
47+
48+
// defaultTTSSampleRate is the output rate assumed for CrispASR TTS engines that
49+
// don't advertise one in GGUF metadata (vibevoice/orpheus/chatterbox/qwen3-tts
50+
// all emit 24 kHz). piper is the exception and carries piper.sample_rate.
51+
const defaultTTSSampleRate = 24000
52+
53+
// piperSampleRate reads the piper.sample_rate metadata key from a GGUF model.
54+
// CrispASR's piper backend returns PCM at the model's native rate without
55+
// resampling, so the WAV header must match it. Returns ok=false for non-piper
56+
// models (key absent) or an unreadable file, letting the caller fall back to
57+
// defaultTTSSampleRate.
58+
func piperSampleRate(modelPath string) (int, bool) {
59+
// Only scalar architecture keys are read, so skip the large array metadata
60+
// (phoneme map) and mmap the header - same rationale as pkg/vram's reader.
61+
f, err := gguf.ParseGGUFFile(modelPath, gguf.UseMMap(), gguf.SkipLargeMetadata())
62+
if err != nil {
63+
return 0, false
64+
}
65+
kv, ok := f.Header.MetadataKV.Get("piper.sample_rate")
66+
if !ok || kv.ValueType != gguf.GGUFMetadataValueTypeUint32 {
67+
return 0, false
68+
}
69+
rate := int(kv.ValueUint32())
70+
if rate <= 0 {
71+
return 0, false
72+
}
73+
return rate, true
4074
}
4175

4276
// splitOption splits a "prefix:value" model option into its key and value,
@@ -103,6 +137,14 @@ func (w *CrispASR) Load(opts *pb.ModelOptions) error {
103137
return fmt.Errorf("Failed to load CrispASR transcription model")
104138
}
105139

140+
// Determine the TTS output sample rate for the WAV header. piper voices
141+
// carry their native rate in GGUF metadata and CrispASR does not resample;
142+
// every other engine emits the 24 kHz default.
143+
w.sampleRate = defaultTTSSampleRate
144+
if rate, ok := piperSampleRate(opts.ModelFile); ok {
145+
w.sampleRate = rate
146+
}
147+
106148
// Load the companion file (codec/tokenizer/s3gen) after the session is open.
107149
// rc==0 means success or "not applicable" for the active backend; only a
108150
// negative code is fatal.
@@ -390,7 +432,7 @@ func (w *CrispASR) synthesize(text string) ([]float32, error) {
390432
}
391433
defer CppTTSFree(ptr)
392434
src := unsafe.Slice((*float32)(unsafe.Pointer(ptr)), int(n)) //nolint:govet // ptr addresses C-allocated PCM returned across the purego boundary; copied out immediately below, before tts_free.
393-
out := make([]float32, int(n)) // copy out of C memory before free
435+
out := make([]float32, int(n)) // copy out of C memory before free
394436
copy(out, src)
395437
return out, nil
396438
}
@@ -417,7 +459,7 @@ func (w *CrispASR) TTS(req *pb.TTSRequest) error {
417459
if err != nil {
418460
return err
419461
}
420-
return writeWAV24k(req.Dst, pcm)
462+
return writeWAV(req.Dst, pcm, w.sampleRate)
421463
}
422464

423465
// TTSStream is the streaming counterpart to TTS. CrispASR has no progressive
@@ -447,7 +489,7 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
447489
}
448490
defer func() { _ = os.Remove(dst) }()
449491

450-
if err := writeWAV24k(dst, pcm); err != nil {
492+
if err := writeWAV(dst, pcm, w.sampleRate); err != nil {
451493
return err
452494
}
453495

@@ -459,14 +501,14 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
459501
return nil
460502
}
461503

462-
// writeWAV24k writes pcm as a 24000 Hz, mono, 16-bit PCM WAV at dst.
463-
func writeWAV24k(dst string, pcm []float32) error {
504+
// writeWAV writes pcm as a sampleRate Hz, mono, 16-bit PCM WAV at dst.
505+
func writeWAV(dst string, pcm []float32, sampleRate int) error {
464506
f, err := os.Create(dst)
465507
if err != nil {
466508
return fmt.Errorf("crispasr: create %q: %w", dst, err)
467509
}
468510

469-
enc := wav.NewEncoder(f, 24000, 16, 1, 1)
511+
enc := wav.NewEncoder(f, sampleRate, 16, 1, 1)
470512
ints := make([]int, len(pcm))
471513
for i, s := range pcm {
472514
if s > 1 {
@@ -477,7 +519,7 @@ func writeWAV24k(dst string, pcm []float32) error {
477519
ints[i] = int(s * 32767)
478520
}
479521
buf := &audio.IntBuffer{
480-
Format: &audio.Format{NumChannels: 1, SampleRate: 24000},
522+
Format: &audio.Format{NumChannels: 1, SampleRate: sampleRate},
481523
Data: ints,
482524
SourceBitDepth: 16,
483525
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/binary"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/go-audio/wav"
10+
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
)
14+
15+
// GGUF metadata value type tags (subset) from the GGUF spec.
16+
const (
17+
ggufTypeUint32 uint32 = 4
18+
ggufTypeString uint32 = 8
19+
)
20+
21+
type ggufKV struct {
22+
key string
23+
vtype uint32
24+
val any
25+
}
26+
27+
// writeMinimalGGUF emits a valid, tensor-less GGUF file carrying only the given
28+
// metadata key-values. Enough for the header-only parse path piperSampleRate
29+
// uses; avoids pulling a real multi-MB voice into the test.
30+
func writeMinimalGGUF(path string, kvs []ggufKV) error {
31+
var b bytes.Buffer
32+
b.WriteString("GGUF") // magic
33+
_ = binary.Write(&b, binary.LittleEndian, uint32(3)) // version
34+
_ = binary.Write(&b, binary.LittleEndian, uint64(0)) // tensor count
35+
_ = binary.Write(&b, binary.LittleEndian, uint64(len(kvs)))
36+
for _, kv := range kvs {
37+
_ = binary.Write(&b, binary.LittleEndian, uint64(len(kv.key)))
38+
b.WriteString(kv.key)
39+
_ = binary.Write(&b, binary.LittleEndian, kv.vtype)
40+
switch v := kv.val.(type) {
41+
case uint32:
42+
_ = binary.Write(&b, binary.LittleEndian, v)
43+
case string:
44+
_ = binary.Write(&b, binary.LittleEndian, uint64(len(v)))
45+
b.WriteString(v)
46+
}
47+
}
48+
return os.WriteFile(path, b.Bytes(), 0o644)
49+
}
50+
51+
// wavSampleRate decodes the WAV header at path and returns its sample rate.
52+
func wavSampleRate(path string) (int, error) {
53+
f, err := os.Open(path)
54+
if err != nil {
55+
return 0, err
56+
}
57+
defer func() { _ = f.Close() }()
58+
dec := wav.NewDecoder(f)
59+
dec.ReadInfo()
60+
return int(dec.SampleRate), nil
61+
}
62+
63+
var _ = Describe("piper sample rate", func() {
64+
Context("piperSampleRate", func() {
65+
It("reads piper.sample_rate from a piper GGUF (medium = 22050)", func() {
66+
p := filepath.Join(GinkgoT().TempDir(), "voice.gguf")
67+
Expect(writeMinimalGGUF(p, []ggufKV{
68+
{key: "general.architecture", vtype: ggufTypeString, val: "piper"},
69+
{key: "piper.sample_rate", vtype: ggufTypeUint32, val: uint32(22050)},
70+
})).To(Succeed())
71+
72+
rate, ok := piperSampleRate(p)
73+
Expect(ok).To(BeTrue(), "piper.sample_rate should be found")
74+
Expect(rate).To(Equal(22050))
75+
})
76+
77+
It("reads the low-quality rate (16000)", func() {
78+
p := filepath.Join(GinkgoT().TempDir(), "voice.gguf")
79+
Expect(writeMinimalGGUF(p, []ggufKV{
80+
{key: "piper.sample_rate", vtype: ggufTypeUint32, val: uint32(16000)},
81+
})).To(Succeed())
82+
83+
rate, ok := piperSampleRate(p)
84+
Expect(ok).To(BeTrue())
85+
Expect(rate).To(Equal(16000))
86+
})
87+
88+
It("returns ok=false for a non-piper GGUF (no piper.sample_rate key)", func() {
89+
p := filepath.Join(GinkgoT().TempDir(), "other.gguf")
90+
Expect(writeMinimalGGUF(p, []ggufKV{
91+
{key: "general.architecture", vtype: ggufTypeString, val: "vibevoice"},
92+
})).To(Succeed())
93+
94+
_, ok := piperSampleRate(p)
95+
Expect(ok).To(BeFalse())
96+
})
97+
98+
It("returns ok=false for an unreadable/non-GGUF file", func() {
99+
p := filepath.Join(GinkgoT().TempDir(), "garbage.gguf")
100+
Expect(os.WriteFile(p, []byte("not a gguf"), 0o644)).To(Succeed())
101+
102+
_, ok := piperSampleRate(p)
103+
Expect(ok).To(BeFalse())
104+
})
105+
})
106+
107+
// End-to-end through the built .so. Gated on CRISPASR_PIPER_MODEL_PATH (a
108+
// real piper voice GGUF) like the other model-backed specs; never runs in
109+
// default CI. Proves CrispASR's piper backend output rate flows into the
110+
// WAV header instead of the hardcoded 24 kHz default.
111+
Context("piper TTS end-to-end", func() {
112+
It("writes the WAV at the model's native piper.sample_rate", func() {
113+
model := os.Getenv("CRISPASR_PIPER_MODEL_PATH")
114+
if model == "" {
115+
Skip("set CRISPASR_PIPER_MODEL_PATH to run the piper e2e spec")
116+
}
117+
ensureLibLoaded()
118+
119+
expected, ok := piperSampleRate(model)
120+
Expect(ok).To(BeTrue(), "model should carry piper.sample_rate metadata")
121+
122+
w := &CrispASR{}
123+
Expect(w.Load(&pb.ModelOptions{
124+
ModelFile: model,
125+
Options: []string{"backend:piper"},
126+
Threads: 4,
127+
})).To(Succeed())
128+
129+
dst := filepath.Join(GinkgoT().TempDir(), "piper.wav")
130+
Expect(w.TTS(&pb.TTSRequest{Text: "Hello from CrispASR piper.", Dst: dst})).To(Succeed())
131+
132+
info, err := os.Stat(dst)
133+
Expect(err).ToNot(HaveOccurred())
134+
Expect(info.Size()).To(BeNumerically(">", 1024), "expected a non-trivial WAV")
135+
136+
rate, err := wavSampleRate(dst)
137+
Expect(err).ToNot(HaveOccurred())
138+
Expect(rate).To(Equal(expected),
139+
"WAV header rate must equal the model's native piper.sample_rate, not the 24k default")
140+
})
141+
})
142+
143+
Context("writeWAV", func() {
144+
It("writes the WAV header at the given sample rate (22050 for piper, not the 24k default)", func() {
145+
dst := filepath.Join(GinkgoT().TempDir(), "out.wav")
146+
pcm := make([]float32, 220) // 10 ms of silence is enough for a header
147+
Expect(writeWAV(dst, pcm, 22050)).To(Succeed())
148+
149+
rate, err := wavSampleRate(dst)
150+
Expect(err).ToNot(HaveOccurred())
151+
Expect(rate).To(Equal(22050))
152+
})
153+
154+
It("writes a 16000 Hz header for low-quality piper voices", func() {
155+
dst := filepath.Join(GinkgoT().TempDir(), "out.wav")
156+
pcm := make([]float32, 160)
157+
Expect(writeWAV(dst, pcm, 16000)).To(Succeed())
158+
159+
rate, err := wavSampleRate(dst)
160+
Expect(err).ToNot(HaveOccurred())
161+
Expect(rate).To(Equal(16000))
162+
})
163+
})
164+
})

0 commit comments

Comments
 (0)