'use strict'; const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); const Ffmpeg = require('@ffmpeg-installer/ffmpeg'); const Ffprobe = require('@ffprobe-installer/ffprobe'); const WxVoiceModule = require('@binsee/wx-voice'); const WxVoice = WxVoiceModule.WxVoice || WxVoiceModule.default || WxVoiceModule; const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths'); const TONE_PRESETS = Object.freeze({ natural: Object.freeze({ id: 'natural', label: '自然', method: 0, alpha: null, text: '' }), friendly: Object.freeze({ id: 'friendly', label: '友好', method: 3, alpha: 0.4, text: '自然、友好、轻松,略带愉快和肯定,表达清晰' }), apology: Object.freeze({ id: 'apology', label: '真诚致歉', method: 3, alpha: 0.5, text: '真诚、耐心、克制,带有适度歉意,语速稍慢,不夸张' }), empathetic: Object.freeze({ id: 'empathetic', label: '温和关怀', method: 3, alpha: 0.45, text: '温和、关怀、耐心,语气柔和沉稳,不过度悲伤' }), reminder: Object.freeze({ id: 'reminder', label: '明确提醒', method: 3, alpha: 0.4, text: '明确、专注、专业,稍微强调时间但不焦虑、不催促' }), }); const TONE_RULES = Object.freeze([ ['apology', /抱歉|对不起|歉意|给您带来|不好的体验|未能及时|处理不周|投诉|不满意/], ['empathetic', /遗憾|难过|理解您的心情|辛苦了|保重|安慰|担心|不容易|身体不适/], ['reminder', /请于|截止|到期|超时|尽快|尽早|抓紧|马上|立即|提醒您|别忘了|时间安排/], ['friendly', /恭喜|好消息|审核通过|申请成功|已经成功|感谢|欢迎|很高兴|祝您|顺利完成/], ]); const MAX_SYNTH_AUDIO_BYTES = 50 * 1024 * 1024; const MAX_ERROR_BODY_BYTES = 1024 * 1024; function removeFile(filePath) { if (!filePath) return; try { fs.unlinkSync(filePath); } catch (error) { if (error?.code !== 'ENOENT') throw error; } } function removePath(targetPath) { let stat; try { stat = fs.lstatSync(targetPath); } catch (error) { if (error?.code === 'ENOENT') return; throw error; } if (stat.isDirectory() && !stat.isSymbolicLink()) { for (const entry of fs.readdirSync(targetPath)) { removePath(path.join(targetPath, entry)); } fs.rmdirSync(targetPath); return; } removeFile(targetPath); } async function readResponseBuffer(response, maxBytes) { const declared = Number(response.headers.get('content-length')) || 0; if (declared > maxBytes) throw new Error('声音合成响应过大'); if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks = []; let size = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBytes) { await reader.cancel(); throw new Error('声音合成响应过大'); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); } return Buffer.concat(chunks, size); } function isExplicitSendSuccess(result) { return Boolean(result && [true, 1, '1'].includes(result.isSendSuccess)); } function inferVoiceTone(text, context = '') { const primary = String(text || '').trim(); const combined = `${primary}\n${String(context || '').trim()}`; const match = TONE_RULES.find(([, pattern]) => pattern.test(combined)); const preset = TONE_PRESETS[match ? match[0] : 'natural']; return { ...preset, automatic: true, reason: match ? `命中文本场景:${preset.label}` : '未识别到特殊情绪,使用自然语气', }; } function resolveTone(input, text, context) { const requested = String(input || 'auto').trim().toLowerCase(); if (!requested || requested === 'auto') return inferVoiceTone(text, context); const preset = TONE_PRESETS[requested]; if (!preset) throw new Error('不支持的语音语气'); return { ...preset, automatic: false, reason: `人工选择:${preset.label}` }; } function runBinary(executable, args) { return new Promise((resolve, reject) => { const child = spawn(executable, args, { windowsHide: true }); let stdout = ''; let stderr = ''; child.stdout.on('data', chunk => { stdout += chunk; }); child.stderr.on('data', chunk => { stderr += chunk; }); child.on('error', reject); child.on('close', code => { if (code === 0) resolve({ stdout, stderr }); else reject(new Error(`音频处理失败(code ${code}):${stderr.slice(-500)}`)); }); }); } async function probeAudio(filePath) { const result = await runBinary(Ffprobe.path, [ '-v', 'error', '-show_entries', 'format=duration,size,format_name', '-show_entries', 'stream=codec_name,sample_rate,channels', '-of', 'json', filePath, ]); const parsed = JSON.parse(result.stdout || '{}'); const stream = (parsed.streams || [])[0] || {}; return { duration: Number(parsed.format?.duration) || 0, size: Number(parsed.format?.size) || fs.statSync(filePath).size, format: parsed.format?.format_name || '', codec: stream.codec_name || '', sampleRate: Number(stream.sample_rate) || 0, channels: Number(stream.channels) || 0, }; } function safeAccountKey(qiwei) { const context = qiwei?.context?.() || {}; const source = String(context.uid || context.guid || 'default'); return crypto.createHash('sha256').update(source).digest('hex').slice(0, 16); } function extensionFor(name, mime = '') { const ext = path.extname(String(name || '')).toLowerCase(); if (['.wav', '.mp3', '.m4a', '.webm', '.ogg', '.aac'].includes(ext)) return ext; if (/wav/i.test(mime)) return '.wav'; if (/mpeg|mp3/i.test(mime)) return '.mp3'; if (/mp4|m4a/i.test(mime)) return '.m4a'; if (/webm/i.test(mime)) return '.webm'; if (/ogg/i.test(mime)) return '.ogg'; return '.audio'; } class VoiceCloneService { constructor({ config = {}, qiwei }) { this.config = { 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; } accountKey() { return safeAccountKey(this.qiwei); } profileDir() { return path.join(categoryDir('voice'), 'profiles', this.accountKey()); } profilePath() { return path.join(this.profileDir(), 'reference.wav'); } metadataPath() { return path.join(this.profileDir(), 'profile.json'); } readMetadata() { try { return JSON.parse(fs.readFileSync(this.metadataPath(), 'utf8')); } catch { return null; } } status() { const metadata = this.readMetadata(); const authToken = this.config.authToken || String(this.qiwei?.context?.()?.token || '').trim(); return { configured: Boolean(authToken && this.config.endpoint), provider: 'fmode-voice', model: this.config.model, enrolled: Boolean(metadata && fs.existsSync(this.profilePath())), profile: metadata ? { createdAt: metadata.createdAt, duration: metadata.duration, sampleRate: metadata.sampleRate, originalName: metadata.originalName, } : null, tones: Object.values(TONE_PRESETS).map(item => ({ id: item.id, label: item.label })), automaticTone: true, previewEnabled: false, uploadMode: 'doFileApi', sendConfigured: Boolean(this.qiwei?.isConfigured?.()), }; } async enroll({ filePath, originalName = '', mime = '' }) { if (!filePath || !fs.existsSync(filePath)) throw new Error('缺少声音参考文件'); const sourceSize = fs.statSync(filePath).size; if (sourceSize > 20 * 1024 * 1024) throw new Error('声音参考文件不能超过 20MB'); const dir = this.profileDir(); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const target = this.profilePath(); const staging = path.join(dir, `reference-${crypto.randomUUID()}.wav`); try { await runBinary(Ffmpeg.path, ['-y', '-i', filePath, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', staging]); const info = await probeAudio(staging); if (info.duration < 5 || info.duration > 30) throw new Error('参考录音时长需在 5~30 秒之间'); const backup = `${target}.bak`; removeFile(backup); if (fs.existsSync(target)) fs.renameSync(target, backup); try { fs.renameSync(staging, target); removeFile(backup); } catch (error) { if (!fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target); throw error; } try { fs.chmodSync(target, 0o600); } catch {} const metadata = { version: 1, createdAt: new Date().toISOString(), originalName: String(originalName || `reference${extensionFor(originalName, mime)}`).slice(0, 160), mime: String(mime || '').slice(0, 100), duration: Number(info.duration.toFixed(3)), sampleRate: info.sampleRate, channels: info.channels, size: info.size, }; fs.writeFileSync(this.metadataPath(), JSON.stringify(metadata, null, 2), 'utf8'); try { fs.chmodSync(this.metadataPath(), 0o600); } catch {} return { status: 'ok', profile: this.status().profile }; } finally { removeFile(staging); } } revoke() { const dir = path.resolve(this.profileDir()); const root = path.resolve(path.join(categoryDir('voice'), 'profiles')); if (!dir.startsWith(`${root}${path.sep}`)) throw new Error('声音档案路径无效'); removePath(dir); return { status: 'ok', revoked: true }; } requireReady() { 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' }) { const authToken = this.requireReady(); const input = String(text || '').trim(); if (!input) throw new Error('语音文本不能为空'); if (input.length > 600) throw new Error('语音文本不能超过 600 字'); const selectedTone = resolveTone(tone, input, context); const payload = { input, emo_control_method: selectedTone.method, use_random: false, }; if (selectedTone.method === 3) { payload.emo_alpha = selectedTone.alpha; payload.emo_text = selectedTone.text; } const form = new FormData(); const reference = fs.readFileSync(this.profilePath()); form.append('spk_audio_file', new Blob([reference], { type: 'audio/wav' }), 'reference.wav'); form.append('payload', JSON.stringify(payload)); form.append('model', this.config.model); form.append('stream_mode', 'true'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); let response; try { response = await fetch(this.config.endpoint, { method: 'POST', headers: { Authorization: `Bearer ${authToken}` }, body: form, signal: controller.signal, }); } catch (error) { if (error.name === 'AbortError') throw new Error('声音合成超时,请稍后重试'); throw new Error(`Fmode 语音服务不可用:${error.message}`); } finally { clearTimeout(timeout); } const contentType = String(response.headers.get('content-type') || '').toLowerCase(); if (!response.ok || contentType.includes('json')) { const raw = (await readResponseBuffer(response, MAX_ERROR_BODY_BYTES)).toString('utf8'); let parsed; try { parsed = JSON.parse(raw); } catch { parsed = {}; } 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'); fs.writeFileSync(wavPath, await readResponseBuffer(response, MAX_SYNTH_AUDIO_BYTES), { mode: 0o600 }); const wavInfo = await probeAudio(wavPath); if (!wavInfo.duration || wavInfo.duration > 180) throw new Error('声音合成结果无效'); const manifestPath = writeRunManifest(runDir, { type: 'voice-clone', provider: 'fmode-voice', model: this.config.model, textLength: input.length, textSha256: crypto.createHash('sha256').update(input).digest('hex'), tone: selectedTone, audio: wavInfo, files: ['speech.wav'], }); return { text: input, tone: selectedTone, wavPath, wavInfo, runDir, manifestPath }; } async encodeSilk(wavPath) { const silkPath = path.join( os.tmpdir(), `qiwei-voice-${process.pid}-${crypto.randomBytes(8).toString('hex')}.silk` ); const voice = new WxVoice(); voice.on('error', () => {}); try { await voice.encode(wavPath, silkPath, { format: 'silk', frequency: 24000, channels: 1 }); const duration = await voice.duration(silkPath); if (!duration) throw new Error('SILK 编码结果无效'); return { silkPath, duration, size: fs.statSync(silkPath).size }; } catch (error) { removeFile(silkPath); throw error; } } async synthesizeAndSend({ text, context = '', tone = 'auto', toId, confirmed = false }) { if (confirmed !== true) throw new Error('发送克隆语音前必须获得人工确认'); if (!this.qiwei?.isConfigured?.()) throw new Error('企微语音媒体上传尚未配置,请先完成 Fmode 鉴权和企微登录'); if (!String(toId || '').trim()) throw new Error('企微语音接收人不能为空'); const result = await this.synthesize({ text, context, tone }); let silk; let outcome = 'failed'; try { silk = await this.encodeSilk(result.wavPath); const uploaded = await this.qiwei.uploadVoiceFile(silk.silkPath); if (!uploaded?.fileId || !uploaded?.fileAesKey) throw new Error('企微语音媒体上传未返回有效文件信息'); const sent = await this.qiwei.sendVoice(toId, { fileAesKey: uploaded.fileAesKey, fileId: uploaded.fileId, fileSize: uploaded.fileSize || silk.size, voiceTime: Math.max(1, Math.round(silk.duration)), }); if (!isExplicitSendSuccess(sent)) throw new Error('企微未明确确认语音发送成功'); outcome = 'sent'; return { text: result.text, tone: result.tone, duration: silk.duration, fileSize: silk.size, sendResult: sent, runDir: result.runDir, audioPath: result.wavPath, }; } finally { removeFile(silk?.silkPath); if (outcome !== 'sent') removeFile(result.wavPath); writeRunManifest(result.runDir, { type: 'voice-clone', provider: 'fmode-voice', model: this.config.model, textLength: result.text.length, textSha256: crypto.createHash('sha256').update(result.text).digest('hex'), tone: result.tone, outcome, files: outcome === 'sent' ? ['speech.wav'] : [], }); } } } module.exports = { VoiceCloneService, TONE_PRESETS, inferVoiceTone, resolveTone, probeAudio, isExplicitSendSuccess, readResponseBuffer, };