|
|
@@ -156,9 +156,9 @@ function extensionFor(name, mime = '') {
|
|
|
class VoiceCloneService {
|
|
|
constructor({ config = {}, qiwei }) {
|
|
|
this.config = {
|
|
|
- endpoint: String(config.endpoint || 'https://www.yuntts.com/api/v1/indextts2_infer').trim(),
|
|
|
- apiKey: String(config.apiKey || '').trim(),
|
|
|
- model: String(config.model || 'IndexTeam/IndexTTS-2').trim(),
|
|
|
+ endpoint: String(config.endpoint || 'https://server.fmode.cn/api/voice/indextts2').trim(),
|
|
|
+ authToken: String(config.authToken || '').trim().replace(/^Bearer\s+/i, ''),
|
|
|
+ model: String(config.model || 'fmode-voice').trim(),
|
|
|
requestTimeoutMs: Math.max(15000, Math.min(300000, Number(config.requestTimeoutMs) || 180000)),
|
|
|
};
|
|
|
this.qiwei = qiwei;
|
|
|
@@ -190,9 +190,10 @@ class VoiceCloneService {
|
|
|
|
|
|
status() {
|
|
|
const metadata = this.readMetadata();
|
|
|
+ const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
|
|
|
return {
|
|
|
- configured: Boolean(this.config.apiKey && this.config.endpoint),
|
|
|
- provider: 'yuntts-indextts2',
|
|
|
+ configured: Boolean(authToken && this.config.endpoint),
|
|
|
+ provider: 'fmode-voice',
|
|
|
model: this.config.model,
|
|
|
enrolled: Boolean(metadata && fs.existsSync(this.profilePath())),
|
|
|
profile: metadata ? {
|
|
|
@@ -259,12 +260,14 @@ class VoiceCloneService {
|
|
|
}
|
|
|
|
|
|
requireReady() {
|
|
|
- if (!this.config.apiKey) throw new Error('缺少 QIWEI_TTS_API_KEY,请先配置云端语音服务');
|
|
|
+ const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim();
|
|
|
+ if (!authToken) throw new Error('缺少 Fmode 鉴权,请先在 Fmode Studio 中完成登录');
|
|
|
if (!fs.existsSync(this.profilePath())) throw new Error('尚未初始化本人声音,请先录制或上传参考音频');
|
|
|
+ return authToken;
|
|
|
}
|
|
|
|
|
|
async synthesize({ text, context = '', tone = 'auto' }) {
|
|
|
- this.requireReady();
|
|
|
+ const authToken = this.requireReady();
|
|
|
const input = String(text || '').trim();
|
|
|
if (!input) throw new Error('语音文本不能为空');
|
|
|
if (input.length > 600) throw new Error('语音文本不能超过 600 字');
|
|
|
@@ -290,13 +293,13 @@ class VoiceCloneService {
|
|
|
try {
|
|
|
response = await fetch(this.config.endpoint, {
|
|
|
method: 'POST',
|
|
|
- headers: { Authorization: `Bearer ${this.config.apiKey}` },
|
|
|
+ headers: { Authorization: `Bearer ${authToken}` },
|
|
|
body: form,
|
|
|
signal: controller.signal,
|
|
|
});
|
|
|
} catch (error) {
|
|
|
if (error.name === 'AbortError') throw new Error('声音合成超时,请稍后重试');
|
|
|
- throw new Error(`声音合成服务不可用:${error.message}`);
|
|
|
+ throw new Error(`Fmode 语音服务不可用:${error.message}`);
|
|
|
} finally {
|
|
|
clearTimeout(timeout);
|
|
|
}
|
|
|
@@ -305,7 +308,7 @@ class VoiceCloneService {
|
|
|
const raw = (await readResponseBuffer(response, MAX_ERROR_BODY_BYTES)).toString('utf8');
|
|
|
let parsed;
|
|
|
try { parsed = JSON.parse(raw); } catch { parsed = {}; }
|
|
|
- throw new Error(parsed.message || parsed.error || `声音合成失败(HTTP ${response.status})`);
|
|
|
+ throw new Error(parsed.mess || parsed.message || parsed.error || `声音合成失败(HTTP ${response.status})`);
|
|
|
}
|
|
|
const runDir = createRunDir('voice', `clone-${crypto.randomBytes(4).toString('hex')}`);
|
|
|
const wavPath = path.join(runDir, 'speech.wav');
|
|
|
@@ -314,7 +317,7 @@ class VoiceCloneService {
|
|
|
if (!wavInfo.duration || wavInfo.duration > 180) throw new Error('声音合成结果无效');
|
|
|
const manifestPath = writeRunManifest(runDir, {
|
|
|
type: 'voice-clone',
|
|
|
- provider: 'yuntts-indextts2',
|
|
|
+ provider: 'fmode-voice',
|
|
|
model: this.config.model,
|
|
|
textLength: input.length,
|
|
|
textSha256: crypto.createHash('sha256').update(input).digest('hex'),
|
|
|
@@ -376,7 +379,7 @@ class VoiceCloneService {
|
|
|
if (outcome !== 'sent') removeFile(result.wavPath);
|
|
|
writeRunManifest(result.runDir, {
|
|
|
type: 'voice-clone',
|
|
|
- provider: 'yuntts-indextts2',
|
|
|
+ provider: 'fmode-voice',
|
|
|
model: this.config.model,
|
|
|
textLength: result.text.length,
|
|
|
textSha256: crypto.createHash('sha256').update(result.text).digest('hex'),
|