| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402 |
- '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,
- };
|