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>
202 lines
6.6 KiB
Go
202 lines
6.6 KiB
Go
package utils
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
laudio "github.com/mudler/LocalAI/pkg/audio"
|
|
|
|
"github.com/go-audio/wav"
|
|
)
|
|
|
|
func ffmpegCommand(args []string) (string, error) {
|
|
cmd := exec.Command("ffmpeg", args...) // Constrain this to ffmpeg to permit security scanner to see that the command is safe.
|
|
cmd.Env = []string{}
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// AudioToWav converts audio to wav for transcribe (16 kHz mono s16le).
|
|
// WAV files already in the target format are passed through directly;
|
|
// everything else is converted via ffmpeg.
|
|
//
|
|
// The pass-through uses a hardlink (with a Copy fallback for cross-fs
|
|
// src/dst) rather than Rename — callers may invoke this twice against
|
|
// the same fixture (e.g. once for AudioTranscription and once for
|
|
// AudioTranscriptionStream) and expect the original file to remain.
|
|
func AudioToWav(src, dst string) error {
|
|
if strings.HasSuffix(src, ".wav") && isTargetWav(src) {
|
|
return passthroughWAV(src, dst)
|
|
}
|
|
return convertWithFFmpeg(src, dst)
|
|
}
|
|
|
|
// AudioToWavPreservingShape converts audio to 16-bit PCM WAV while KEEPING the
|
|
// source sample rate and channel count. A WAV that is already 16-bit PCM is
|
|
// passed through byte for byte, whatever its rate or channel count.
|
|
//
|
|
// It is the counterpart to AudioToWav, which folds everything to 16 kHz mono.
|
|
// That fold is right for speech backends and destructive for the rest: source
|
|
// separation models refuse any rate but their checkpoint's own, and separate a
|
|
// centred vocal from a wide mix using the stereo image, so a 16 kHz mono
|
|
// downmix removes both the format they accept and the cue they work from.
|
|
// Callers pick between the two from the BACKEND'S declared need
|
|
// (config.AudioTransformRequiresMono16kInput), never from the file.
|
|
//
|
|
// Non-WAV uploads are still transcoded, since backends read WAV. Only the rate
|
|
// and the channel layout survive that.
|
|
func AudioToWavPreservingShape(src, dst string) error {
|
|
if strings.HasSuffix(src, ".wav") && isPCM16Wav(src) {
|
|
return passthroughWAV(src, dst)
|
|
}
|
|
return convertWithFFmpegPreservingShape(src, dst)
|
|
}
|
|
|
|
func passthroughWAV(src, dst string) error {
|
|
if err := os.Link(src, dst); err == nil {
|
|
return nil
|
|
}
|
|
// Fallback: copy. Hardlink fails across filesystems (e.g. src on a
|
|
// read-only mount, dst in /tmp) or when the destination already
|
|
// exists — both are fine; just copy bytes.
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return fmt.Errorf("open src: %w", err)
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return fmt.Errorf("create dst: %w", err)
|
|
}
|
|
defer out.Close()
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
return fmt.Errorf("copy: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isTargetWav returns true when src is a valid WAV already in the
|
|
// target format (16 kHz, mono, 16-bit PCM).
|
|
func isTargetWav(src string) bool {
|
|
f, err := os.Open(src)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
|
|
dec := wav.NewDecoder(f)
|
|
if !dec.IsValidFile() {
|
|
return false
|
|
}
|
|
return dec.BitDepth == 16 && dec.NumChans == 1 && dec.SampleRate == 16000
|
|
}
|
|
|
|
func convertWithFFmpeg(src, dst string) error {
|
|
commandArgs := []string{"-i", src, "-format", "s16le", "-ar", "16000", "-ac", "1", "-acodec", "pcm_s16le", dst}
|
|
out, err := ffmpegCommand(commandArgs)
|
|
if err != nil {
|
|
return fmt.Errorf("error: %w out: %s", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isPCM16Wav returns true when src is a valid WAV carrying PLAIN 16-bit PCM, at
|
|
// ANY sample rate and ANY channel count. Weaker than isTargetWav on rate and
|
|
// channels; exactly as strict on the encoding, and deliberately so.
|
|
//
|
|
// The format tag is checked and that is not a formality. go-audio's
|
|
// IsValidFile() never inspects it, so a BitDepth == 16 test on its own also
|
|
// accepts WAVE_FORMAT_EXTENSIBLE (0xFFFE), which is what many DAWs and Windows
|
|
// tools write. Consumers are not as relaxed: audio.cpp's WAV reader accepts
|
|
// 16-bit only when the tag is 1 and otherwise refuses with "unsupported WAV
|
|
// encoding". Passing such a file through unchanged would trade a transcode for
|
|
// a failed request, and music files, which are the input this passthrough
|
|
// exists for, are exactly where extensible turns up.
|
|
func isPCM16Wav(src string) bool {
|
|
f, err := os.Open(src)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
dec := wav.NewDecoder(f)
|
|
if !dec.IsValidFile() {
|
|
return false
|
|
}
|
|
return dec.BitDepth == 16 && dec.WavAudioFormat == 1
|
|
}
|
|
|
|
// convertWithFFmpegPreservingShape transcodes to 16-bit PCM WAV with no -ar and
|
|
// no -ac, so ffmpeg keeps the source rate and channel count. Dropping those two
|
|
// flags is the whole difference from convertWithFFmpeg.
|
|
func convertWithFFmpegPreservingShape(src, dst string) error {
|
|
commandArgs := []string{"-y", "-i", src, "-acodec", "pcm_s16le", dst}
|
|
out, err := ffmpegCommand(commandArgs)
|
|
if err != nil {
|
|
return fmt.Errorf("error: %w out: %s", err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AudioResample resamples an audio file to the given sample rate using ffmpeg.
|
|
// If sampleRate <= 0, it is a no-op and returns src unchanged.
|
|
func AudioResample(src string, sampleRate int) (string, error) {
|
|
if sampleRate <= 0 {
|
|
return src, nil
|
|
}
|
|
dst := strings.Replace(src, ".wav", fmt.Sprintf("_%dhz.wav", sampleRate), 1)
|
|
commandArgs := []string{"-y", "-i", src, "-ar", fmt.Sprintf("%d", sampleRate), dst}
|
|
out, err := ffmpegCommand(commandArgs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error resampling audio: %w out: %s", err, out)
|
|
}
|
|
return dst, nil
|
|
}
|
|
|
|
// AudioConvert converts generated wav file from tts to other output formats.
|
|
// TODO: handle pcm to have 100% parity of supported format from OpenAI
|
|
func AudioConvert(src string, format string) (string, error) {
|
|
extension := ""
|
|
// compute file extension from format, default to wav
|
|
switch format {
|
|
case "opus":
|
|
extension = ".ogg"
|
|
case "mp3", "aac", "flac":
|
|
extension = fmt.Sprintf(".%s", format)
|
|
default:
|
|
extension = ".wav"
|
|
}
|
|
|
|
// if .wav, do nothing
|
|
if extension == ".wav" {
|
|
return src, nil
|
|
}
|
|
|
|
// naive conversion based on default values and target extension of file
|
|
dst := strings.Replace(src, ".wav", extension, -1)
|
|
commandArgs := []string{"-y", "-i", src, "-vn", dst}
|
|
out, err := ffmpegCommand(commandArgs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("error: %w out: %s", err, out)
|
|
}
|
|
return dst, nil
|
|
}
|
|
|
|
// WriteWav16kFromReader reads all PCM data from r and writes a 16 kHz mono
|
|
// 16-bit WAV to w. Useful when the PCM length is not known in advance.
|
|
func WriteWav16kFromReader(w io.Writer, r io.Reader) error {
|
|
pcm, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return fmt.Errorf("read pcm: %w", err)
|
|
}
|
|
hdr := laudio.NewWAVHeader(uint32(len(pcm)))
|
|
if err := hdr.Write(w); err != nil {
|
|
return fmt.Errorf("write wav header: %w", err)
|
|
}
|
|
_, err = w.Write(pcm)
|
|
return err
|
|
}
|