From a551c83465ee538367358f239de5a364ebea9329 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Mon, 12 Oct 2026 18:49:17 +0200 Subject: [PATCH] fix(audio): preserve generated sound recordings (#12641) FLAC normalization renames the backend's WAV destination. The next request then reuses that apparently vacant path and replaces the first recording when normalization runs again. Give each request a UUID filename, and test that successive FLAC outputs retain distinct paths and their original bytes. Addresses the overwrite report in #12633. Reference-audio input remains outside this fix. Assisted-by: Codex:gpt-6 Signed-off-by: Ettore Di Giacinto Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- .../backend/model_identity_modalities_test.go | 6 +++ core/backend/soundgeneration.go | 5 +- core/backend/soundgeneration_history_test.go | 50 +++++++++++++++++++ docs/content/features/text-to-audio.md | 4 ++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 core/backend/soundgeneration_history_test.go diff --git a/core/backend/model_identity_modalities_test.go b/core/backend/model_identity_modalities_test.go index cc77a87b8..39d7930e2 100644 --- a/core/backend/model_identity_modalities_test.go +++ b/core/backend/model_identity_modalities_test.go @@ -49,6 +49,7 @@ type recordingBackend struct { // `ModelIdentity`; the spec below asserts they are populated independently. soundGenModelField string soundGenParams map[string]string + soundGenOutput []byte } func newRecordingBackend() *recordingBackend { @@ -73,6 +74,11 @@ func (r *recordingBackend) SoundGeneration(_ context.Context, in *pb.SoundGenera r.record("SoundGeneration", in) r.soundGenModelField = in.GetModel() r.soundGenParams = in.GetParams() + if r.soundGenOutput != nil { + if err := os.WriteFile(in.GetDst(), r.soundGenOutput, 0o600); err != nil { + return nil, err + } + } return &pb.Result{Success: true}, nil } diff --git a/core/backend/soundgeneration.go b/core/backend/soundgeneration.go index 98fb64201..31ea0e7f2 100644 --- a/core/backend/soundgeneration.go +++ b/core/backend/soundgeneration.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/google/uuid" "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/trace" "github.com/mudler/LocalAI/pkg/grpc/proto" @@ -56,7 +57,9 @@ func SoundGeneration( return "", nil, fmt.Errorf("failed creating audio directory: %s", err) } - fileName := utils.GenerateUniqueFileName(audioDir, "sound_generation", ".wav") + // Format normalization can rename .wav to .flac, so existence of the + // .wav path cannot determine whether an earlier recording owns this stem. + fileName := utils.GenerateUniqueFileName(audioDir, "sound_generation_"+uuid.NewString(), ".wav") filePath := filepath.Join(audioDir, fileName) if filePath, err = filepath.Abs(filePath); err != nil { return "", nil, fmt.Errorf("failed resolving sound generation path: %w", err) diff --git a/core/backend/soundgeneration_history_test.go b/core/backend/soundgeneration_history_test.go new file mode 100644 index 000000000..3888de03e --- /dev/null +++ b/core/backend/soundgeneration_history_test.go @@ -0,0 +1,50 @@ +package backend_test + +import ( + "context" + "os" + "path/filepath" + + "github.com/mudler/LocalAI/core/backend" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/audio" + "github.com/mudler/LocalAI/pkg/system" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("sound generation history", func() { + It("preserves previous recordings after FLAC extension normalization", func() { + rec := newRecordingBackend() + loader := newRecordingLoader(rec) + appCfg := config.NewApplicationConfig(config.WithSystemState(&system.SystemState{})) + appCfg.GeneratedContentDir = GinkgoT().TempDir() + cfg := identityModelCfg() + + generate := func(content []byte) string { + rec.soundGenOutput = content + path, _, err := backend.SoundGeneration(context.Background(), "music", nil, nil, nil, nil, nil, nil, "", "", nil, "", "", "", nil, nil, loader, appCfg, cfg) + Expect(err).ToNot(HaveOccurred()) + // The endpoint detects the backend's format and renames its .wav + // destination. Only the FLAC signature is needed for detection. + finalPath, contentType := audio.NormalizeAudioFile(path) + Expect(contentType).To(Equal("audio/flac")) + Expect(filepath.Ext(finalPath)).To(Equal(".flac")) + return finalPath + } + + firstAudio := []byte("fLaC first recording fixture") + secondAudio := []byte("fLaC second recording fixture") + firstPath := generate(firstAudio) + secondPath := generate(secondAudio) + + Expect(secondPath).ToNot(Equal(firstPath)) + first, err := os.ReadFile(firstPath) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(Equal(firstAudio)) + second, err := os.ReadFile(secondPath) + Expect(err).ToNot(HaveOccurred()) + Expect(second).To(Equal(secondAudio)) + }) +}) diff --git a/docs/content/features/text-to-audio.md b/docs/content/features/text-to-audio.md index 98e2a3913..c8109ba57 100644 --- a/docs/content/features/text-to-audio.md +++ b/docs/content/features/text-to-audio.md @@ -384,6 +384,10 @@ curl http://localhost:8080/v1/sound-generation -H "Content-Type: application/jso The `/v1/sound-generation` endpoint is compatible with the [ElevenLabs sound generation API](https://elevenlabs.io/docs/api-reference/sound-generation) and can produce music, sound effects, and other audio content. It responds with a binary audio file and the appropriate `Content-Type` header (for example `audio/wav`, `audio/mpeg`, `audio/flac`, or `audio/ogg`). The request body is JSON and supports two usage modes. +Each generated sound has a unique filename. The filename remains unique when +LocalAI changes its extension to match the audio format, so later generations +do not overwrite earlier recordings in the WebUI history. + **Simple mode:** | Parameter | Type | Required | Description |