Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/backend.proto
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ message SoundGenerationRequest {
// Empty means "no identity supplied" and backends MUST skip the check.
string ModelIdentity = 18;
map<string, string> params = 19;
optional bool expanded = 20;
}

message TokenizationResponse {
Expand Down
25 changes: 24 additions & 1 deletion backend/cpp/audio-cpp/generation_request.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "generation_request.h"

#include <filesystem>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
Expand Down Expand Up @@ -153,7 +154,8 @@ build_tts_request(const backend::TTSRequest &request,

engine::runtime::TaskRequest
build_sound_generation_request(const backend::SoundGenerationRequest &request,
std::optional<engine::runtime::AudioBuffer> source_audio) {
std::optional<engine::runtime::AudioBuffer> source_audio,
const std::string &family) {
engine::runtime::TaskRequest task;

engine::runtime::Transcript transcript;
Expand Down Expand Up @@ -266,6 +268,27 @@ build_sound_generation_request(const backend::SoundGenerationRequest &request,
// vocal_language. No AudioGeneration family reads a "language" option.
task.options["language"] = request.language();
}
if (request.expanded()) {
if (family != "yue2" && family != "heartmula" &&
family != "ace_step" && family != "stable_audio") {
throw std::invalid_argument(
"audio-cpp: prompt expansion is unsupported for family '" + family + "'");
}
if (family == "heartmula" &&
(request.instrumental() || request.lyrics().empty())) {
throw std::invalid_argument(
"audio-cpp: heartmula prompt expansion requires non-empty lyrics and does not support instrumental requests");
}
// Expanded fields are authoritative even when empty. In particular,
// YuE2 falls back from empty lyrics to transcript text upstream.
task.options["lyrics"] = request.lyrics();
if (family == "yue2" || family == "heartmula") {
task.options[family == "yue2" ? "style" : "tags"] = request.caption();
task.text_input.reset();
} else if (family == "ace_step" || task.text_input->text.empty()) {
task.text_input->text = request.caption();
}
}
return task;
}

Expand Down
5 changes: 4 additions & 1 deletion backend/cpp/audio-cpp/generation_request.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,12 @@ build_tts_request(const backend::TTSRequest &request,

// `source_audio` is SoundGenerationRequest.src already read, present exactly
// when the field was set and non-empty. Same reasoning as above.
// family comes from LoadedModel::family(), never from the request model name.
// Marked expansions reject families without a verified field mapping.
engine::runtime::TaskRequest
build_sound_generation_request(const backend::SoundGenerationRequest &request,
std::optional<engine::runtime::AudioBuffer> source_audio);
std::optional<engine::runtime::AudioBuffer> source_audio,
const std::string &family = "");

// Lifts a text-conditioned transform route's text out of the request params
// into TaskRequest.text_input, and reports whether it set one.
Expand Down
68 changes: 68 additions & 0 deletions backend/cpp/audio-cpp/generation_request_ctest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include <filesystem>
#include <fstream>
#include <string>
#include <stdexcept>
#include <unordered_map>

static int failures = 0;
Expand Down Expand Up @@ -639,7 +640,74 @@ static void test_transform_text_preserves_other_inputs() {
"transform text: the source audio is untouched");
}

static void test_expanded_families() {
backend::SoundGenerationRequest request;
request.set_expanded(true);
request.set_caption("warm jazz");
request.set_lyrics("sing this");
request.set_bpm(123);
request.set_language("en");
request.set_text("raw description");
(*request.mutable_params())["tags"] = "old tags";
auto task = build_sound_generation_request(request, std::nullopt, "heartmula");
check(!task.text_input && task.options.at("tags") == "warm jazz" &&
task.options.at("lyrics") == "sing this", "expanded heartmula: tags and lyrics, no transcript");
task = build_sound_generation_request(request, std::nullopt, "ace_step");
check(task.text_input && task.text_input->text == "warm jazz" &&
task.text_input->language == "en" && task.options.at("bpm") == "123" &&
task.options.at("lyrics") == "sing this", "expanded ace_step: caption transcript and metadata");
request.set_text("");
task = build_sound_generation_request(request, std::nullopt, "stable_audio");
check(task.text_input && task.text_input->text == "warm jazz", "expanded stable_audio: caption fills empty transcript");
request.set_text("explicit transcript");
task = build_sound_generation_request(request, std::nullopt, "stable_audio");
check(task.text_input && task.text_input->text == "explicit transcript", "expanded stable_audio: preserve nonempty transcript");
for (const auto &family : {"unknown", "", "heartmula"}) {
if (std::string(family) == "heartmula") request.set_lyrics("");
bool rejected = false;
try { build_sound_generation_request(request, std::nullopt, family); }
catch (const std::invalid_argument &) { rejected = true; }
check(rejected, std::string("expanded rejects unsupported or empty lyrics: ") + family);
}
request.set_lyrics("sing this");
request.set_instrumental(true);
bool rejected = false;
try { build_sound_generation_request(request, std::nullopt, "heartmula"); }
catch (const std::invalid_argument &) { rejected = true; }
check(rejected, "expanded heartmula: instrumental rejected even with lyrics");
request.set_expanded(false);
(*request.mutable_params())["style"] = "";
task = build_sound_generation_request(request, std::nullopt, "unknown");
check(task.text_input && task.text_input->text == "explicit transcript" &&
task.options.at("style").empty(), "unmarked: unknown family and empty explicit style unchanged");
request.mutable_params()->erase("style");
task = build_sound_generation_request(request, std::nullopt, "unknown");
check(task.options.at("style") == "warm jazz", "unmarked: legacy caption style fallback unchanged");
}

static void test_expanded_yue2() {
backend::SoundGenerationRequest request;
request.set_expanded(true);
request.set_text("raw description");
request.set_caption("soft piano");
request.set_lyrics("");
(*request.mutable_params())["style"] = "conflicting style";
(*request.mutable_params())["lyrics"] = "conflicting lyrics";
const auto task = build_sound_generation_request(request, std::nullopt, "yue2");
check(!task.text_input.has_value(), "expanded yue2: raw text cannot become lyrics");
check(option_or(task.options, "style", "") == "soft piano",
"expanded yue2: caption overrides conflicting style");
check(option_or(task.options, "lyrics", "missing").empty(),
"expanded yue2: empty lyrics override params");
request.set_caption("");
const auto empty = build_sound_generation_request(request, std::nullopt, "yue2");
check(empty.options.at("style").empty() && !empty.text_input,
"expanded yue2: empty caption clears params style without raw fallback");
}

int main() {
test_expanded_families();
test_expanded_yue2();
test_voice_is_reference_file();
test_tts_shape();
test_tts_plain();
Expand Down
7 changes: 6 additions & 1 deletion backend/cpp/audio-cpp/grpc-server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
Expand Down Expand Up @@ -1316,7 +1317,7 @@ class AudioCppBackend final : public backend::Backend::Service {
request->src(), kVoiceReferenceSampleRate);
}
const auto task = audiocpp_backend::build_sound_generation_request(
*request, std::move(source));
*request, std::move(source), model->family());

audiocpp_backend::LaneEntry lane = model->acquire(0);
const auto session = model->session_for(
Expand All @@ -1334,6 +1335,10 @@ class AudioCppBackend final : public backend::Backend::Service {
result->set_success(true);
result->set_message(request->dst());
return GStatus::OK;
} catch (const std::invalid_argument &err) {
result->set_success(false);
result->set_message(err.what());
return GStatus(grpc::StatusCode::INVALID_ARGUMENT, err.what());
} catch (const std::exception &err) {
result->set_success(false);
result->set_message(err.what());
Expand Down
2 changes: 1 addition & 1 deletion backend/python/ace-step/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ def SoundGeneration(self, request, context):
if not request.dst:
return backend_pb2.Result(success=False, message="request.dst is required")

use_simple = bool(request.text)
use_simple = bool(request.text) and not request.expanded
if use_simple:
payload = {
"sample_query": request.text or "",
Expand Down
51 changes: 51 additions & 0 deletions backend/python/ace-step/test_sound_expansion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# SPDX-License-Identifier: MIT
"""Test the Sound request adapter without importing model dependencies."""
import ast
from pathlib import Path
from types import SimpleNamespace
import unittest


class SoundExpansionTests(unittest.TestCase):
def setUp(self):
source = ast.parse(Path(__file__).with_name("backend.py").read_text())
service = next(node for node in source.body
if isinstance(node, ast.ClassDef) and node.name == "BackendServicer")
method = next(node for node in service.body
if isinstance(node, ast.FunctionDef) and node.name == "SoundGeneration")
self.calls = []
namespace = {
"backend_pb2": SimpleNamespace(Result=lambda **fields: SimpleNamespace(**fields)),
"_generate_audio_sync": lambda owner, payload, dst: self.calls.append(payload),
}
exec(compile(ast.Module(body=[method], type_ignores=[]), "backend.py", "exec"), namespace)
self.generate = namespace["SoundGeneration"]

def request(self, **fields):
values = dict(dst="output.wav", text="original description", expanded=False,
language="it", instrumental=True, caption="piano", lyrics="",
think=False, src="", bpm=120, keyscale="", timesignature="", duration=0)
values.update(fields)
values["HasField"] = lambda name: name in {"instrumental", "bpm"}
return SimpleNamespace(**values)

def test_omission_preserves_native_sample_query(self):
self.generate(None, self.request(), None)
self.assertEqual(self.calls[0]["sample_query"], "original description")
self.assertTrue(self.calls[0]["sample_mode"])

def test_expanded_uses_structured_caption_and_empty_lyrics(self):
self.generate(None, self.request(expanded=True, text=""), None)
self.assertEqual(self.calls[0]["prompt"], "piano")
self.assertEqual(self.calls[0]["lyrics"], "")
self.assertEqual(self.calls[0]["bpm"], 120)
self.assertNotIn("sample_query", self.calls[0])

def test_marker_prevents_native_expansion_even_with_stale_text(self):
self.generate(None, self.request(expanded=True), None)
self.assertEqual(self.calls[0]["prompt"], "piano")
self.assertNotIn("sample_query", self.calls[0])


if __name__ == "__main__":
unittest.main()
Loading
Loading