mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
Four defects the whole-branch review found on the Go side, plus two comment corrections. sample_rate is a disk-exhaustion hazard. The branch added the `form:` tag that makes the field bind for the first time, so the resample path went from dead to live, and utils.AudioResample interpolates the int straight into ffmpeg's -ar with no bound. Measured with ffmpeg 7: -ar 999999999 on a 0.01 s clip writes 20 MB and exits 0, which scales linearly to the reported 3.9 GB for one second, into a GeneratedContentDir nothing sweeps, and convertStems repeats it once per separation stem. Clamped to 8000..192000 in the handler, before the temp dir and before the model is touched, and rejected with a 400 outside it. The low end was reported as "a 0-byte file". It is not: -ar 1 writes a 78-byte header with no audio behind it, whose declared data size still claims 70 bytes, so go-audio parses it as a 35 SECOND file and a size check does not see it. The guard therefore compares the declared data chunk against the bytes actually on disk, and AudioResample now fails rather than returning a WAV carrying nothing. Both parts of a transform request land in one temp dir, and the raw copy was named only after the client's basename, so `-F audio=@mic/clip.wav -F reference=@loopback/clip.wav` wrote "raw-clip.wav" twice. Since AudioToWavPreservingShape hardlinks an already-PCM16 WAV rather than copying it, the reference part's os.Create truncated the inode audio.wav pointed at: mic and reference came out identical, which makes an echo canceller null everything and return near-silence with a 200. The raw copy now carries the form field name. audio-cpp had no BackendCapabilities entry, so VoiceCloningForModel returned nil before it ever consulted the model's tts.voice_cloning override and every `voice: "profile:<id>"` request was refused with a 400, on a backend that ships audio-cpp-chatterbox whose family serves cloning and not plain TTS. Registered with its RPCs, usecases and the reference-audio contract, and deliberately without the 16 kHz mono fold, which its separation families cannot survive. GetBackendCapability was exact-match only, so every pinned gallery variant read as an unknown backend: vulkan-localvqe lost the 16 kHz mono fold that used to be unconditional and started failing inside LocalVQE, and the usecase gate does not stand in for it because BuildFilteredFirstAvailableDefaultModel returns early once the client names a model. Lookup now falls back to the meta name by stripping the gallery's hardware prefix and release-channel suffix, exact match first so nothing can be shadowed. Same class as #10945. Also corrected: the AudioTransformRequest comment claimed echo's binder falls back to the field name, which it does not in either direction (bindData binds ONLY tagged fields and `continue`s otherwise; `model` arrives from setModelNameFromRequest's c.FormValue). And the stable_audio `src` heap corruption caveat now lives on ElevenLabsSoundGenerationRequest, where the Go developer who would add the field can see it, instead of only in C++. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
265 lines
9.6 KiB
Go
265 lines
9.6 KiB
Go
package utils_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
|
|
laudio "github.com/mudler/LocalAI/pkg/audio"
|
|
. "github.com/mudler/LocalAI/pkg/utils"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// writeWAV writes a 16-bit PCM WAV with the given shape. The samples are a
|
|
// ramp rather than silence, so a conversion that dropped its input would be
|
|
// visible as a shorter file and not just as a different header.
|
|
func writeWAV(path string, sampleRate uint32, channels uint16, frames int) {
|
|
f, err := os.Create(path)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = f.Close() }()
|
|
|
|
const bitsPerSample = uint16(16)
|
|
blockAlign := channels * (bitsPerSample / 8)
|
|
total := frames * int(channels)
|
|
dataSize := uint32(total) * uint32(bitsPerSample/8)
|
|
|
|
hdr := laudio.WAVHeader{
|
|
ChunkID: [4]byte{'R', 'I', 'F', 'F'},
|
|
ChunkSize: 36 + dataSize,
|
|
Format: [4]byte{'W', 'A', 'V', 'E'},
|
|
Subchunk1ID: [4]byte{'f', 'm', 't', ' '},
|
|
Subchunk1Size: 16,
|
|
AudioFormat: 1,
|
|
NumChannels: channels,
|
|
SampleRate: sampleRate,
|
|
ByteRate: sampleRate * uint32(blockAlign),
|
|
BlockAlign: blockAlign,
|
|
BitsPerSample: bitsPerSample,
|
|
Subchunk2ID: [4]byte{'d', 'a', 't', 'a'},
|
|
Subchunk2Size: dataSize,
|
|
}
|
|
Expect(binary.Write(f, binary.LittleEndian, &hdr)).To(Succeed())
|
|
for i := 0; i < total; i++ {
|
|
Expect(binary.Write(f, binary.LittleEndian, int16(200*(i%100)))).To(Succeed())
|
|
}
|
|
}
|
|
|
|
// readShape reports the (rate, channels, bit depth) a WAV declares.
|
|
func readShape(path string) (uint32, uint16, uint16) {
|
|
f, err := os.Open(path)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = f.Close() }()
|
|
var hdr laudio.WAVHeader
|
|
Expect(binary.Read(f, binary.LittleEndian, &hdr)).To(Succeed())
|
|
return hdr.SampleRate, hdr.NumChannels, hdr.BitsPerSample
|
|
}
|
|
|
|
// readFormatTag reports wFormatTag, which sits at the fixed offset 20 of a
|
|
// RIFF/WAVE header. 1 is plain PCM; 0xFFFE is WAVE_FORMAT_EXTENSIBLE.
|
|
func readFormatTag(path string) uint16 {
|
|
raw, err := os.ReadFile(path)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(len(raw)).To(BeNumerically(">=", 22))
|
|
return binary.LittleEndian.Uint16(raw[20:22])
|
|
}
|
|
|
|
// writeExtensibleWAV writes a real WAVE_FORMAT_EXTENSIBLE file: a 40-byte fmt
|
|
// chunk with tag 0xFFFE, cbSize 22 and the KSDATAFORMAT_SUBTYPE_PCM GUID. Many
|
|
// DAWs and Windows tools emit exactly this, and the samples inside are ordinary
|
|
// 16-bit PCM, which is why a bit-depth-only check waves it through.
|
|
func writeExtensibleWAV(path string, sampleRate uint32, channels uint16, frames int) {
|
|
const bitsPerSample = uint16(16)
|
|
blockAlign := channels * (bitsPerSample / 8)
|
|
dataSize := uint32(frames) * uint32(channels) * uint32(bitsPerSample/8)
|
|
|
|
buf := &bytes.Buffer{}
|
|
write := func(values ...any) {
|
|
for _, value := range values {
|
|
Expect(binary.Write(buf, binary.LittleEndian, value)).To(Succeed())
|
|
}
|
|
}
|
|
buf.WriteString("RIFF")
|
|
write(uint32(4 + 8 + 40 + 8 + dataSize))
|
|
buf.WriteString("WAVE")
|
|
buf.WriteString("fmt ")
|
|
write(uint32(40)) // fmt chunk size for extensible
|
|
write(uint16(0xFFFE)) // wFormatTag = WAVE_FORMAT_EXTENSIBLE
|
|
write(channels, sampleRate) // nChannels, nSamplesPerSec
|
|
write(sampleRate * uint32(blockAlign)) // nAvgBytesPerSec
|
|
write(blockAlign, bitsPerSample) // nBlockAlign, wBitsPerSample
|
|
write(uint16(22), bitsPerSample) // cbSize, wValidBitsPerSample
|
|
write(uint32(0x3)) // dwChannelMask: front left + right
|
|
// KSDATAFORMAT_SUBTYPE_PCM {00000001-0000-0010-8000-00AA00389B71}
|
|
buf.Write([]byte{
|
|
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00,
|
|
0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
|
|
})
|
|
buf.WriteString("data")
|
|
write(dataSize)
|
|
for i := 0; i < frames*int(channels); i++ {
|
|
write(int16(200 * (i % 100)))
|
|
}
|
|
Expect(os.WriteFile(path, buf.Bytes(), 0o600)).To(Succeed())
|
|
}
|
|
|
|
// The distinction these specs defend: /audio/transform used to fold EVERY
|
|
// upload to 16 kHz mono, which made source separation impossible to reach
|
|
// through the HTTP API (htdemucs refuses any rate but its checkpoint's own and
|
|
// separates using the stereo image). AudioToWav keeps that fold for the
|
|
// backends that want it; AudioToWavPreservingShape is what everything else
|
|
// gets.
|
|
var _ = Describe("utils/ffmpeg shape-preserving conversion", func() {
|
|
var dir string
|
|
|
|
BeforeEach(func() {
|
|
dir = GinkgoT().TempDir()
|
|
})
|
|
|
|
It("passes a 44.1 kHz stereo WAV through byte for byte", func() {
|
|
src := filepath.Join(dir, "input.wav")
|
|
dst := filepath.Join(dir, "output.wav")
|
|
writeWAV(src, 44100, 2, 4410)
|
|
|
|
Expect(AudioToWavPreservingShape(src, dst)).To(Succeed())
|
|
|
|
rate, channels, depth := readShape(dst)
|
|
Expect(rate).To(Equal(uint32(44100)), "the source rate must survive")
|
|
Expect(channels).To(Equal(uint16(2)), "the stereo image must survive")
|
|
Expect(depth).To(Equal(uint16(16)))
|
|
|
|
before, err := os.ReadFile(src)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
after, err := os.ReadFile(dst)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(after).To(Equal(before), "a 16-bit PCM WAV is not re-encoded at all")
|
|
})
|
|
|
|
It("keeps an unusual rate and channel count that AudioToWav would destroy", func() {
|
|
src := filepath.Join(dir, "odd.wav")
|
|
preserved := filepath.Join(dir, "preserved.wav")
|
|
folded := filepath.Join(dir, "folded.wav")
|
|
writeWAV(src, 48000, 2, 4800)
|
|
|
|
Expect(AudioToWavPreservingShape(src, preserved)).To(Succeed())
|
|
rate, channels, _ := readShape(preserved)
|
|
Expect(rate).To(Equal(uint32(48000)))
|
|
Expect(channels).To(Equal(uint16(2)))
|
|
|
|
// The contrast, so this spec fails if the two functions ever converge.
|
|
// AudioToWav shells out to ffmpeg here, which the unit suite cannot
|
|
// assume is installed, so the fold is only asserted when it succeeds.
|
|
if err := AudioToWav(src, folded); err == nil {
|
|
foldedRate, foldedChannels, _ := readShape(folded)
|
|
Expect(foldedRate).To(Equal(uint32(16000)), "AudioToWav still folds")
|
|
Expect(foldedChannels).To(Equal(uint16(1)), "AudioToWav still downmixes")
|
|
}
|
|
})
|
|
|
|
It("still passes a 16 kHz mono WAV through", func() {
|
|
src := filepath.Join(dir, "speech.wav")
|
|
dst := filepath.Join(dir, "speech-out.wav")
|
|
writeWAV(src, 16000, 1, 1600)
|
|
|
|
Expect(AudioToWavPreservingShape(src, dst)).To(Succeed())
|
|
|
|
rate, channels, _ := readShape(dst)
|
|
Expect(rate).To(Equal(uint32(16000)))
|
|
Expect(channels).To(Equal(uint16(1)))
|
|
})
|
|
|
|
// The passthrough is about the SHAPE, never about the encoding. An
|
|
// extensible WAV carries plain 16-bit samples behind a 0xFFFE tag, and
|
|
// audio.cpp's reader accepts 16-bit only when the tag is 1, so passing one
|
|
// through unchanged would turn a transcode into "unsupported WAV encoding".
|
|
It("transcodes a WAVE_FORMAT_EXTENSIBLE upload instead of passing it through", func() {
|
|
src := filepath.Join(dir, "extensible.wav")
|
|
dst := filepath.Join(dir, "extensible-out.wav")
|
|
writeExtensibleWAV(src, 44100, 2, 4410)
|
|
Expect(readFormatTag(src)).To(Equal(uint16(0xFFFE)), "the fixture really is extensible")
|
|
|
|
Expect(AudioToWavPreservingShape(src, dst)).To(Succeed())
|
|
|
|
Expect(readFormatTag(dst)).To(Equal(uint16(1)),
|
|
"the output must be plain PCM, which is all a backend's WAV reader accepts")
|
|
rate, channels, depth := readShape(dst)
|
|
Expect(rate).To(Equal(uint32(44100)), "and the transcode still keeps the rate")
|
|
Expect(channels).To(Equal(uint16(2)), "and the channels")
|
|
Expect(depth).To(Equal(uint16(16)))
|
|
|
|
before, err := os.ReadFile(src)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
after, err := os.ReadFile(dst)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(after).ToNot(Equal(before), "it was converted, not hardlinked")
|
|
})
|
|
|
|
It("leaves the source file in place", func() {
|
|
src := filepath.Join(dir, "keep.wav")
|
|
dst := filepath.Join(dir, "keep-out.wav")
|
|
writeWAV(src, 44100, 2, 441)
|
|
|
|
Expect(AudioToWavPreservingShape(src, dst)).To(Succeed())
|
|
Expect(src).To(BeAnExistingFile(), "the upload must survive the conversion")
|
|
})
|
|
})
|
|
|
|
// AudioResample interpolates the caller's sample rate straight into ffmpeg's
|
|
// -ar, and a successful exit does not mean a usable file came out. Measured
|
|
// against ffmpeg 7 on a one second 16 kHz clip: -ar 1 exits 0 and writes 78
|
|
// bytes, a bare header with no audio behind it, whose declared data size still
|
|
// claims 70 bytes so go-audio parses it as a 35 SECOND file. Both the exit
|
|
// status and the parsed duration say "audio"; only the bytes on disk say
|
|
// otherwise. Before this guard that file was returned to the caller as the
|
|
// transformed audio, with a 200.
|
|
var _ = Describe("utils/ffmpeg resample output", func() {
|
|
var dir string
|
|
|
|
BeforeEach(func() {
|
|
dir = GinkgoT().TempDir()
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
Skip("ffmpeg is not installed")
|
|
}
|
|
})
|
|
|
|
It("refuses an output ffmpeg wrote with no audio in it", func() {
|
|
src := filepath.Join(dir, "speech.wav")
|
|
writeWAV(src, 16000, 1, 16000)
|
|
|
|
dst, err := AudioResample(src, 1)
|
|
|
|
Expect(err).To(HaveOccurred(),
|
|
"a header with no audio must not be handed back as the transformed audio")
|
|
Expect(err.Error()).To(ContainSubstring("no audio"))
|
|
Expect(dst).To(BeEmpty())
|
|
})
|
|
|
|
It("resamples normally at a rate inside the endpoint's bounds", func() {
|
|
src := filepath.Join(dir, "speech.wav")
|
|
writeWAV(src, 16000, 1, 16000)
|
|
|
|
dst, err := AudioResample(src, 8000)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
rate, channels, _ := readShape(dst)
|
|
Expect(rate).To(Equal(uint32(8000)))
|
|
Expect(channels).To(Equal(uint16(1)))
|
|
info, err := os.Stat(dst)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(info.Size()).To(BeNumerically(">", 8000), "half a second of 8 kHz mono is 8 kB of PCM")
|
|
})
|
|
|
|
It("is a no-op for an unset rate", func() {
|
|
src := filepath.Join(dir, "speech.wav")
|
|
writeWAV(src, 16000, 1, 160)
|
|
|
|
dst, err := AudioResample(src, 0)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(dst).To(Equal(src))
|
|
})
|
|
})
|