mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 10:28:43 -04:00
Four fixes from the second review, plus one bug they made visible. isPCM16Wav tested only the bit depth, and go-audio's IsValidFile never looks at the format tag, so a 16-bit WAVE_FORMAT_EXTENSIBLE (0xFFFE) upload was passed through untouched where the old fold would have transcoded it. audio.cpp's WAV reader accepts 16-bit only when the tag is 1, so such a file died with "unsupported WAV encoding". Extensible is what many DAWs and Windows tools write and music files are this endpoint's new headline input, so it is a first-contact failure rather than a corner. The check now requires tag 1, with a spec that fails against the old implementation. Stem URLs are percent-escaped. A stem name is the model's own string and legally contains a space, a '#', a '?' or a '%'; an unescaped '#' truncates the URL before the request is even sent. The name field keeps the raw name. sample_rate and response_format are applied to the stems as well as to dst. Applying beat documenting: dst IS one of those stems, so leaving them alone broke the "dst duplicates the selected stem" invariant the whole design rests on, and both conversions are no-ops when unset. A stem whose conversion fails is dropped from the header rather than advertised in the wrong shape. Verifying that turned up why it had never been noticed: the two fields were never bound at all. The request arrives as multipart/form-data and echo's binder falls back to the FIELD NAME without a form tag, matching only case-insensitively, so "SampleRate" never matched "sample_rate" and "Format" never matched "response_format". Both were documented in the endpoint table and silently ignored. Two form tags fix it, and with them the conversion is observable end to end. Docs: audio-transform.md now documents what LocalAI does to an upload before the backend sees it, which backend gets the 16 kHz mono fold and why, params[stem], and the X-Audio-Stems header with a worked example. Also records the known limitation that the fold lookup is on the bare backend name, so pinned variants (vulkan-localvqe) do not match, and points at IsLlamaCppBackend as the suffix-tolerant precedent. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
208 lines
7.8 KiB
Go
208 lines
7.8 KiB
Go
package utils_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"os"
|
|
"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")
|
|
})
|
|
})
|