Skip to content

Commit 59f1faa

Browse files
eded
authored andcommitted
feat(tts): support minimax t2a
1 parent 62a1df9 commit 59f1faa

File tree

4 files changed

+492
-23
lines changed

4 files changed

+492
-23
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.xiaozhi.dialogue.tts.providers;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
5+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
6+
import com.xiaozhi.dialogue.tts.TtsService;
7+
import com.xiaozhi.entity.SysConfig;
8+
import com.xiaozhi.utils.HttpUtil;
9+
import com.xiaozhi.utils.JsonUtil;
10+
import lombok.Data;
11+
import lombok.experimental.Accessors;
12+
import lombok.extern.slf4j.Slf4j;
13+
import okhttp3.MediaType;
14+
import okhttp3.OkHttpClient;
15+
import okhttp3.Request;
16+
import okhttp3.RequestBody;
17+
18+
import java.io.IOException;
19+
import java.nio.file.Files;
20+
import java.nio.file.Paths;
21+
import java.util.HexFormat;
22+
23+
@Slf4j
24+
public class MiniMaxTtsService implements TtsService {
25+
26+
private static final String PROVIDER_NAME = "minimax";
27+
28+
private final String groupId;
29+
private final String apiKey;
30+
31+
private final String outputPath;
32+
private final String voiceName;
33+
34+
private final OkHttpClient client = HttpUtil.client;
35+
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
36+
37+
public MiniMaxTtsService(SysConfig config, String voiceName, String outputPath) {
38+
this.groupId = config.getAppId();
39+
this.apiKey = config.getApiKey();
40+
this.voiceName = voiceName;
41+
this.outputPath = outputPath;
42+
}
43+
44+
@Override
45+
public String getProviderName() {
46+
return PROVIDER_NAME;
47+
}
48+
49+
@Override
50+
public String audioFormat() {
51+
return "mp3";
52+
}
53+
54+
@Override
55+
public String textToSpeech(String text) throws Exception {
56+
var output = Paths.get(outputPath, getAudioFileName()).toString();
57+
sendRequest(text, output);
58+
return output;
59+
}
60+
61+
private void sendRequest(String text, String filepath) {
62+
var params = new Text2AudioParams(voiceName, text);
63+
var request = new Request.Builder()
64+
.url("https://api.minimaxi.com/v1/t2a_v2?Groupid=%s".formatted(groupId))
65+
.addHeader("Content-Type", "application/json")
66+
.addHeader("Authorization", "Bearer %s".formatted(apiKey)) // 添加Authorization头
67+
.post(RequestBody.create(JsonUtil.toJson(params), JSON))
68+
.build();
69+
70+
try (var resp = client.newCall(request).execute()) {
71+
if (resp.isSuccessful()) {
72+
var respBody = JsonUtil.fromJson(resp.body().string(), Text2AudioResp.class);
73+
if (respBody.baseResp.statusCode == 0) {
74+
var bytes = HexFormat.of().parseHex(respBody.data.audio);
75+
Files.write(Paths.get(filepath), bytes);
76+
} else {
77+
log.error("TTS失败 {}:{}", respBody.baseResp.statusCode, respBody.baseResp.statusMsg);
78+
}
79+
} else {
80+
log.error("TTS请求失败 {}", resp.body().string());
81+
}
82+
} catch (IOException e) {
83+
log.error("发送TTS请求时发生错误", e);
84+
throw new RuntimeException("发送TTS请求失败", e);
85+
}
86+
}
87+
88+
@Data
89+
@Accessors(chain = true)
90+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
91+
public static class Text2AudioParams {
92+
93+
public Text2AudioParams(String voiceId, String text) {
94+
this("speech-02-hd", voiceId, text);
95+
}
96+
97+
public Text2AudioParams(String model, String voiceId, String text) {
98+
this.model = model;
99+
this.text = text;
100+
this.audioSetting = new AudioSetting();
101+
this.voiceSetting = new VoiceSetting().setVoiceId(voiceId);
102+
}
103+
104+
private String model;
105+
private String text;
106+
private boolean stream = false;
107+
private String languageBoost = "auto";
108+
private String outputFormat = "hex";
109+
private VoiceSetting voiceSetting;
110+
private AudioSetting audioSetting;
111+
112+
@Data
113+
@Accessors(chain = true)
114+
public static class VoiceSetting {
115+
@JsonProperty("voice_id")
116+
private String voiceId;
117+
private double speed = 1;
118+
private double vol = 1;
119+
private int pitch = 0;
120+
private String emotion = "happy";
121+
}
122+
123+
@Data
124+
public static class AudioSetting {
125+
@JsonProperty("sample_rate")
126+
private int sampleRate = 32000;
127+
private int bitrate = 128000;
128+
private String format = "mp3";
129+
}
130+
}
131+
132+
@Data
133+
public static class Text2AudioResp {
134+
private Data data;
135+
@JsonProperty("base_resp")
136+
private BaseResp baseResp;
137+
138+
record Data(int status, String audio) {
139+
}
140+
141+
record BaseResp(@JsonProperty("status_code") int statusCode, @JsonProperty("status_msg") String statusMsg) {
142+
}
143+
}
144+
145+
}

web/src/config/providerConfig.js

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ export const configTypeMap = {
1313
{ label: 'Ollama', value: 'ollama', key: '1' },
1414
{ label: 'Spark', value: 'spark', key: '2' },
1515
{ label: 'Zhipu', value: 'zhipu', key: '3' },
16-
{ label: 'AliYun', value: 'aliyun', key: '4'},
17-
{ label: 'Doubao', value: 'doubao', key: '5'},
18-
{ label: 'DeepSeek', value: 'deepseek', key: '6'},
19-
{ label: 'ChatGLM', value: 'chatglm', key: '7'},
20-
{ label: 'Gemini', value: 'gemini', key: '8'},
21-
{ label: 'LMStudio', value: 'lmstudio', key: '9'},
22-
{ label: 'Fastgpt', value: 'fastgpt', key: '10'},
23-
{ label: 'Xinference', value: 'xinference', key: '11'},
16+
{ label: 'AliYun', value: 'aliyun', key: '4' },
17+
{ label: 'Doubao', value: 'doubao', key: '5' },
18+
{ label: 'DeepSeek', value: 'deepseek', key: '6' },
19+
{ label: 'ChatGLM', value: 'chatglm', key: '7' },
20+
{ label: 'Gemini', value: 'gemini', key: '8' },
21+
{ label: 'LMStudio', value: 'lmstudio', key: '9' },
22+
{ label: 'Fastgpt', value: 'fastgpt', key: '10' },
23+
{ label: 'Xinference', value: 'xinference', key: '11' },
2424
],
2525
// 各类别对应的参数字段定义
2626
typeFields: {
@@ -29,46 +29,46 @@ export const configTypeMap = {
2929
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions' },
3030
],
3131
ollama: [
32-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/api/chat', defaultUrl:"http://localhost:11434" }
32+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/api/chat', defaultUrl: "http://localhost:11434" }
3333
],
3434
spark: [
3535
{ name: 'apiKey', label: 'API Key', required: true, span: 8 },
36-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://spark-api-open.xf-yun.com/v1" }
36+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://spark-api-open.xf-yun.com/v1" }
3737
],
3838
zhipu: [
3939
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
40-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://open.bigmodel.cn/api/paas" }
40+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://open.bigmodel.cn/api/paas" }
4141
],
4242
aliyun: [
4343
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
44-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://dashscope.aliyuncs.com/compatible-mode/v1" }
44+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" }
4545
],
4646
doubao: [
4747
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
48-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://ark.cn-beijing.volces.com/api/v3" }
48+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://ark.cn-beijing.volces.com/api/v3" }
4949
],
5050
deepseek: [
5151
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
52-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://api.deepseek.com" }
52+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://api.deepseek.com" }
5353
],
5454
chatglm: [
5555
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
56-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://open.bigmodel.cn/api/paas/v4/" }
56+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://open.bigmodel.cn/api/paas/v4/" }
5757
],
5858
gemini: [
5959
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
60-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"https://generativelanguage.googleapis.com/v1beta/" }
60+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://generativelanguage.googleapis.com/v1beta/" }
6161
],
6262
lmstudio: [
63-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"http://localhost:1234/v1" }
63+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "http://localhost:1234/v1" }
6464
],
6565
fastgpt: [
6666
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
67-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"http://localhost:3000/api/v1" }
67+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "http://localhost:3000/api/v1" }
6868
],
6969
xinference: [
7070
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
71-
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl:"http://localhost:9997/v1" }
71+
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "http://localhost:9997/v1" }
7272
]
7373
}
7474
},
@@ -77,7 +77,7 @@ export const configTypeMap = {
7777
typeOptions: [
7878
{ label: 'Tencent', value: 'tencent', key: '0' },
7979
{ label: 'Aliyun', value: 'aliyun', key: '1' },
80-
{ label: 'Xfyun', value: 'xfyun', key: '2'},
80+
{ label: 'Xfyun', value: 'xfyun', key: '2' },
8181
{ label: 'FunASR', value: 'funasr', key: '3' }
8282
],
8383
typeFields: {
@@ -95,7 +95,7 @@ export const configTypeMap = {
9595
{ name: 'apiKey', label: 'Api Key', required: true, span: 12 }
9696
],
9797
funasr: [
98-
{ name: 'apiUrl', label: 'Websocket URL', required: true, span: 12, defaultUrl:"ws://127.0.0.1:10095" }
98+
{ name: 'apiUrl', label: 'Websocket URL', required: true, span: 12, defaultUrl: "ws://127.0.0.1:10095" }
9999
]
100100
}
101101
},
@@ -104,7 +104,8 @@ export const configTypeMap = {
104104
typeOptions: [
105105
{ label: 'Aliyun', value: 'aliyun', key: '0' },
106106
{ label: 'Volcengine(doubao)', value: 'volcengine', key: '1' },
107-
{ label: 'Xfyun', value: 'xfyun', key: '2' }
107+
{ label: 'Xfyun', value: 'xfyun', key: '2' },
108+
{ label: 'Minimax', value: 'minimax', key: '3' }
108109
],
109110
typeFields: {
110111
aliyun: [
@@ -120,7 +121,11 @@ export const configTypeMap = {
120121
{ name: 'appId', label: 'App Id', required: true, span: 12 },
121122
{ name: 'apiSecret', label: 'Api Secret', required: true, span: 12 },
122123
{ name: 'apiKey', label: 'Api Key', required: true, span: 12 }
123-
]
124+
],
125+
minimax: [
126+
{ name: 'appId', label: 'Group Id', required: true, span: 12 },
127+
{ name: 'apiKey', label: 'API Key', required: true, span: 12 }
128+
],
124129
}
125130
}
126131
};

web/src/views/page/Role.vue

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
<a-select-option value="aliyun">阿里云</a-select-option>
8787
<a-select-option value="volcengine">火山引擎(豆包)</a-select-option>
8888
<a-select-option value="xfyun">讯飞云</a-select-option>
89+
<a-select-option value="minimax">Minimax</a-select-option>
8990
</a-select>
9091
</a-form-item>
9192
</a-col>
@@ -238,6 +239,7 @@ export default {
238239
aliyunVoices: [], // 阿里云语音列表
239240
volcengineVoices: [], // 火山引擎语音列表
240241
xfyunVoices: [], // 讯飞语音列表
242+
minimaxVoices: [], // Minimax语音列表
241243
voiceLoading: false, // 语音列表加载状态
242244
selectedProvider: 'edge', // 默认使用Edge语音
243245
selectedGender: '', // 存储当前选择的性别
@@ -307,6 +309,7 @@ export default {
307309
this.loadAliyunVoices();
308310
this.loadVolcengineVoices();
309311
this.loadXfyunVoices();
312+
this.loadMinimaxVoices();
310313
this.getData();
311314
312315
// 初始化设置Edge默认TTS配置
@@ -325,6 +328,8 @@ export default {
325328
return this.volcengineVoices;
326329
} else if (this.selectedProvider === 'xfyun') {
327330
return this.xfyunVoices;
331+
} else if (this.selectedProvider === 'minimax') {
332+
return this.minimaxVoices;
328333
}
329334
return [];
330335
},
@@ -745,6 +750,42 @@ export default {
745750
this.voiceLoading = false;
746751
});
747752
},
753+
// 加载讯飞云语音列表 - 从本地文件加载
754+
loadMinimaxVoices() {
755+
this.voiceLoading = true;
756+
757+
// 直接从本地文件加载火山引擎语音列表
758+
fetch('/static/assets/minimaxVoicesList.json')
759+
.then(response => {
760+
if (!response.ok) {
761+
throw new Error('加载Minimax语音列表失败');
762+
}
763+
return response.json();
764+
})
765+
.then(voices => {
766+
// 保存语音列表
767+
this.minimaxVoices = voices;
768+
769+
// 加载完语音列表后,设置默认语音
770+
this.$nextTick(() => {
771+
if (
772+
this.selectedProvider === "minimax" &&
773+
this.minimaxVoices.length > 0 &&
774+
this.activeTabKey === "1"
775+
) {
776+
this.roleForm.setFieldsValue({
777+
voiceName: this.defaultVoiceName,
778+
});
779+
}
780+
});
781+
})
782+
.catch(error => {
783+
this.$message.error('加载Minimax语音列表失败,请确认文件是否存在');
784+
})
785+
.finally(() => {
786+
this.voiceLoading = false;
787+
});
788+
},
748789
// 提交表单
749790
handleSubmit(e) {
750791
e.preventDefault();

0 commit comments

Comments
 (0)