diff --git a/tools/music_audio/minimax_tts/__init__.py b/tools/music_audio/minimax_tts/__init__.py new file mode 100644 index 0000000000..fd8eeec16c --- /dev/null +++ b/tools/music_audio/minimax_tts/__init__.py @@ -0,0 +1,3 @@ +from .src.python.minimax_tts_tool import MiniMaxTTSTool + +__all__ = ["MiniMaxTTSTool"] diff --git a/tools/music_audio/minimax_tts/settings.sample.json b/tools/music_audio/minimax_tts/settings.sample.json new file mode 100644 index 0000000000..2ab8bc99bc --- /dev/null +++ b/tools/music_audio/minimax_tts/settings.sample.json @@ -0,0 +1,5 @@ +{ + "MINIMAX_TTS_API_KEY": null, + "MINIMAX_TTS_MODEL": "speech-2.8-hd", + "MINIMAX_TTS_REGION": "global_en" +} diff --git a/tools/music_audio/minimax_tts/src/nodejs/index.ts b/tools/music_audio/minimax_tts/src/nodejs/index.ts new file mode 100644 index 0000000000..73bfc0b079 --- /dev/null +++ b/tools/music_audio/minimax_tts/src/nodejs/index.ts @@ -0,0 +1,9 @@ +export { default } from './minimax_tts-tool' +export type { + AudioSetting, + OutputFormat, + SupportedAudioFormat, + SupportedModel, + SupportedRegion, + SynthesizeOptions +} from './minimax_tts-tool' diff --git a/tools/music_audio/minimax_tts/src/nodejs/minimax_tts-tool.ts b/tools/music_audio/minimax_tts/src/nodejs/minimax_tts-tool.ts new file mode 100644 index 0000000000..89407fcbb3 --- /dev/null +++ b/tools/music_audio/minimax_tts/src/nodejs/minimax_tts-tool.ts @@ -0,0 +1,304 @@ +import fs from 'node:fs' + +import { Tool } from '@sdk/base-tool' +import { ToolkitConfig } from '@sdk/toolkit-config' +import { Network } from '@sdk/network' + +// Hardcoded default settings for MiniMax TTS tool +const MINIMAX_TTS_API_KEY: string | null = null +const MINIMAX_TTS_MODEL = 'speech-2.8-hd' +const MINIMAX_TTS_REGION = 'global_en' +const DEFAULT_SETTINGS: Record = { + MINIMAX_TTS_API_KEY, + MINIMAX_TTS_MODEL, + MINIMAX_TTS_REGION +} +const REQUIRED_SETTINGS = ['MINIMAX_TTS_API_KEY'] + +/** + * Text-to-audio endpoint per region. The global endpoint is served from the + * international host, the Chinese one from the mainland host. + */ +const TEXT_TO_AUDIO_ENDPOINTS = { + global_en: 'https://api.minimax.io/v1/t2a_v2', + cn_zh: 'https://api.minimaxi.com/v1/t2a_v2' +} as const + +/** Speech models accepted by the text-to-audio endpoint. */ +const SUPPORTED_MODELS = [ + 'speech-2.8-hd', + 'speech-2.8-turbo', + 'speech-2.6-hd', + 'speech-2.6-turbo', + 'speech-02-hd', + 'speech-02-turbo', + 'speech-01-hd', + 'speech-01-turbo' +] as const + +/** Audio container formats the endpoint can encode. */ +const SUPPORTED_AUDIO_FORMATS = ['mp3', 'wav', 'flac', 'pcm'] as const + +/** The endpoint reports success with this status code. */ +const SUCCESS_STATUS_CODE = 0 + +export type SupportedRegion = keyof typeof TEXT_TO_AUDIO_ENDPOINTS +export type SupportedModel = (typeof SUPPORTED_MODELS)[number] +export type SupportedAudioFormat = (typeof SUPPORTED_AUDIO_FORMATS)[number] + +/** Encoding of the audio payload returned by the endpoint. */ +export type OutputFormat = 'hex' | 'url' + +export interface AudioSetting extends Record { + format?: SupportedAudioFormat +} + +export interface SynthesizeOptions { + /** Speech model to synthesize with. Defaults to the configured model. */ + model?: string + /** Regional endpoint to call. Defaults to the configured region. */ + region?: string + /** API key overriding the configured one. */ + apiKey?: string + /** Encoding of the returned audio. Defaults to hex. */ + outputFormat?: OutputFormat + /** Language or dialect to prioritize during synthesis. */ + languageBoost?: string + /** Whether the endpoint should also generate subtitles. */ + subtitleEnable?: boolean + /** Voice options. A voice_id is required by the endpoint. */ + voiceSetting?: Record + /** Output audio options such as format, sample rate and bitrate. */ + audioSetting?: AudioSetting + /** Pronunciation replacement rules applied to the text. */ + pronunciationDict?: Record + /** Voice modification options such as pitch, intensity and timbre. */ + voiceModify?: Record +} + +interface TextToAudioRequest extends Record { + model: string + text: string + stream: boolean + output_format: OutputFormat +} + +interface TextToAudioResponse { + data?: { + audio?: string + status?: number + } | null + base_resp?: { + status_code?: number + status_msg?: string + } | null +} + +export default class MiniMaxTTSTool extends Tool { + private static readonly TOOLKIT = 'music_audio' + private readonly config: ReturnType + readonly apiKey: string | null + readonly model: string + readonly region: string + + constructor() { + super() + this.config = ToolkitConfig.load(MiniMaxTTSTool.TOOLKIT, this.toolName) + + const toolSettings = ToolkitConfig.loadToolSettings( + MiniMaxTTSTool.TOOLKIT, + this.toolName, + DEFAULT_SETTINGS + ) + this.settings = toolSettings + this.requiredSettings = REQUIRED_SETTINGS + this.checkRequiredSettings(this.toolName) + + // Priority: toolkit settings > hardcoded default + this.apiKey = + (this.settings['MINIMAX_TTS_API_KEY'] as string) || MINIMAX_TTS_API_KEY + this.model = + (this.settings['MINIMAX_TTS_MODEL'] as string) || MINIMAX_TTS_MODEL + this.region = + (this.settings['MINIMAX_TTS_REGION'] as string) || MINIMAX_TTS_REGION + } + + get toolName(): string { + return 'minimax_tts' + } + + get toolkit(): string { + return MiniMaxTTSTool.TOOLKIT + } + + get description(): string { + return this.config['description'] + } + + /** + * Synthesize speech from text and save the generated audio to a file + * @param text Text to synthesize into speech + * @param outputPath Path of the audio file to write + * @param options Optional synthesis settings, defaulting to the tool settings + * @returns The path to the generated audio file + */ + async synthesizeToFile( + text: string, + outputPath: string, + options: SynthesizeOptions = {} + ): Promise { + if (!text) { + throw new Error('Text to synthesize is missing') + } + if (!outputPath) { + throw new Error('Output path is missing') + } + + const apiKey = options.apiKey || this.apiKey + if (!apiKey) { + throw new Error('MiniMax API key is missing') + } + + const model = options.model || this.model + if (!this.isSupportedModel(model)) { + throw new Error( + `Unsupported speech model "${model}". Supported models: ${SUPPORTED_MODELS.join( + ', ' + )}` + ) + } + + const region = options.region || this.region + if (!this.isSupportedRegion(region)) { + throw new Error( + `Unsupported region "${region}". Supported regions: ${Object.keys( + TEXT_TO_AUDIO_ENDPOINTS + ).join(', ')}` + ) + } + + const audioFormat = options.audioSetting?.format + if (audioFormat && !this.isSupportedAudioFormat(audioFormat)) { + throw new Error( + `Unsupported audio format "${audioFormat}". Supported formats: ${SUPPORTED_AUDIO_FORMATS.join( + ', ' + )}` + ) + } + + const outputFormat: OutputFormat = options.outputFormat || 'hex' + const endpoint = new URL(TEXT_TO_AUDIO_ENDPOINTS[region]) + const network = new Network({ baseURL: endpoint.origin }) + const response = await network.request({ + url: endpoint.pathname, + method: 'POST', + data: this.buildRequest(text, model, outputFormat, options), + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }) + + const statusCode = response.data.base_resp?.status_code + if (statusCode !== undefined && statusCode !== SUCCESS_STATUS_CODE) { + const statusMessage = + response.data.base_resp?.status_msg || 'Unknown error' + + throw new Error( + `MiniMax speech synthesis failed with status ${statusCode}: ${statusMessage}` + ) + } + + const audio = response.data.data?.audio + if (!audio) { + throw new Error('MiniMax speech synthesis returned no audio') + } + + const audioBuffer = + outputFormat === 'url' + ? await this.downloadAudio(audio) + : this.decodeAudio(audio) + + await fs.promises.writeFile(outputPath, audioBuffer) + + return outputPath + } + + /** Build the request body, omitting the options that were not provided. */ + private buildRequest( + text: string, + model: string, + outputFormat: OutputFormat, + options: SynthesizeOptions + ): TextToAudioRequest { + const request: TextToAudioRequest = { + model, + text, + // The whole audio is needed at once to write it to a file + stream: false, + output_format: outputFormat + } + + if (options.languageBoost) { + request['language_boost'] = options.languageBoost + } + if (options.subtitleEnable !== undefined) { + request['subtitle_enable'] = options.subtitleEnable + } + if (options.voiceSetting) { + request['voice_setting'] = options.voiceSetting + } + if (options.audioSetting) { + request['audio_setting'] = options.audioSetting + } + if (options.pronunciationDict) { + request['pronunciation_dict'] = options.pronunciationDict + } + if (options.voiceModify) { + request['voice_modify'] = options.voiceModify + } + + return request + } + + /** Decode the hexadecimal audio payload returned by the endpoint. */ + private decodeAudio(audio: string): Buffer { + if (audio.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(audio)) { + throw new Error('MiniMax speech synthesis returned a malformed audio') + } + + return Buffer.from(audio, 'hex') + } + + /** Download the audio when the endpoint returns a URL instead of bytes. */ + private async downloadAudio(audioURL: string): Promise { + const url = new URL(audioURL) + const network = new Network({ baseURL: url.origin }) + const response = await network.request({ + url: `${url.pathname}${url.search}`, + method: 'GET', + responseType: 'arraybuffer' + }) + + return Buffer.from(response.data as ArrayBuffer) + } + + private isSupportedModel(model: string): model is SupportedModel { + return SUPPORTED_MODELS.includes(model as SupportedModel) + } + + private isSupportedRegion(region: string): region is SupportedRegion { + return Object.prototype.hasOwnProperty.call( + TEXT_TO_AUDIO_ENDPOINTS, + region + ) + } + + private isSupportedAudioFormat( + audioFormat: string + ): audioFormat is SupportedAudioFormat { + return SUPPORTED_AUDIO_FORMATS.includes( + audioFormat as SupportedAudioFormat + ) + } +} diff --git a/tools/music_audio/minimax_tts/src/python/minimax_tts_tool.py b/tools/music_audio/minimax_tts/src/python/minimax_tts_tool.py new file mode 100644 index 0000000000..f80104a2ad --- /dev/null +++ b/tools/music_audio/minimax_tts/src/python/minimax_tts_tool.py @@ -0,0 +1,226 @@ +import binascii +import re +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from bridges.python.src.sdk.base_tool import BaseTool +from bridges.python.src.sdk.toolkit_config import ToolkitConfig +from bridges.python.src.sdk.network import Network + +# Hardcoded default settings for MiniMax TTS tool +MINIMAX_TTS_API_KEY = None +MINIMAX_TTS_MODEL = "speech-2.8-hd" +MINIMAX_TTS_REGION = "global_en" +DEFAULT_SETTINGS = { + "MINIMAX_TTS_API_KEY": MINIMAX_TTS_API_KEY, + "MINIMAX_TTS_MODEL": MINIMAX_TTS_MODEL, + "MINIMAX_TTS_REGION": MINIMAX_TTS_REGION, +} +REQUIRED_SETTINGS = ["MINIMAX_TTS_API_KEY"] + +# Text-to-audio endpoint per region. The global endpoint is served from the +# international host, the Chinese one from the mainland host. +TEXT_TO_AUDIO_ENDPOINTS = { + "global_en": "https://api.minimax.io/v1/t2a_v2", + "cn_zh": "https://api.minimaxi.com/v1/t2a_v2", +} + +# Speech models accepted by the text-to-audio endpoint +SUPPORTED_MODELS = [ + "speech-2.8-hd", + "speech-2.8-turbo", + "speech-2.6-hd", + "speech-2.6-turbo", + "speech-02-hd", + "speech-02-turbo", + "speech-01-hd", + "speech-01-turbo", +] + +# Audio container formats the endpoint can encode +SUPPORTED_AUDIO_FORMATS = ["mp3", "wav", "flac", "pcm"] + +# The endpoint reports success with this status code +SUCCESS_STATUS_CODE = 0 + +HEX_PATTERN = re.compile(r"^[0-9a-fA-F]+$") + + +class MiniMaxTTSTool(BaseTool): + TOOLKIT = "music_audio" + + def __init__(self): + super().__init__() + self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name) + + tool_settings = ToolkitConfig.load_tool_settings( + self.TOOLKIT, self.tool_name, DEFAULT_SETTINGS + ) + self.settings = tool_settings + self.required_settings = REQUIRED_SETTINGS + self._check_required_settings(self.tool_name) + + # Priority: toolkit settings > hardcoded default + self.api_key = self.settings.get("MINIMAX_TTS_API_KEY", MINIMAX_TTS_API_KEY) + self.model = self.settings.get("MINIMAX_TTS_MODEL", MINIMAX_TTS_MODEL) + self.region = self.settings.get("MINIMAX_TTS_REGION", MINIMAX_TTS_REGION) + + @property + def tool_name(self) -> str: + # Use the actual config name for toolkit lookup + return "minimax_tts" + + @property + def toolkit(self) -> str: + return self.TOOLKIT + + @property + def description(self) -> str: + return self.config["description"] + + def synthesize_to_file( + self, + text: str, + output_path: str, + options: Optional[Dict[str, Any]] = None, + ) -> str: + """ + Synthesize speech from text and save the generated audio to a file + + Args: + text: Text to synthesize into speech + output_path: Path of the audio file to write + options: Optional synthesis settings, defaulting to the tool settings + + Returns: + The path to the generated audio file + """ + options = options or {} + + if not text: + raise Exception("Text to synthesize is missing") + if not output_path: + raise Exception("Output path is missing") + + api_key = options.get("apiKey") or self.api_key + if not api_key: + raise Exception("MiniMax API key is missing") + + model = options.get("model") or self.model + if model not in SUPPORTED_MODELS: + raise Exception( + f"Unsupported speech model \"{model}\". " + f"Supported models: {', '.join(SUPPORTED_MODELS)}" + ) + + region = options.get("region") or self.region + if region not in TEXT_TO_AUDIO_ENDPOINTS: + raise Exception( + f"Unsupported region \"{region}\". " + f"Supported regions: {', '.join(TEXT_TO_AUDIO_ENDPOINTS)}" + ) + + audio_setting = options.get("audioSetting") + audio_format = audio_setting.get("format") if audio_setting else None + if audio_format and audio_format not in SUPPORTED_AUDIO_FORMATS: + raise Exception( + f"Unsupported audio format \"{audio_format}\". " + f"Supported formats: {', '.join(SUPPORTED_AUDIO_FORMATS)}" + ) + + output_format = options.get("outputFormat") or "hex" + endpoint = urlparse(TEXT_TO_AUDIO_ENDPOINTS[region]) + network = Network({"base_url": f"{endpoint.scheme}://{endpoint.netloc}"}) + response = network.request( + { + "url": endpoint.path, + "method": "POST", + "headers": { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + "data": self._build_request(text, model, output_format, options), + "use_json": True, + } + ) + + body = response["data"] or {} + base_resp = body.get("base_resp") or {} + status_code = base_resp.get("status_code") + if status_code is not None and status_code != SUCCESS_STATUS_CODE: + status_message = base_resp.get("status_msg") or "Unknown error" + + raise Exception( + f"MiniMax speech synthesis failed with status {status_code}: " + f"{status_message}" + ) + + audio = (body.get("data") or {}).get("audio") + if not audio: + raise Exception("MiniMax speech synthesis returned no audio") + + if output_format == "url": + audio_bytes = self._download_audio(audio) + else: + audio_bytes = self._decode_audio(audio) + + with open(output_path, "wb") as audio_file: + audio_file.write(audio_bytes) + + return output_path + + def _build_request( + self, + text: str, + model: str, + output_format: str, + options: Dict[str, Any], + ) -> Dict[str, Any]: + """Build the request body, omitting the options that were not provided.""" + request: Dict[str, Any] = { + "model": model, + "text": text, + # The whole audio is needed at once to write it to a file + "stream": False, + "output_format": output_format, + } + + if options.get("languageBoost"): + request["language_boost"] = options["languageBoost"] + if options.get("subtitleEnable") is not None: + request["subtitle_enable"] = options["subtitleEnable"] + if options.get("voiceSetting"): + request["voice_setting"] = options["voiceSetting"] + if options.get("audioSetting"): + request["audio_setting"] = options["audioSetting"] + if options.get("pronunciationDict"): + request["pronunciation_dict"] = options["pronunciationDict"] + if options.get("voiceModify"): + request["voice_modify"] = options["voiceModify"] + + return request + + def _decode_audio(self, audio: str) -> bytes: + """Decode the hexadecimal audio payload returned by the endpoint.""" + if len(audio) % 2 != 0 or not HEX_PATTERN.match(audio): + raise Exception("MiniMax speech synthesis returned a malformed audio") + + return binascii.unhexlify(audio) + + def _download_audio(self, audio_url: str) -> bytes: + """Download the audio when the endpoint returns a URL instead of bytes.""" + url = urlparse(audio_url) + network = Network({"base_url": f"{url.scheme}://{url.netloc}"}) + path = url.path + if url.query: + path = f"{path}?{url.query}" + + response = network.request( + { + "url": path, + "method": "GET", + "response_type": "bytes", + } + ) + + return response["data"] diff --git a/tools/music_audio/minimax_tts/tool.json b/tools/music_audio/minimax_tts/tool.json new file mode 100644 index 0000000000..35a255fe42 --- /dev/null +++ b/tools/music_audio/minimax_tts/tool.json @@ -0,0 +1,110 @@ +{ + "$schema": "../../../schemas/tool-schemas/tool.json", + "tool_id": "minimax_tts", + "toolkit_id": "music_audio", + "name": "MiniMax TTS", + "description": "A tool for text-to-speech synthesis using MiniMax speech models.", + "icon_name": "voiceprint-line", + "author": { + "name": "octo-patch", + "url": "https://github.com/octo-patch" + }, + "functions": { + "synthesizeToFile": { + "description": "Synthesize speech from text and save the generated audio to a file.", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to synthesize into speech." + }, + "outputPath": { + "type": "string", + "description": "Path of the audio file to write." + }, + "options": { + "type": "object", + "description": "Optional synthesis settings. Defaults come from the tool settings.", + "properties": { + "model": { + "type": "string", + "enum": [ + "speech-2.8-hd", + "speech-2.8-turbo", + "speech-2.6-hd", + "speech-2.6-turbo", + "speech-02-hd", + "speech-02-turbo", + "speech-01-hd", + "speech-01-turbo" + ], + "description": "Speech model to synthesize with." + }, + "region": { + "type": "string", + "enum": [ + "global_en", + "cn_zh" + ], + "description": "Regional endpoint to call." + }, + "outputFormat": { + "type": "string", + "enum": [ + "hex", + "url" + ], + "description": "Encoding of the audio returned by the API." + }, + "languageBoost": { + "type": "string", + "description": "Language or dialect to prioritize during synthesis." + }, + "subtitleEnable": { + "type": "boolean", + "description": "Whether the API should also generate subtitles." + }, + "voiceSetting": { + "type": "object", + "description": "Voice options. A voice_id is required by the API.", + "additionalProperties": true + }, + "audioSetting": { + "type": "object", + "description": "Output audio options such as format, sample rate and bitrate.", + "properties": { + "format": { + "type": "string", + "enum": [ + "mp3", + "wav", + "flac", + "pcm" + ] + } + }, + "additionalProperties": true + }, + "pronunciationDict": { + "type": "object", + "description": "Pronunciation replacement rules applied to the text.", + "additionalProperties": true + }, + "voiceModify": { + "type": "object", + "description": "Voice modification options such as pitch, intensity and timbre.", + "additionalProperties": true + } + }, + "additionalProperties": false + } + }, + "required": [ + "text", + "outputPath" + ] + } + } + } +} diff --git a/tools/music_audio/toolkit.json b/tools/music_audio/toolkit.json index 23595db0a0..af21f89128 100644 --- a/tools/music_audio/toolkit.json +++ b/tools/music_audio/toolkit.json @@ -9,6 +9,7 @@ "tools": [ "qwen3_asr", "qwen3_tts", + "minimax_tts", "ecapa", "chatterbox_onnx", "ultimate_vocal_remover_onnx"