const express = require('express'); const cors = require('cors'); const multer = require('multer'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const OpenAI = require('openai'); const { subtle } = crypto.webcrypto; const app = express(); const port = 3000; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); app.use(cors()); app.use(express.json()); // ========================================== // 1. 原版 Web 页面 (完整 HTML) // ========================================== const HTML_PAGE = ` VoiceCraft - AI-Powered Voice Processing Platform
🌐 English
🇺🇸 English
🇨🇳 中文
🇯🇵 日本語
🇰🇷 한국어
🇪🇸 Español
🇫🇷 Français
🇩🇪 Deutsch
🇷🇺 Русский

VoiceCraft

AI-Powered Voice Processing Platform

✨ 20+ Voice Options
⚡ Lightning Fast
🆓 Completely Free
📱 Download Support

拖拽txt文件到此处,或点击选择文件

支持txt格式,最大500KB

正在生成语音,请稍候...

📥 下载音频文件

拖拽音频文件到此处,或点击选择文件

支持mp3、wav、m4a、flac、aac、ogg、webm、amr、3gp格式,最大10MB

正在转录音频,请稍候...

`; // ========================================== // 2. 全新:AI 智能 5角色 专属控制台 UI // ========================================== const LEGADO_UI = ` AI 智能 5 角色听书后台

🎙️ AI 智能 5角色听书后台

返回主配音界面 →

1. 大模型配置 (用于分析文本角色)

2. 微软稳定版发音人 (5角色)

参考音色: zh-CN-YunxiNeural(云希), zh-CN-YunyangNeural(云扬), zh-CN-YunfengNeural(云枫), zh-CN-XiaoxiaoNeural(晓晓), zh-CN-XiaomoNeural(晓墨)

🚀 阅读 App (Legado) 接入地址:

http://你的服务器IP:3000/api/legado
`; // ========================================== // 3. 配置存储持久化逻辑 // ========================================== const DATA_DIR = path.join(__dirname, 'data'); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR); const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); let aiConfig = { llmUrl: '', llmKey: '', llmModel: 'gpt-4o-mini', voiceNarrator: 'zh-CN-YunxiNeural', voiceMale1: 'zh-CN-YunyangNeural', voiceMale2: 'zh-CN-YunfengNeural', voiceFemale1: 'zh-CN-XiaoxiaoNeural', voiceFemale2: 'zh-CN-XiaomoNeural', }; if (fs.existsSync(CONFIG_FILE)) { try { aiConfig = { ...aiConfig, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) }; } catch (e) {} } // 全局 Token 缓存 const TOKEN_REFRESH_BEFORE_EXPIRY = 3 * 60; let tokenInfo = { endpoint: null, token: null, expiredAt: null }; // ========================================== // 4. 路由定义 // ========================================== // 原版界面 app.get(['/', '/index.html'], (req, res) => { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(HTML_PAGE); }); // 新版 5角色 AI 配置后台 app.get('/admin', (req, res) => { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(LEGADO_UI); }); // AI 配置存取接口 app.get('/api/config', (req, res) => res.json(aiConfig)); app.post('/api/config', (req, res) => { aiConfig = { ...aiConfig, ...req.body }; fs.writeFileSync(CONFIG_FILE, JSON.stringify(aiConfig, null, 2)); res.json({ success: true }); }); // 原版 STT 功能 app.post('/v1/audio/transcriptions', upload.single('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: { message: "未找到音频文件" } }); const token = req.body.token || 'sk-wtldsvuprmwltxpbspbmawtolbacghzawnjhtlzlnujjkfhh'; const apiFormData = new FormData(); const blob = new Blob([req.file.buffer], { type: req.file.mimetype }); apiFormData.append('file', blob, req.file.originalname); apiFormData.append('model', 'FunAudioLLM/SenseVoiceSmall'); const apiResponse = await fetch('https://api.siliconflow.cn/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: apiFormData }); if (!apiResponse.ok) throw new Error(`API 错误: ${apiResponse.status}`); res.json(await apiResponse.json()); } catch (error) { res.status(500).json({ error: { message: error.message } }); } }); // 原版 TTS 功能 app.post('/v1/audio/speech', upload.single('file'), async (req, res) => { try { let text = req.file ? req.file.buffer.toString('utf-8') : req.body.input; let voice = req.body.voice || "zh-CN-XiaoxiaoNeural"; let speed = req.body.speed || "1.0"; let pitch = req.body.pitch || "0"; let style = req.body.style || "general"; let volume = req.body.volume || "0"; if (!text || !text.trim()) throw new Error("文本内容为空"); let rate = parseInt(String((parseFloat(speed) - 1.0) * 100)); let numVolume = parseInt(String(parseFloat(volume) * 100)); let numPitch = parseInt(pitch); const finalBuffer = await getVoice(text, voice, rate >= 0 ? `+${rate}%` : `${rate}%`, numPitch >= 0 ? `+${numPitch}Hz` : `${numPitch}Hz`, numVolume >= 0 ? `+${numVolume}%` : `${numVolume}%`, style, "audio-24khz-48kbitrate-mono-mp3"); res.setHeader('Content-Type', 'audio/mpeg'); res.send(finalBuffer); } catch (error) { res.status(500).json({ error: { message: error.message } }); } }); // 🔥 核心融合:阅读 App (Legado) 5角色智能接口 app.all('/api/legado', async (req, res) => { const text = req.query.text || req.body.text || req.body.speakText; if (!text) return res.status(400).send('Missing text'); console.log(`\n📚 [Legado听书] 长度: ${text.length}`); let segments = []; if (aiConfig.llmKey && aiConfig.llmUrl) { try { const openai = new OpenAI({ baseURL: aiConfig.llmUrl, apiKey: aiConfig.llmKey }); const completion = await openai.chat.completions.create({ model: aiConfig.llmModel, messages: [{ role: 'user', content: `你是一个小说语音分配助手。请将文本拆分,并根据角色性别、年龄、身份分配以下 5 种角色:\n1. narrator (旁白)\n2. male1 (青年男声/主角)\n3. male2 (中年/反派男声)\n4. female1 (少女/年轻女声)\n5. female2 (成熟/御姐女声)\n严格返回JSON:{"segments":[{"text":"内容","role":"narrator/male1/male2/female1/female2"}]}\n文本:\n${text}` }], response_format: { type: 'json_object' }, temperature: 0.3 }); segments = JSON.parse(completion.choices[0].message.content).segments || []; } catch (e) { console.error('⚠️ AI 分配失败:', e.message); } } if (segments.length === 0) segments = [{ text, role: 'narrator' }]; try { const audioBufferArray = []; for (const seg of segments) { if (!seg.text.trim()) continue; let voiceName = aiConfig.voiceNarrator; if (seg.role === 'male1') voiceName = aiConfig.voiceMale1; if (seg.role === 'male2') voiceName = aiConfig.voiceMale2; if (seg.role === 'female1') voiceName = aiConfig.voiceFemale1; if (seg.role === 'female2') voiceName = aiConfig.voiceFemale2; console.log(` 🎵 [${seg.role} -> ${voiceName}]: ${seg.text.substring(0, 15)}...`); const buffer = await getAudioChunk(seg.text, voiceName, '+0%', '+0Hz', '+0%', 'general'); audioBufferArray.push(buffer); } const finalAudio = Buffer.concat(audioBufferArray); res.setHeader('Content-Type', 'audio/mpeg'); res.setHeader('Content-Length', finalAudio.length); res.send(finalAudio); console.log(`✅ 响应完毕, 传回数据 ${(finalAudio.length / 1024).toFixed(2)} KB`); } catch (error) { console.error('❌ TTS 致命错误:', error); res.status(500).send('Error generating TTS'); } }); // ========================================== // 5. 底层核心机制 // ========================================== function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function optimizedTextSplit(text, maxChunkSize = 1500) { const chunks = []; const sentences = text.split(/[。!?\n]/); let currentChunk = ''; for (const sentence of sentences) { const trimmedSentence = sentence.trim(); if (!trimmedSentence) continue; if (trimmedSentence.length > maxChunkSize) { if (currentChunk) { chunks.push(currentChunk.trim()); currentChunk = ''; } for (let i = 0; i < trimmedSentence.length; i += maxChunkSize) chunks.push(trimmedSentence.slice(i, i + maxChunkSize)); } else if ((currentChunk + trimmedSentence).length > maxChunkSize) { if (currentChunk) chunks.push(currentChunk.trim()); currentChunk = trimmedSentence; } else { currentChunk += (currentChunk ? '。' : '') + trimmedSentence; } } if (currentChunk.trim()) chunks.push(currentChunk.trim()); return chunks.filter(chunk => chunk.length > 0); } async function processBatchedAudioChunks(chunks, voiceName, rate, pitch, volume, style, outputFormat, batchSize = 3, delayMs = 1000) { const audioChunks = []; for (let i = 0; i < chunks.length; i += batchSize) { const batch = chunks.slice(i, i + batchSize); const batchPromises = batch.map(async (chunk, index) => { if (index > 0) await delay(index * 200); return await getAudioChunk(chunk, voiceName, rate, pitch, volume, style, outputFormat); }); const batchResults = await Promise.all(batchPromises); audioChunks.push(...batchResults); if (i + batchSize < chunks.length) await delay(delayMs); } return audioChunks; } async function getVoice(text, voiceName, rate, pitch, volume, style, outputFormat) { const cleanText = text.trim(); if (cleanText.length <= 1500) return await getAudioChunk(cleanText, voiceName, rate, pitch, volume, style, outputFormat); const chunks = optimizedTextSplit(cleanText, 1500); if (chunks.length > 40) throw new Error("文本过长,超过分块限制"); const audioChunks = await processBatchedAudioChunks(chunks, voiceName, rate, pitch, volume, style, outputFormat, 3, 800); return Buffer.concat(audioChunks); } async function getAudioChunk(text, voiceName, rate, pitch, volume, style, outputFormat = 'audio-24khz-48kbitrate-mono-mp3', maxRetries = 3) { const retryDelay = 500; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const endpoint = await getEndpoint(); const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; let m = text.match(/\[(\d+)\]\s*?$/); let slien = 0; if (m && m.length == 2) { slien = parseInt(m[1]); text = text.replace(m[0], ''); } const response = await fetch(url, { method: "POST", headers: { "Authorization": endpoint.t, "Content-Type": "application/ssml+xml", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "X-Microsoft-OutputFormat": outputFormat }, body: getSsml(text, voiceName, rate, pitch, volume, style, slien) }); if (!response.ok) { if (response.status === 429 && attempt < maxRetries) { await delay(retryDelay * (attempt + 1)); continue; } if (response.status >= 500 && attempt < maxRetries) { await delay(retryDelay * (attempt + 1)); continue; } throw new Error(`Edge API错误: ${response.status}`); } return Buffer.from(await response.arrayBuffer()); } catch (error) { if (attempt === maxRetries) throw error; await delay(retryDelay * (attempt + 1)); } } } function escapeXmlText(text) { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function getSsml(text, voiceName, rate, pitch, volume, style, slien = 0) { const escapedText = escapeXmlText(text); let slien_str = slien > 0 ? `` : ''; return ` ${escapedText} ${slien_str} `; } async function getEndpoint() { const now = Date.now() / 1000; if (tokenInfo.token && tokenInfo.expiredAt && now < tokenInfo.expiredAt - TOKEN_REFRESH_BEFORE_EXPIRY) return tokenInfo.endpoint; const endpointUrl = "https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0"; const clientId = crypto.randomUUID().replace(/-/g, ""); const response = await fetch(endpointUrl, { method: "POST", headers: { "Accept-Language": "zh-Hans", "X-ClientVersion": "4.0.530a 5fe1dc6c", "X-UserId": "0f04d16a175c411e", "X-HomeGeographicRegion": "zh-Hans-CN", "X-ClientTraceId": clientId, "X-MT-Signature": await sign(endpointUrl), "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Content-Type": "application/json; charset=utf-8", "Content-Length": "0" } }); if (!response.ok) throw new Error(`获取鉴权端点失败: ${response.status}`); const data = await response.json(); const decodedJwt = JSON.parse(Buffer.from(data.t.split(".")[1], 'base64').toString()); tokenInfo = { endpoint: data, token: data.t, expiredAt: decodedJwt.exp }; return data; } async function hmacSha256(key, data) { const cryptoKey = await subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signature = await subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data)); return new Uint8Array(signature); } async function sign(urlStr) { const url = urlStr.split("://")[1]; const encodedUrl = encodeURIComponent(url); const uuidStr = crypto.randomUUID().replace(/-/g, ""); const formattedDate = (new Date()).toUTCString().replace(/GMT/, "").trim() + " GMT"; const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate.toLowerCase()}${uuidStr}`.toLowerCase(); const decode = Buffer.from("oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw==", 'base64'); const signData = await hmacSha256(decode, bytesToSign); const signBase64 = Buffer.from(signData).toString('base64'); return `MSTranslatorAndroidApp::${signBase64}::${formattedDate.toLowerCase()}::${uuidStr}`; } app.listen(port, () => { console.log(`🚀 VoiceCraft + AI多角色服务已启动,访问地址: http://localhost:${port}`); });