Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ MINIMAX_BASE_URL=
# 声音复刻与合成模型
MINIMAX_VOICE_CLONE_MODEL=speech-2.8-hd

# MiniMax 音乐生成配置 🆕
# 音乐生成模型: music-3.0 / music-2.6 / music-3.0-free / music-2.6-free
MINIMAX_MUSIC_MODEL=music-3.0
# 接口返回形式: hex(直接返回音频数据)或 url(返回临时下载地址,有效期24小时)
MINIMAX_MUSIC_OUTPUT_FORMAT=hex
# 音频编码格式: mp3 / wav / pcm
MINIMAX_MUSIC_AUDIO_FORMAT=mp3
# 是否添加AIGC水印(仅 cn_zh 区域支持)
MINIMAX_MUSIC_AIGC_WATERMARK=false

# 人脸检测配置
# 检测方法: opencv(轻量级,使用OpenCV Haar Cascade)或 llm(大模型,使用火山引擎多模态模型)
FACE_DETECTION_METHOD=opencv
Expand Down
1 change: 1 addition & 0 deletions api/app/domain/services/agent_task_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ async def _handle_tool_event(self, event: ToolEvent) -> None:
"virtual_anchor",
"qwen_tts",
"minimax_voice",
"minimax_music",
"audio_mixing",
]:
result_payload: Any = None
Expand Down
2 changes: 2 additions & 0 deletions api/app/domain/services/flows/planner_react.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from app.domain.services.tools.image_generation import ImageGenerationTool
from app.domain.services.tools.mcp import MCPTool
from app.domain.services.tools.message import MessageTool
from app.domain.services.tools.minimax_music import MiniMaxMusicTool
from app.domain.services.tools.minimax_voice import MiniMaxVoiceTool
from app.domain.services.tools.model_3d_generation import Model3DGenerationTool
from app.domain.services.tools.qwen_tts import QwenTTSTool
Expand Down Expand Up @@ -75,6 +76,7 @@ def __init__(
VirtualAnchorGenerationTool(),
QwenTTSTool(),
MiniMaxVoiceTool(),
MiniMaxMusicTool(),
AudioMixingTool(),
mcp_tool,
a2a_tool,
Expand Down
55 changes: 55 additions & 0 deletions api/app/domain/services/tools/minimax_music.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from typing import Optional

from app.domain.models.tool_result import ToolResult
from .base import BaseTool, tool
from .multimodal_core import MultimodalCore


class MiniMaxMusicTool(BaseTool):
name: str = "minimax_music"

def __init__(self) -> None:
super().__init__()
self.core = MultimodalCore()

@tool(
name="minimax_music_generation",
description="根据风格描述与歌词生成音乐,返回本地音频文件。",
parameters={
"prompt": {"type": "string", "description": "音乐风格、情绪或场景描述"},
"lyrics": {"type": "string", "description": "歌词,换行分隔,可选"},
"model": {"type": "string", "description": "音乐生成模型,可选"},
"output_format": {
"type": "string",
"enum": list(MultimodalCore.MINIMAX_MUSIC_OUTPUT_FORMATS),
"description": "接口返回形式,可选",
},
"audio_format": {
"type": "string",
"enum": list(MultimodalCore.MINIMAX_MUSIC_AUDIO_FORMATS),
"description": "音频编码格式,可选",
},
"is_instrumental": {"type": "boolean", "description": "是否生成纯伴奏,可选"},
"lyrics_optimizer": {"type": "boolean", "description": "是否自动优化歌词,可选"},
},
required=["prompt"],
)
async def minimax_music_generation(
self,
prompt: Optional[str] = None,
lyrics: Optional[str] = None,
model: Optional[str] = None,
output_format: Optional[str] = None,
audio_format: Optional[str] = None,
is_instrumental: Optional[bool] = None,
lyrics_optimizer: Optional[bool] = None,
) -> ToolResult:
return await self.core.generate_music(
prompt=prompt,
lyrics=lyrics,
model=model,
output_format=output_format,
audio_format=audio_format,
is_instrumental=is_instrumental,
lyrics_optimizer=lyrics_optimizer,
)
124 changes: 124 additions & 0 deletions api/app/domain/services/tools/multimodal_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@


class MultimodalCore:
# MiniMax music generation constraints
MINIMAX_MUSIC_OUTPUT_FORMATS = ("url", "hex")
MINIMAX_MUSIC_AUDIO_FORMATS = ("mp3", "wav", "pcm")
MINIMAX_MUSIC_STATUS_IN_PROGRESS = 1
MINIMAX_MUSIC_STATUS_COMPLETED = 2
MINIMAX_MUSIC_URL_TTL_HOURS = 24

def __init__(self) -> None:
self.base_dir = Path(__file__).resolve().parents[4]
self.storage_dir = self.base_dir / "storage"
Expand Down Expand Up @@ -64,6 +71,10 @@ def __init__(self) -> None:
self.minimax_base_url = os.getenv("MINIMAX_BASE_URL", "").strip()
self.minimax_voice_clone_model = os.getenv("MINIMAX_VOICE_CLONE_MODEL", "speech-2.8-hd").strip()
self.minimax_voice_synthesis_model = os.getenv("MINIMAX_VOICE_SYNTHESIS_MODEL", self.minimax_voice_clone_model).strip()
self.minimax_music_model = os.getenv("MINIMAX_MUSIC_MODEL", "music-3.0").strip()
self.minimax_music_output_format = os.getenv("MINIMAX_MUSIC_OUTPUT_FORMAT", "hex").strip().lower()
self.minimax_music_audio_format = os.getenv("MINIMAX_MUSIC_AUDIO_FORMAT", "mp3").strip().lower()
self.minimax_music_aigc_watermark = os.getenv("MINIMAX_MUSIC_AIGC_WATERMARK", "false").strip().lower() == "true"
self.face_detection_method = os.getenv("FACE_DETECTION_METHOD", "llm").strip().lower()

@staticmethod
Expand Down Expand Up @@ -1052,6 +1063,119 @@ async def minimax_voice_cloning(self, reference_audio: str, text: str, model: Op
except Exception as e:
return ToolResult(success=False, message=f"声音复刻失败: {e}")

def _minimax_music_payload(
self,
model: str,
prompt: Optional[str],
lyrics: Optional[str],
output_format: str,
audio_format: str,
is_instrumental: Optional[bool],
lyrics_optimizer: Optional[bool],
) -> Dict[str, Any]:
"""Build the /v1/music_generation request body; only `model` is mandatory."""
payload: Dict[str, Any] = {
"model": model,
"output_format": output_format,
# Streamed responses only carry hex chunks, so always request the
# non-streaming form and persist one complete file instead.
"stream": False,
"audio_setting": {"format": audio_format},
}
if prompt:
payload["prompt"] = prompt
if lyrics:
payload["lyrics"] = lyrics
if is_instrumental is not None:
payload["is_instrumental"] = is_instrumental
if lyrics_optimizer is not None:
payload["lyrics_optimizer"] = lyrics_optimizer
# `aigc_watermark` is only accepted by the cn_zh endpoint
if self.minimax_region == "cn_zh" and self.minimax_music_aigc_watermark:
payload["aigc_watermark"] = True
return payload

def _extract_minimax_music_audio(self, data: Dict[str, Any]) -> str:
"""Validate a music generation response and return the completed audio payload."""
base_resp = (data.get("base_resp") or {}) if isinstance(data, dict) else {}
if base_resp.get("status_code") not in (0, None):
raise RuntimeError(base_resp.get("status_msg") or f"status_code={base_resp.get('status_code')}")
result = (data.get("data") or {}) if isinstance(data, dict) else {}
status = result.get("status")
if status == self.MINIMAX_MUSIC_STATUS_IN_PROGRESS:
raise RuntimeError("音乐仍在生成中,请稍后重试")
if status not in (self.MINIMAX_MUSIC_STATUS_COMPLETED, None):
raise RuntimeError(f"音乐生成未完成: status={status}")
audio = result.get("audio")
if not audio:
raise RuntimeError("音乐生成返回为空")
return audio

async def _store_minimax_music_audio(self, audio: str, output_format: str, audio_format: str, text: str) -> str:
"""Persist a url- or hex-form music result into the local audio directory."""
ext = f".{audio_format}"
if output_format == "url":
return await self._download_to(audio, self.audios_dir, "audios", "music", text, ext)
return self._save_bytes_to(bytes.fromhex(audio), self.audios_dir, "audios", "music", text, ext)

async def generate_music(
self,
prompt: Optional[str] = None,
lyrics: Optional[str] = None,
model: Optional[str] = None,
output_format: Optional[str] = None,
audio_format: Optional[str] = None,
is_instrumental: Optional[bool] = None,
lyrics_optimizer: Optional[bool] = None,
) -> ToolResult:
try:
if not self.minimax_api_key:
return ToolResult(success=False, message="未配置 MINIMAX_API_KEY")
if not prompt and not lyrics:
return ToolResult(success=False, message="prompt 与 lyrics 至少需要提供一个")
music_model = (model or self.minimax_music_model).strip()
resolved_output = (output_format or self.minimax_music_output_format or "hex").strip().lower()
if resolved_output not in self.MINIMAX_MUSIC_OUTPUT_FORMATS:
return ToolResult(success=False, message=f"不支持的返回格式: {resolved_output},可选 {'/'.join(self.MINIMAX_MUSIC_OUTPUT_FORMATS)}")
resolved_audio = (audio_format or self.minimax_music_audio_format or "mp3").strip().lower()
if resolved_audio not in self.MINIMAX_MUSIC_AUDIO_FORMATS:
return ToolResult(success=False, message=f"不支持的音频格式: {resolved_audio},可选 {'/'.join(self.MINIMAX_MUSIC_AUDIO_FORMATS)}")
payload = self._minimax_music_payload(
music_model,
prompt,
lyrics,
resolved_output,
resolved_audio,
is_instrumental,
lyrics_optimizer,
)
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(
self._minimax_endpoint("/v1/music_generation"),
json=payload,
headers=self._minimax_headers(),
)
resp.raise_for_status()
data = resp.json()
audio = self._extract_minimax_music_audio(data)
audio_url = await self._store_minimax_music_audio(audio, resolved_output, resolved_audio, prompt or lyrics or "")
result_data: Dict[str, Any] = {
"audio_url": audio_url,
"local_path": audio_url,
"model": music_model,
"output_format": resolved_output,
"audio_format": resolved_audio,
"is_instrumental": bool(is_instrumental),
"provider": "minimax-music-generation",
}
if resolved_output == "url":
# The remote address expires, so keep it only for troubleshooting
result_data["original_url"] = audio
result_data["original_url_ttl_hours"] = self.MINIMAX_MUSIC_URL_TTL_HOURS
return ToolResult(success=True, message="音乐生成成功", data=result_data)
except Exception as e:
return ToolResult(success=False, message=f"音乐生成失败: {e}")

async def concatenate_audio(self, audio_files: List[str], crossfade_duration: int = 200, silence_duration: int = 1200) -> ToolResult:
try:
if len(audio_files) < 2:
Expand Down
121 changes: 121 additions & 0 deletions api/tests/test_minimax_music_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Unit tests for the MiniMax music generation tool wiring."""
import asyncio

from app.domain.services.tools.minimax_music import MiniMaxMusicTool


def _tool_without_key() -> MiniMaxMusicTool:
tool = MiniMaxMusicTool()
tool.core.minimax_api_key = ""
return tool


def _tool_with_key() -> MiniMaxMusicTool:
tool = MiniMaxMusicTool()
tool.core.minimax_api_key = "test-key"
return tool


def test_tool_registers_music_generation():
"""The music generation operation is exposed to the LLM tool schema."""
tool = MiniMaxMusicTool()
names = {schema["function"]["name"] for schema in tool.get_tools()}
assert "minimax_music_generation" in names
assert tool.has_tool("minimax_music_generation")


def test_music_endpoint_covers_both_regions():
"""Music generation resolves the global and CN hosts of the same path."""
core = MiniMaxMusicTool().core
core.minimax_base_url = ""
core.minimax_group_id = ""
core.minimax_region = "global_en"
assert core._minimax_endpoint("/v1/music_generation") == "https://api.minimax.io/v1/music_generation"
core.minimax_region = "cn_zh"
assert core._minimax_endpoint("/v1/music_generation") == "https://api.minimaxi.com/v1/music_generation"


def test_payload_defaults_and_optional_fields():
"""Only the model is mandatory; optional fields appear when provided."""
core = MiniMaxMusicTool().core
core.minimax_region = "global_en"
minimal = core._minimax_music_payload("music-3.0", "calm piano", None, "hex", "mp3", None, None)
assert minimal == {
"model": "music-3.0",
"output_format": "hex",
"stream": False,
"audio_setting": {"format": "mp3"},
"prompt": "calm piano",
}
full = core._minimax_music_payload("music-2.6", "rock", "line one\nline two", "url", "wav", True, False)
assert full["lyrics"] == "line one\nline two"
assert full["is_instrumental"] is True
assert full["lyrics_optimizer"] is False
assert full["output_format"] == "url"
assert full["audio_setting"] == {"format": "wav"}


def test_watermark_field_is_cn_only():
"""The watermark flag is only sent to the CN endpoint."""
core = MiniMaxMusicTool().core
core.minimax_music_aigc_watermark = True
core.minimax_region = "global_en"
assert "aigc_watermark" not in core._minimax_music_payload("music-3.0", "jazz", None, "hex", "mp3", None, None)
core.minimax_region = "cn_zh"
assert core._minimax_music_payload("music-3.0", "jazz", None, "hex", "mp3", None, None)["aigc_watermark"] is True


def test_completed_response_returns_audio():
"""A completed task exposes the audio payload."""
core = MiniMaxMusicTool().core
data = {"data": {"status": 2, "audio": "1a2b3c"}, "base_resp": {"status_code": 0}}
assert core._extract_minimax_music_audio(data) == "1a2b3c"


def test_in_progress_response_is_rejected():
"""An unfinished task does not yield audio."""
core = MiniMaxMusicTool().core
data = {"data": {"status": 1, "audio": ""}, "base_resp": {"status_code": 0}}
try:
core._extract_minimax_music_audio(data)
except RuntimeError:
pass
else:
raise AssertionError("in-progress response should raise")


def test_api_error_and_empty_audio_are_rejected():
"""Non-zero status codes and empty audio are reported as failures."""
core = MiniMaxMusicTool().core
for data in (
{"data": {"status": 2, "audio": "1a2b"}, "base_resp": {"status_code": 1004, "status_msg": "bad key"}},
{"data": {"status": 2, "audio": ""}, "base_resp": {"status_code": 0}},
):
try:
core._extract_minimax_music_audio(data)
except RuntimeError:
continue
raise AssertionError("invalid response should raise")


def test_music_generation_requires_api_key():
"""Music generation fails gracefully when no API key is configured."""
tool = _tool_without_key()
result = asyncio.run(tool.core.generate_music(prompt="calm piano"))
assert result.success is False


def test_music_generation_requires_prompt_or_lyrics():
"""At least one of prompt or lyrics must be supplied."""
tool = _tool_with_key()
result = asyncio.run(tool.core.generate_music())
assert result.success is False


def test_music_generation_validates_formats():
"""Unsupported output and audio formats are rejected before any request."""
tool = _tool_with_key()
bad_output = asyncio.run(tool.core.generate_music(prompt="calm piano", output_format="mp4"))
assert bad_output.success is False
bad_audio = asyncio.run(tool.core.generate_music(prompt="calm piano", audio_format="flac"))
assert bad_audio.success is False
1 change: 1 addition & 0 deletions ui/src/components/tool-preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function getCompactToolContent(tool: ToolEvent): unknown {
'virtual_anchor',
'qwen_tts',
'minimax_voice',
'minimax_music',
'audio_mixing',
])
if (!multimodalToolNames.has(tool.name)) return tool.content
Expand Down