Files
LocalAI/pkg/utils/ffmpeg.go
Ettore Di Giacinto dc0ac45c5c feat(audio-transform): stop folding every upload to 16 kHz mono, and name the separation stems
Two defects that made source separation unusable through LocalAI's own API,
even though the backend served it correctly over gRPC.

/audio/transform normalized every upload to 16 kHz mono s16 through
utils.AudioToWav, with no way past it. htdemucs and mel_band_roformer refuse
any rate but their checkpoint's own and separate a centred vocal from a wide
mix using the stereo image, so every separation request through the HTTP API
died with "HTDemucs prepare() sample rate mismatch: expected 44100, got 16000"
while the same call over gRPC worked. The fold is not wrong, it is
backend-specific: LocalVQE's echo cancellation genuinely wants 16 kHz mono and
needs the reference in the same shape. So it becomes a declaration,
BackendCapability.AudioTransformInputMono16k, set for localvqe and for nothing
else. A backend that declares nothing gets its upload unchanged, which means no
backend has to opt in to work. utils.AudioToWavPreservingShape is the
non-folding conversion: a 16-bit PCM WAV passes through byte for byte at any
rate and channel count, anything else is transcoded to WAV with its rate and
channel layout kept.

The other defect is that the run-once stem design bought nothing. A separation
backend writes every stem beside dst from one inference, but AudioTransformResult
carried only dst, so the other three were files no caller could find and a
caller wanting all four had to run four separations. AudioTransformResult grows
a repeated AudioTransformStem, the backend fills it, core/backend validates that
each path really is inside the generated-content directory it handed over, and
the endpoint publishes them as an X-Audio-Stems JSON header beside the existing
X-Audio-Input-Url. JSON because a stem name is the model's own string and could
contain any separator a hand-rolled format would use.

Verified end to end through the HTTP endpoint with htdemucs f16 on a 44.1 kHz
stereo file: 200 with a 44.1 kHz stereo body, all four stems named and fetchable
through /generated-audio/, body byte identical to the selected stem, and
params[stem]=drums returning a different one. The same upload sent to a model
whose backend is localvqe still reaches the backend as 16 kHz mono, confirmed
both by the engine's own rate refusal and by the persisted input file.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-29 19:03:33 +00:00

193 lines
6.0 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 16-bit PCM WAV at ANY sample rate
// and ANY channel count. Deliberately weaker than isTargetWav: it is the "no
// conversion needed" test for callers that want the file's own shape kept.
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
}
// 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
}