|
|
@@ -1,332 +0,0 @@
|
|
|
-/**
|
|
|
- * MiniMax TTS Provider (异步长文本语音合成)
|
|
|
- * 文档: https://platform.minimaxi.com/docs/api-reference/speech-t2a-async-create
|
|
|
- *
|
|
|
- * API 限制:
|
|
|
- * - 异步长文本: 1,000,000 字符(业界最长)
|
|
|
- * - 同步接口: 10,000 字符(>3,000 推荐异步)
|
|
|
- *
|
|
|
- * 流程:
|
|
|
- * 1. POST /v1/t2a_async_v2 创建任务,获取 task_id / task_token / file_id
|
|
|
- * 2. GET /v1/query/t2a_async_query_v2 轮询任务状态 (响应字段: status)
|
|
|
- * 3. GET /v1/files/retrieve_content?file_id=xxx 下载音频
|
|
|
- *
|
|
|
- * 实测可用模型: speech-2.8-hd (speech-2.8-turbo 等需要付费计划)
|
|
|
- */
|
|
|
-
|
|
|
-import axios from 'axios';
|
|
|
-import fs from 'fs';
|
|
|
-import path from 'path';
|
|
|
-import { config } from '../../config';
|
|
|
-import { VoiceParams } from '../../types';
|
|
|
-import { withAiLog } from '../../services/ai-call-logger';
|
|
|
-import { ITtsProvider } from './provider.interface';
|
|
|
-
|
|
|
-const API_BASE = 'https://api.minimaxi.com';
|
|
|
-const POLL_INTERVAL_BASE = 3000; // 基础轮询间隔 3 秒
|
|
|
-
|
|
|
-/** 根据文本长度计算轮询间隔:越长等越久,减少无效查询 */
|
|
|
-function getPollInterval(textLength: number): number {
|
|
|
- // 每 200 字加 5 秒,下限 10s,上限 120s
|
|
|
- return Math.max(10000, Math.min(Math.floor(textLength / 200) * 5000, 120000));
|
|
|
-}
|
|
|
-
|
|
|
-/** 根据文本长度计算最大轮询超时:基础 2 分钟 + 每 1000 字 1 分钟,上限 30 分钟 */
|
|
|
-function getMaxPollTime(textLength: number): number {
|
|
|
- const base = 2 * 60 * 1000; // 2 分钟
|
|
|
- const perChar = (textLength / 1000) * 60 * 1000; // 每千字 1 分钟
|
|
|
- return Math.min(base + perChar, 30 * 60 * 1000);
|
|
|
-}
|
|
|
-
|
|
|
-// MiniMax 预置音色(实测可用)
|
|
|
-const MINI_MAX_VOICES: Record<string, string> = {
|
|
|
- cherry: 'audiobook_female_1',
|
|
|
- serena: 'audiobook_female_2',
|
|
|
- ethan: 'audiobook_male_1',
|
|
|
- chelsie: 'audiobook_female_3',
|
|
|
- momo: 'audiobook_female_4',
|
|
|
- vivian: 'audiobook_female_5',
|
|
|
- moon: 'audiobook_male_2',
|
|
|
- maia: 'audiobook_female_6',
|
|
|
- kai: 'audiobook_male_3',
|
|
|
- nofish: 'audiobook_male_4',
|
|
|
-};
|
|
|
-
|
|
|
-export function getMiniMaxVoice(voiceId: string): string {
|
|
|
- return MINI_MAX_VOICES[voiceId] || 'audiobook_female_1';
|
|
|
-}
|
|
|
-
|
|
|
-export class MiniMaxTtsProvider implements ITtsProvider {
|
|
|
- readonly name: string;
|
|
|
- readonly vendor: string;
|
|
|
- readonly mode = 'async' as const;
|
|
|
- maxTextLength = 0; // 由 models.json 配置,未配置时用默认 1000
|
|
|
- readonly concurrency = 3; // MiniMax 异步长文本 API 支持并发提交
|
|
|
- readonly modelId: string;
|
|
|
- readonly ttsApiPath: string;
|
|
|
-
|
|
|
- private apiKey: string;
|
|
|
-
|
|
|
- /**
|
|
|
- * @param vendorKey 供应商 key(如 'minimax', 'minimax-key2')
|
|
|
- * @param apiKey API Key
|
|
|
- * @param modelId TTS 模型 ID(来自 models.json,如 'speech-2.8-hd')
|
|
|
- * @param ttsApiPath TTS API 基础路径(来自 models.json vendor 的 ttsApiPath)
|
|
|
- */
|
|
|
- constructor(vendorKey: string = 'minimax', apiKey?: string, modelId?: string, ttsApiPath?: string, maxTextLength?: number) {
|
|
|
- this.vendor = vendorKey;
|
|
|
- this.name = `${vendorKey}-tts`;
|
|
|
- this.modelId = modelId || 'speech-2.8-hd';
|
|
|
- this.ttsApiPath = ttsApiPath || API_BASE;
|
|
|
- if (maxTextLength) this.maxTextLength = maxTextLength;
|
|
|
- if (apiKey) {
|
|
|
- this.apiKey = apiKey;
|
|
|
- } else {
|
|
|
- const vendorConfig = (config.models as any).vendors?.[vendorKey];
|
|
|
- this.apiKey = vendorConfig?.apiKey || '';
|
|
|
- }
|
|
|
- if (!this.apiKey) {
|
|
|
- throw new Error(`MiniMax TTS API Key 未配置 (vendor=${vendorKey})`);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 创建异步 TTS 任务
|
|
|
- */
|
|
|
- private async createTask(
|
|
|
- text: string,
|
|
|
- voiceId: string,
|
|
|
- params: VoiceParams,
|
|
|
- ): Promise<{ task_id: string; task_token: string; file_id: string }> {
|
|
|
- const voiceName = getMiniMaxVoice(voiceId);
|
|
|
-
|
|
|
- const body: any = {
|
|
|
- model: this.modelId,
|
|
|
- text,
|
|
|
- voice_setting: {
|
|
|
- voice_id: voiceName,
|
|
|
- speed: params.speed || 1,
|
|
|
- vol: (params.volume || 50) / 50, // 0-100 -> 0-2
|
|
|
- pitch: 1 + (params.pitch || 0) / 500, // -500~500 -> 0~2, 1 为默认
|
|
|
- },
|
|
|
- audio_setting: {
|
|
|
- audio_sample_rate: 32000,
|
|
|
- bitrate: 128000,
|
|
|
- format: 'mp3',
|
|
|
- channel: 1,
|
|
|
- },
|
|
|
- };
|
|
|
-
|
|
|
- console.log(`📤 [MiniMax TTS] 创建任务, model: ${this.modelId}, voice: ${voiceName}, text length: ${text.length}`);
|
|
|
-
|
|
|
- let response;
|
|
|
- try {
|
|
|
- response = await withAiLog(
|
|
|
- () => axios.post(`${this.ttsApiPath}/v1/t2a_async_v2`, body, {
|
|
|
- headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
|
|
|
- timeout: 30000,
|
|
|
- }),
|
|
|
- { callType: 'tts_create', provider: this.vendor, model: this.modelId, textLen: text.length }
|
|
|
- );
|
|
|
- } catch (err: any) {
|
|
|
- console.log(`📤 [MiniMax TTS] HTTP错误: status=${err.response?.status}, data=`, JSON.stringify(err.response?.data));
|
|
|
- throw new Error(`MiniMax 创建任务失败: ${err.response?.data?.base_resp?.status_msg || err.message}`);
|
|
|
- }
|
|
|
-
|
|
|
- const data = response.data;
|
|
|
- console.log(`📤 [MiniMax TTS] 响应:`, JSON.stringify(data));
|
|
|
- if (data.base_resp?.status_code !== 0) {
|
|
|
- throw new Error(`MiniMax 创建任务失败: ${data.base_resp?.status_msg || JSON.stringify(data)}`);
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- task_id: String(data.task_id),
|
|
|
- task_token: data.task_token,
|
|
|
- file_id: String(data.file_id),
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 查询任务状态
|
|
|
- * 注意: 响应字段是 status 不是 task_status
|
|
|
- */
|
|
|
- private async queryTask(task_id: string, task_token: string): Promise<{
|
|
|
- status: string;
|
|
|
- file_id?: string;
|
|
|
- status_msg?: string;
|
|
|
- }> {
|
|
|
- const response = await withAiLog(
|
|
|
- () => axios.get(`${this.ttsApiPath}/v1/query/t2a_async_query_v2`, {
|
|
|
- params: { task_id, task_token },
|
|
|
- headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
|
- timeout: 30000,
|
|
|
- }),
|
|
|
- { callType: 'tts_poll', provider: this.vendor, model: this.modelId }
|
|
|
- );
|
|
|
-
|
|
|
- const data = response.data;
|
|
|
- if (data.base_resp?.status_code !== 0) {
|
|
|
- throw new Error(`MiniMax 查询任务失败: ${data.base_resp?.status_msg || JSON.stringify(data)}`);
|
|
|
- }
|
|
|
-
|
|
|
- return {
|
|
|
- status: data.status,
|
|
|
- file_id: data.file_id ? String(data.file_id) : undefined,
|
|
|
- status_msg: data.status_msg,
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 轮询直到任务完成
|
|
|
- */
|
|
|
- private async pollUntilComplete(task_id: string, task_token: string, textLength: number): Promise<string> {
|
|
|
- const startTime = Date.now();
|
|
|
- const pollInterval = getPollInterval(textLength);
|
|
|
- const maxPollTime = getMaxPollTime(textLength);
|
|
|
-
|
|
|
- while (Date.now() - startTime < maxPollTime) {
|
|
|
- const result = await this.queryTask(task_id, task_token);
|
|
|
-
|
|
|
- if (result.status === 'Success') {
|
|
|
- if (!result.file_id) {
|
|
|
- throw new Error('MiniMax 任务完成但未返回 file_id');
|
|
|
- }
|
|
|
- return result.file_id;
|
|
|
- }
|
|
|
-
|
|
|
- if (result.status === 'Failed') {
|
|
|
- throw new Error(`MiniMax 任务失败: ${result.status_msg || '未知错误'}`);
|
|
|
- }
|
|
|
-
|
|
|
- // PENDING 或 Processing,继续轮询
|
|
|
- await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
|
- }
|
|
|
-
|
|
|
- throw new Error(`MiniMax 任务超时 (${Math.round(maxPollTime / 60000)}分钟)`);
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 通过 file_id 下载音频
|
|
|
- * MiniMax 返回的是 tar 格式,需要提取其中的 MP3
|
|
|
- */
|
|
|
- private async downloadAudio(fileId: string, outputPath: string): Promise<string> {
|
|
|
- console.log(`⬇️ [MiniMax TTS] 下载音频, file_id: ${fileId}`);
|
|
|
-
|
|
|
- const response = await withAiLog(
|
|
|
- () => axios.get(`${this.ttsApiPath}/v1/files/retrieve_content`, {
|
|
|
- params: { file_id: fileId },
|
|
|
- headers: {
|
|
|
- 'Authorization': `Bearer ${this.apiKey}`,
|
|
|
- 'Content-Type': 'application/json',
|
|
|
- },
|
|
|
- responseType: 'arraybuffer',
|
|
|
- timeout: 60000,
|
|
|
- }),
|
|
|
- { callType: 'tts_download', provider: this.vendor, model: this.modelId }
|
|
|
- );
|
|
|
-
|
|
|
- const dir = path.dirname(outputPath);
|
|
|
- if (!fs.existsSync(dir)) {
|
|
|
- fs.mkdirSync(dir, { recursive: true });
|
|
|
- }
|
|
|
-
|
|
|
- const finalPath = outputPath.endsWith('.mp3') ? outputPath : outputPath.replace(/\.[^.]+$/, '.mp3');
|
|
|
- const buffer = Buffer.from(response.data);
|
|
|
-
|
|
|
- // MiniMax 返回 tar 格式,解析并提取 MP3 文件
|
|
|
- const mp3Data = this.extractMp3FromTar(buffer);
|
|
|
- if (!mp3Data) {
|
|
|
- throw new Error('MiniMax 返回的 tar 中未找到 MP3 文件');
|
|
|
- }
|
|
|
-
|
|
|
- fs.writeFileSync(finalPath, mp3Data);
|
|
|
- console.log(`✅ [MiniMax TTS] 下载完成: ${finalPath} (${mp3Data.length} bytes)`);
|
|
|
-
|
|
|
- return finalPath;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 从 tar 中提取 MP3 文件
|
|
|
- * tar 格式:512 字节头 + 内容(对齐到 512 字节)+ 下一个条目 + 两个 512 零块结束
|
|
|
- */
|
|
|
- private extractMp3FromTar(buffer: Buffer): Buffer | null {
|
|
|
- let offset = 0;
|
|
|
-
|
|
|
- while (offset < buffer.length - 512) {
|
|
|
- // 读取 tar 头部的文件名(偏移 0,最多 100 字节)
|
|
|
- const nameSlice = buffer.slice(offset, offset + 100);
|
|
|
- const nameEnd = nameSlice.indexOf(0);
|
|
|
- const filename = nameEnd >= 0 ? nameSlice.slice(0, nameEnd).toString() : nameSlice.toString();
|
|
|
-
|
|
|
- if (!filename || filename.trim().length === 0) {
|
|
|
- // 零块 = tar 结束
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- // 读取文件大小(偏移 124,12 字节八进制)
|
|
|
- const sizeStr = buffer.slice(offset + 124, offset + 135).toString().trim();
|
|
|
- const fileSize = parseInt(sizeStr, 8);
|
|
|
-
|
|
|
- // 内容从 512 字节后开始
|
|
|
- const contentOffset = offset + 512;
|
|
|
-
|
|
|
- // 如果是 MP3 文件,返回内容
|
|
|
- if (filename.endsWith('.mp3')) {
|
|
|
- console.log(`📦 [MiniMax TTS] 从 tar 中提取 ${filename} (${fileSize} bytes)`);
|
|
|
- return buffer.slice(contentOffset, contentOffset + fileSize);
|
|
|
- }
|
|
|
-
|
|
|
- // 跳过当前条目:512 头 + 内容(向上对齐到 512)
|
|
|
- const paddedSize = Math.ceil(fileSize / 512) * 512;
|
|
|
- offset = contentOffset + paddedSize;
|
|
|
- }
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 语音合成(完整流程:创建任务 → 轮询 → 下载)
|
|
|
- */
|
|
|
- async synthesize(
|
|
|
- text: string,
|
|
|
- voiceId: string,
|
|
|
- params: VoiceParams,
|
|
|
- outputPath: string,
|
|
|
- retries: number = 3,
|
|
|
- _modelOverride?: string,
|
|
|
- ): Promise<string> {
|
|
|
- let lastError: Error | null = null;
|
|
|
-
|
|
|
- for (let attempt = 1; attempt <= retries; attempt++) {
|
|
|
- try {
|
|
|
- // 1. 创建任务
|
|
|
- const { task_id, task_token } = await this.createTask(text, voiceId, params);
|
|
|
- console.log(`🆔 [MiniMax TTS] task_id: ${task_id}`);
|
|
|
-
|
|
|
- // 2. 轮询完成,获取 file_id(根据文本长度调整间隔)
|
|
|
- const fileId = await this.pollUntilComplete(task_id, task_token, text.length);
|
|
|
-
|
|
|
- // 3. 下载音频
|
|
|
- return await this.downloadAudio(fileId, outputPath);
|
|
|
- } catch (error: any) {
|
|
|
- const errorDetails = error.response?.data || error.message;
|
|
|
- const isRateLimit = error.response?.status === 429;
|
|
|
- const isServerError = error.response?.status >= 500;
|
|
|
-
|
|
|
- console.error(`❌ [MiniMax TTS] 失败 (尝试 ${attempt}/${retries}):`, error.message);
|
|
|
-
|
|
|
- if ((isRateLimit || isServerError) && attempt < retries) {
|
|
|
- const waitTime = Math.pow(2, attempt) * 1000;
|
|
|
- console.warn(`⏳ 等待 ${waitTime}ms 后重试...`);
|
|
|
- await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
|
- lastError = new Error(`MiniMax TTS 临时错误: ${error.message}`);
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- lastError = new Error(`MiniMax TTS 调用失败: ${error.message}`);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- throw lastError || new Error('MiniMax TTS 调用失败');
|
|
|
- }
|
|
|
-}
|