mutiltts / server.js
wangguoxing99's picture
Upload 4 files
466b350 verified
Raw
History Blame Contribute Delete
76.6 kB
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 = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="page.title">VoiceCraft - AI-Powered Voice Processing Platform</title>
<meta name="description" content="" data-i18n-content="page.description">
<meta name="keywords" content="" data-i18n-content="page.keywords">
<style>
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--secondary-color: #64748b;
--success-color: #059669;
--warning-color: #d97706;
--error-color: #dc2626;
--background-color: #f8fafc;
--surface-color: #ffffff;
--text-primary: #0f172a;
--text-secondary: #475569;
--border-color: #e2e8f0;
--border-focus: #3b82f6;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.header {
background: var(--surface-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
padding: 40px 30px;
text-align: center;
margin-bottom: 30px;
border: 1px solid var(--border-color);
}
.header h1 {
font-size: 2.5rem;
font-weight: 800;
color: var(--primary-color);
margin-bottom: 12px;
letter-spacing: -0.025em;
}
.header .subtitle {
font-size: 1.125rem;
color: var(--text-secondary);
margin-bottom: 20px;
font-weight: 500;
}
.header .features {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
margin-top: 20px;
}
.feature-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.feature-icon {
width: 20px;
height: 20px;
color: var(--success-color);
}
.main-content {
background: var(--surface-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.form-container {
padding: 40px;
}
.form-group {
margin-bottom: 24px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-primary);
font-size: 0.875rem;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 16px;
color: var(--text-primary);
background: var(--surface-color);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
}
.form-textarea {
min-height: 120px;
resize: vertical;
font-family: inherit;
}
.controls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.btn-primary {
width: 100%;
background: var(--primary-color);
color: white;
border: none;
padding: 16px 32px;
font-size: 16px;
font-weight: 600;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--success-color);
color: white;
border: none;
padding: 12px 24px;
border-radius: var(--radius-md);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-secondary:hover {
background: #047857;
transform: translateY(-1px);
}
.result-container {
margin-top: 32px;
padding: 24px;
background: var(--background-color);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
display: none;
}
.audio-player {
width: 100%;
margin-bottom: 16px;
border-radius: var(--radius-md);
}
.error-message {
color: var(--error-color);
background: #fef2f2;
border: 1px solid #fecaca;
padding: 16px;
border-radius: var(--radius-md);
margin-top: 16px;
font-weight: 500;
}
.loading-container {
text-align: center;
padding: 32px 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top: 3px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
.loading-text {
color: var(--text-secondary);
font-weight: 500;
}
.wechat-promotion {
margin-top: 40px;
background: var(--surface-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
overflow: hidden;
}
.promotion-header {
background: #f1f5f9;
padding: 20px 30px;
border-bottom: 1px solid var(--border-color);
}
.promotion-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.promotion-subtitle {
color: var(--text-secondary);
font-size: 0.875rem;
}
.promotion-content {
padding: 30px;
display: grid;
grid-template-columns: auto 1fr;
gap: 24px;
align-items: center;
}
.qr-code {
width: 120px;
height: 120px;
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: cover;
}
.promotion-info h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.promotion-info p {
color: var(--text-secondary);
margin-bottom: 16px;
line-height: 1.6;
}
.benefits-list {
list-style: none;
padding: 0;
margin: 0;
}
.benefits-list li {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 8px;
}
.benefits-list li:before {
content: "✓";
color: var(--success-color);
font-weight: bold;
font-size: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
/* 输入方式选择优化样式 */
.input-method-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: var(--background-color);
padding: 4px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 20px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.tab-btn:hover {
color: var(--primary-color);
background: rgba(37, 99, 235, 0.05);
}
.tab-btn.active {
background: var(--primary-color);
color: white;
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.tab-btn .tab-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
font-size: 0.875rem;
}
.tab-btn:not(.active) .tab-icon {
background: rgba(100, 116, 139, 0.1);
}
.file-upload-container {
width: 100%;
}
.file-drop-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, var(--background-color) 0%, rgba(248, 250, 252, 0.8) 100%);
position: relative;
overflow: hidden;
}
.file-drop-zone::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.file-drop-zone:hover::before,
.file-drop-zone.dragover::before {
opacity: 1;
}
.file-drop-zone:hover,
.file-drop-zone.dragover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(37, 99, 235, 0.15);
}
.file-drop-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
position: relative;
z-index: 1;
}
.file-drop-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, #3b82f6 100%);
border-radius: var(--radius-lg);
color: white;
margin-bottom: 8px;
box-shadow: var(--shadow-md);
position: relative;
}
.file-drop-text {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.4;
}
.file-drop-hint {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
padding: 8px 16px;
background: rgba(100, 116, 139, 0.1);
border-radius: var(--radius-sm);
}
.file-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
background: linear-gradient(135deg, var(--surface-color) 0%, rgba(248, 250, 252, 0.5) 100%);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
margin-top: 16px;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.file-info:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.file-details {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.file-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.file-name::before {
content: '';
width: 16px;
height: 16px;
background: var(--primary-color);
border-radius: 3px;
opacity: 0.8;
flex-shrink: 0;
}
.file-size {
font-size: 0.8rem;
color: var(--text-secondary);
background: rgba(100, 116, 139, 0.1);
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.file-remove-btn {
width: 32px;
height: 32px;
border: none;
background: var(--error-color);
color: white;
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 600;
}
.file-remove-btn:hover {
background: #b91c1c;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
/* 主功能切换器样式 */
.mode-switcher {
max-width: 900px;
margin: 0 auto 30px;
padding: 0 20px;
display: flex;
justify-content: center;
gap: 20px;
}
.mode-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 32px;
border: 2px solid var(--border-color);
background: var(--surface-color);
color: var(--text-secondary);
border-radius: var(--radius-lg);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
flex: 1;
max-width: 250px;
}
.mode-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.mode-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.mode-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
/* 语音转录界面样式 */
.transcription-container {
background: var(--surface-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
overflow: hidden;
max-width: 900px;
margin: 0 auto;
}
.audio-upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, var(--background-color) 0%, rgba(248, 250, 252, 0.8) 100%);
position: relative;
overflow: hidden;
}
.audio-upload-zone::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.audio-upload-zone:hover::before,
.audio-upload-zone.dragover::before {
opacity: 1;
}
.audio-upload-zone:hover,
.audio-upload-zone.dragover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(37, 99, 235, 0.15);
}
.token-config {
display: flex;
gap: 20px;
margin-bottom: 16px;
}
.token-option {
display: flex;
align-items: center;
}
.token-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
color: var(--text-secondary);
transition: color 0.2s ease;
}
.token-label:hover {
color: var(--text-primary);
}
.token-label input[type="radio"] {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--border-color);
margin: 0;
cursor: pointer;
accent-color: var(--primary-color);
}
.transcription-result {
margin-top: 20px;
}
.result-actions {
display: flex;
gap: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.result-actions .btn-secondary {
flex: 1;
min-width: 140px;
}
/* 语言切换器样式 */
.language-switcher {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.language-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.language-btn:hover {
color: var(--primary-color);
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
}
.language-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 120px;
display: none;
}
.language-dropdown.show {
display: block;
}
.language-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-secondary);
transition: background-color 0.2s ease;
}
.language-option:hover {
background: var(--background-color);
color: var(--text-primary);
}
.language-option.active {
background: var(--primary-color);
color: white;
}
@media (max-width: 768px) {
.container {
padding: 16px;
}
.header {
padding: 30px 20px;
}
.header h1 {
font-size: 2rem;
}
.form-container {
padding: 24px;
}
.controls-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.promotion-content {
grid-template-columns: 1fr;
text-align: center;
gap: 20px;
}
.qr-code {
margin: 0 auto;
}
.input-method-tabs {
gap: 2px;
padding: 2px;
}
.tab-btn {
padding: 12px 16px;
font-size: 0.85rem;
gap: 8px;
}
.tab-btn .tab-icon {
width: 18px;
height: 18px;
}
.file-drop-zone {
padding: 32px 16px;
}
.file-drop-icon {
width: 56px;
height: 56px;
}
.file-info {
padding: 16px;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.file-remove-btn {
align-self: flex-end;
}
/* 移动端模式切换器样式 */
.mode-switcher {
padding: 0 16px;
margin-bottom: 20px;
flex-direction: column;
gap: 12px;
}
.mode-btn {
max-width: none;
padding: 14px 20px;
font-size: 0.9rem;
gap: 8px;
}
.mode-icon {
width: 20px;
height: 20px;
}
/* 移动端语音转录界面样式 */
.audio-upload-zone {
padding: 32px 16px;
}
.token-config {
flex-direction: column;
gap: 12px;
}
.result-actions {
flex-direction: column;
}
.result-actions .btn-secondary {
min-width: auto;
}
}
</style>
</head>
<body>
<div class="language-switcher">
<div class="language-btn" id="languageBtn">
<span id="currentLangFlag">🌐</span>
<span id="currentLangName" data-i18n="lang.current">English</span>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
</div>
<div class="language-dropdown" id="languageDropdown">
<div class="language-option" data-lang="en">
<span>🇺🇸</span>
<span data-i18n="lang.en">English</span>
</div>
<div class="language-option" data-lang="zh">
<span>🇨🇳</span>
<span data-i18n="lang.zh">中文</span>
</div>
<div class="language-option" data-lang="ja">
<span>🇯🇵</span>
<span data-i18n="lang.ja">日本語</span>
</div>
<div class="language-option" data-lang="ko">
<span>🇰🇷</span>
<span data-i18n="lang.ko">한국어</span>
</div>
<div class="language-option" data-lang="es">
<span>🇪🇸</span>
<span data-i18n="lang.es">Español</span>
</div>
<div class="language-option" data-lang="fr">
<span>🇫🇷</span>
<span data-i18n="lang.fr">Français</span>
</div>
<div class="language-option" data-lang="de">
<span>🇩🇪</span>
<span data-i18n="lang.de">Deutsch</span>
</div>
<div class="language-option" data-lang="ru">
<span>🇷🇺</span>
<span data-i18n="lang.ru">Русский</span>
</div>
</div>
</div>
<div class="container">
<div class="header">
<h1 data-i18n="header.title">VoiceCraft</h1>
<p class="subtitle" data-i18n="header.subtitle">AI-Powered Voice Processing Platform</p>
<div class="features">
<div class="feature-item">
<span class="feature-icon">✨</span>
<span data-i18n="header.feature1">20+ Voice Options</span>
</div>
<div class="feature-item">
<span class="feature-icon">⚡</span>
<span data-i18n="header.feature2">Lightning Fast</span>
</div>
<div class="feature-item">
<span class="feature-icon">🆓</span>
<span data-i18n="header.feature3">Completely Free</span>
</div>
<div class="feature-item">
<span class="feature-icon">📱</span>
<span data-i18n="header.feature4">Download Support</span>
</div>
</div>
</div>
<div class="mode-switcher">
<button type="button" class="mode-btn active" id="ttsMode">
<span class="mode-icon">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
</svg>
</span>
<span data-i18n="mode.tts">Text to Speech</span>
</button>
<button type="button" class="mode-btn" id="transcriptionMode">
<span class="mode-icon">
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 9m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/>
<path d="M9 17v4"/>
<path d="M12 13a3 3 0 0 0 3 -3"/>
<path d="M15 9.5v-3a3 3 0 0 0 -3 -3h-1"/>
<path d="M19 8v8"/>
<path d="M17 9v6"/>
<path d="M21 9v6"/>
</svg>
</span>
<span data-i18n="mode.transcription">Speech to Text</span>
</button>
<button type="button" class="mode-btn" onclick="window.location.href='/admin'" style="background: #eef2ff; color: #4f46e5; border-color: #c7d2fe;">
<span class="mode-icon">🎙️</span>
<span>智能 5 角色后台</span>
</button>
</div>
<div class="main-content">
<div class="form-container">
<form id="ttsForm">
<div class="form-group">
<label class="form-label">选择输入方式</label>
<div class="input-method-tabs">
<button type="button" class="tab-btn active" id="textInputTab">
<span class="tab-icon">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</span>
<span>手动输入</span>
</button>
<button type="button" class="tab-btn" id="fileUploadTab">
<span class="tab-icon">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
</svg>
</span>
<span>上传文件</span>
</button>
</div>
</div>
<div class="form-group" id="textInputArea">
<label class="form-label" for="text">输入文本</label>
<textarea class="form-textarea" id="text" placeholder="请输入要转换为语音的文本内容,支持中文、英文、数字等..." required></textarea>
</div>
<div class="form-group" id="fileUploadArea" style="display: none;">
<label class="form-label" for="fileInput">上传txt文件</label>
<div class="file-upload-container">
<div class="file-drop-zone" id="fileDropZone">
<div class="file-drop-content">
<div class="file-drop-icon">
<svg width="28" height="28" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L13.09 8.26L19 7L17.74 13.09L24 12L17.74 10.91L19 5L13.09 6.26L12 0L10.91 6.26L5 5L6.26 10.91L0 12L6.26 13.09L5 19L10.91 17.74L12 24L13.09 17.74L19 19L17.74 13.09L24 12Z"/>
<path d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2M18 20H6V4H13V9H18V20Z"/>
</svg>
</div>
<p class="file-drop-text">拖拽txt文件到此处,或点击选择文件</p>
<p class="file-drop-hint">支持txt格式,最大500KB</p>
</div>
<input type="file" id="fileInput" accept=".txt,text/plain" style="display: none;">
</div>
<div class="file-info" id="fileInfo" style="display: none;">
<div class="file-details">
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
</div>
<button type="button" class="file-remove-btn" id="fileRemoveBtn">✕</button>
</div>
</div>
</div>
<div class="controls-grid">
<div class="form-group">
<label class="form-label" for="voice">语音选择</label>
<select class="form-select" id="voice">
<option value="zh-CN-XiaoxiaoNeural">晓晓 (女声·温柔)</option>
<option value="zh-CN-YunxiNeural">云希 (男声·清朗)</option>
<option value="zh-CN-YunyangNeural">云扬 (男声·阳光)</option>
<option value="zh-CN-XiaoyiNeural">晓伊 (女声·甜美)</option>
<option value="zh-CN-YunjianNeural">云健 (男声·稳重)</option>
<option value="zh-CN-XiaochenNeural">晓辰 (女声·知性)</option>
<option value="zh-CN-XiaohanNeural">晓涵 (女声·优雅)</option>
<option value="zh-CN-XiaomengNeural">晓梦 (女声·梦幻)</option>
<option value="zh-CN-XiaomoNeural">晓墨 (女声·文艺)</option>
<option value="zh-CN-XiaoqiuNeural">晓秋 (女声·成熟)</option>
<option value="zh-CN-XiaoruiNeural">晓睿 (女声·智慧)</option>
<option value="zh-CN-XiaoshuangNeural">晓双 (女声·活泼)</option>
<option value="zh-CN-XiaoxuanNeural">晓萱 (女声·清新)</option>
<option value="zh-CN-XiaoyanNeural">晓颜 (女声·柔美)</option>
<option value="zh-CN-XiaoyouNeural">晓悠 (女声·悠扬)</option>
<option value="zh-CN-XiaozhenNeural">晓甄 (女声·端庄)</option>
<option value="zh-CN-YunfengNeural">云枫 (男声·磁性)</option>
<option value="zh-CN-YunhaoNeural">云皓 (男声·豪迈)</option>
<option value="zh-CN-YunxiaNeural">云夏 (男声·热情)</option>
<option value="zh-CN-YunyeNeural">云野 (男声·野性)</option>
<option value="zh-CN-YunzeNeural">云泽 (男声·深沉)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="speed">语速调节</label>
<select class="form-select" id="speed">
<option value="0.5">🐌 很慢</option>
<option value="0.75">🚶 慢速</option>
<option value="1.0" selected>⚡ 正常</option>
<option value="1.25">🏃 快速</option>
<option value="1.5">🚀 很快</option>
<option value="2.0">💨 极速</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="pitch">音调高低</label>
<select class="form-select" id="pitch">
<option value="-50">📉 很低沉</option>
<option value="-25">📊 低沉</option>
<option value="0" selected>🎵 标准</option>
<option value="25">📈 高亢</option>
<option value="50">🎶 很高亢</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="style">语音风格</label>
<select class="form-select" id="style">
<option value="general" selected>🎭 通用风格</option>
<option value="assistant">🤖 智能助手</option>
<option value="chat">💬 聊天对话</option>
<option value="customerservice">📞 客服专业</option>
<option value="newscast">📺 新闻播报</option>
<option value="affectionate">💕 亲切温暖</option>
<option value="calm">😌 平静舒缓</option>
<option value="cheerful">😊 愉快欢乐</option>
<option value="gentle">🌸 温和柔美</option>
<option value="lyrical">🎼 抒情诗意</option>
<option value="serious">🎯 严肃正式</option>
</select>
</div>
</div>
<button type="submit" class="btn-primary" id="generateBtn">
<span>🎙️</span>
<span>开始生成语音</span>
</button>
</form>
<div id="result" class="result-container">
<div id="loading" class="loading-container" style="display: none;">
<div class="loading-spinner"></div>
<p class="loading-text" id="loadingText">正在生成语音,请稍候...</p>
<div class="progress-info" id="progressInfo" style="margin-top: 12px; font-size: 0.875rem; color: var(--text-secondary);"></div>
</div>
<div id="success" style="display: none;">
<audio id="audioPlayer" class="audio-player" controls></audio>
<a id="downloadBtn" class="btn-secondary" download="speech.mp3">
<span>📥</span>
<span>下载音频文件</span>
</a>
</div>
<div id="error" class="error-message" style="display: none;"></div>
</div>
</div>
</div>
<div class="transcription-container" id="transcriptionContainer" style="display: none;">
<div class="form-container">
<form id="transcriptionForm">
<div class="form-group">
<label class="form-label">上传音频文件</label>
<div class="audio-upload-zone" id="audioDropZone">
<div class="file-drop-content">
<div class="file-drop-icon">
<svg width="28" height="28" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
<path d="M14 2v6h6"/>
<path d="M12 18v-6"/>
<path d="M9 15l3-3 3 3"/>
</svg>
</div>
<p class="file-drop-text">拖拽音频文件到此处,或点击选择文件</p>
<p class="file-drop-hint">支持mp3、wav、m4a、flac、aac、ogg、webm、amr、3gp格式,最大10MB</p>
</div>
<input type="file" id="audioFileInput" accept=".mp3,.wav,.m4a,.flac,.aac,.ogg,.webm,.amr,.3gp,audio/*" style="display: none;">
</div>
<div class="file-info" id="audioFileInfo" style="display: none;">
<div class="file-details">
<span class="file-name" id="audioFileName"></span>
<span class="file-size" id="audioFileSize"></span>
</div>
<button type="button" class="file-remove-btn" id="audioFileRemoveBtn">✕</button>
</div>
</div>
<div class="form-group">
<label class="form-label" for="tokenInput">API Token配置</label>
<div class="token-config">
<div class="token-option">
<label class="token-label">
<input type="radio" name="tokenOption" value="default" checked>
<span>使用默认Token</span>
</label>
</div>
<div class="token-option">
<label class="token-label">
<input type="radio" name="tokenOption" value="custom">
<span>使用硅基流动自定义Token</span>
</label>
</div>
</div>
<input type="password" class="form-input" id="tokenInput"
placeholder="输入您的API Token(可选)" style="display: none;">
</div>
<button type="submit" class="btn-primary" id="transcribeBtn">
<span>🎧</span>
<span>开始语音转录</span>
</button>
</form>
<div id="transcriptionResult" class="result-container">
<div id="transcriptionLoading" class="loading-container" style="display: none;">
<div class="loading-spinner"></div>
<p class="loading-text" id="transcriptionLoadingText">正在转录音频,请稍候...</p>
<div class="progress-info" id="transcriptionProgressInfo" style="margin-top: 12px; font-size: 0.875rem; color: var(--text-secondary);"></div>
</div>
<div id="transcriptionSuccess" style="display: none;">
<div class="transcription-result">
<label class="form-label">转录结果</label>
<textarea class="form-textarea" id="transcriptionText"
placeholder="转录结果将在这里显示..." readonly></textarea>
<div class="result-actions">
<button type="button" class="btn-secondary" id="copyTranscriptionBtn">
<span>📋</span>
<span>复制文本</span>
</button>
<button type="button" class="btn-secondary" id="editTranscriptionBtn">
<span>✏️</span>
<span>编辑文本</span>
</button>
<button type="button" class="btn-secondary" id="useForTtsBtn">
<span>🎙️</span>
<span>转为语音</span>
</button>
</div>
</div>
</div>
<div id="transcriptionError" class="error-message" style="display: none;"></div>
</div>
</div>
</div>
</div>
<script>
let selectedFile = null;
let currentInputMethod = 'text';
let currentMode = 'tts';
let selectedAudioFile = null;
let transcriptionToken = null;
let currentLanguage = 'en';
const translations = {
en: {
'page.title': 'VoiceCraft - AI-Powered Voice Processing Platform',
'lang.current': 'English', 'lang.en': 'English', 'lang.zh': '中文',
'header.title': 'VoiceCraft', 'header.subtitle': 'AI-Powered Voice Processing Platform',
'header.feature1': '20+ Voice Options', 'header.feature2': 'Lightning Fast',
'header.feature3': 'Completely Free', 'header.feature4': 'Download Support',
'mode.tts': 'Text to Speech', 'mode.transcription': 'Speech to Text'
},
zh: {
'page.title': 'VoiceCraft - AI驱动的语音处理平台',
'lang.current': '中文', 'lang.en': 'English', 'lang.zh': '中文',
'header.title': 'VoiceCraft', 'header.subtitle': 'AI驱动的语音处理平台',
'header.feature1': '20+种语音选项', 'header.feature2': '闪电般快速',
'header.feature3': '完全免费', 'header.feature4': '支持下载',
'mode.tts': '文字转语音', 'mode.transcription': '语音转文字'
}
};
function detectLanguage() {
const browserLang = navigator.language || navigator.userLanguage;
const shortLang = browserLang.split('-')[0];
return translations[shortLang] ? shortLang : 'en';
}
function setLanguage(lang) {
currentLanguage = lang;
localStorage.setItem('voicecraft-language', lang);
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : lang;
const langData = translations[currentLanguage];
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
if (langData[key]) element.textContent = langData[key];
});
if (langData['page.title']) document.title = langData['page.title'];
}
document.addEventListener('DOMContentLoaded', function() {
setLanguage(localStorage.getItem('voicecraft-language') || detectLanguage());
const textInputTab = document.getElementById('textInputTab');
const fileUploadTab = document.getElementById('fileUploadTab');
const textInputArea = document.getElementById('textInputArea');
const fileUploadArea = document.getElementById('fileUploadArea');
textInputTab.addEventListener('click', function() {
currentInputMethod = 'text';
textInputTab.classList.add('active'); fileUploadTab.classList.remove('active');
textInputArea.style.display = 'block'; fileUploadArea.style.display = 'none';
document.getElementById('text').required = true;
});
fileUploadTab.addEventListener('click', function() {
currentInputMethod = 'file';
fileUploadTab.classList.add('active'); textInputTab.classList.remove('active');
textInputArea.style.display = 'none'; fileUploadArea.style.display = 'block';
document.getElementById('text').required = false;
});
const fileDropZone = document.getElementById('fileDropZone');
const fileInput = document.getElementById('fileInput');
fileDropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => { if (e.target.files[0]) handleFileSelect(e.target.files[0]); });
function handleFileSelect(file) {
if (file.size > 500 * 1024) return alert('文件大小不能超过500KB');
selectedFile = file;
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileInfo').style.display = 'flex';
document.getElementById('fileDropZone').style.display = 'none';
}
document.getElementById('fileRemoveBtn').addEventListener('click', function() {
selectedFile = null; fileInput.value = '';
document.getElementById('fileInfo').style.display = 'none';
document.getElementById('fileDropZone').style.display = 'block';
});
document.getElementById('ttsForm').addEventListener('submit', async function(e) {
e.preventDefault();
const generateBtn = document.getElementById('generateBtn');
document.getElementById('result').style.display = 'block';
document.getElementById('loading').style.display = 'block';
document.getElementById('success').style.display = 'none';
document.getElementById('error').style.display = 'none';
generateBtn.disabled = true; generateBtn.textContent = '生成中...';
try {
let response;
if (currentInputMethod === 'text') {
response = await fetch('/v1/audio/speech', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: document.getElementById('text').value,
voice: document.getElementById('voice').value,
speed: document.getElementById('speed').value,
pitch: document.getElementById('pitch').value,
style: document.getElementById('style').value
})
});
} else {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('voice', document.getElementById('voice').value);
formData.append('speed', document.getElementById('speed').value);
formData.append('pitch', document.getElementById('pitch').value);
formData.append('style', document.getElementById('style').value);
response = await fetch('/v1/audio/speech', { method: 'POST', body: formData });
}
if (!response.ok) throw new Error((await response.json()).error?.message || '生成失败');
const audioUrl = URL.createObjectURL(await response.blob());
document.getElementById('audioPlayer').src = audioUrl;
document.getElementById('downloadBtn').href = audioUrl;
document.getElementById('loading').style.display = 'none';
document.getElementById('success').style.display = 'block';
} catch (err) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = '错误: ' + err.message;
} finally {
generateBtn.disabled = false; generateBtn.innerHTML = '<span>🎙️</span><span>开始生成语音</span>';
}
});
const ttsMode = document.getElementById('ttsMode');
const transcriptionMode = document.getElementById('transcriptionMode');
ttsMode.addEventListener('click', () => switchMode('tts'));
transcriptionMode.addEventListener('click', () => switchMode('transcription'));
function switchMode(mode) {
currentMode = mode;
if (mode === 'tts') {
ttsMode.classList.add('active'); transcriptionMode.classList.remove('active');
document.querySelector('.main-content').style.display = 'block';
document.getElementById('transcriptionContainer').style.display = 'none';
} else {
transcriptionMode.classList.add('active'); ttsMode.classList.remove('active');
document.querySelector('.main-content').style.display = 'none';
document.getElementById('transcriptionContainer').style.display = 'block';
}
}
const audioDropZone = document.getElementById('audioDropZone');
const audioFileInput = document.getElementById('audioFileInput');
audioDropZone.addEventListener('click', () => audioFileInput.click());
audioFileInput.addEventListener('change', e => { if (e.target.files[0]) handleAudioFileSelect(e.target.files[0]); });
function handleAudioFileSelect(file) {
if (file.size > 10 * 1024 * 1024) return alert('音频文件大小不能超过10MB');
selectedAudioFile = file;
document.getElementById('audioFileName').textContent = file.name;
document.getElementById('audioFileInfo').style.display = 'flex';
document.getElementById('audioDropZone').style.display = 'none';
}
document.getElementById('audioFileRemoveBtn').addEventListener('click', function() {
selectedAudioFile = null; audioFileInput.value = '';
document.getElementById('audioFileInfo').style.display = 'none';
document.getElementById('audioDropZone').style.display = 'block';
});
document.querySelectorAll('input[name="tokenOption"]').forEach(r => {
r.addEventListener('change', function() {
document.getElementById('tokenInput').style.display = this.value === 'custom' ? 'block' : 'none';
});
});
document.getElementById('transcriptionForm').addEventListener('submit', async function(e) {
e.preventDefault();
if (!selectedAudioFile) return alert('请选择音频文件');
const btn = document.getElementById('transcribeBtn');
document.getElementById('transcriptionResult').style.display = 'block';
document.getElementById('transcriptionLoading').style.display = 'block';
document.getElementById('transcriptionSuccess').style.display = 'none';
document.getElementById('transcriptionError').style.display = 'none';
btn.disabled = true; btn.textContent = '转录中...';
try {
const formData = new FormData();
formData.append('file', selectedAudioFile);
if (document.querySelector('input[name="tokenOption"]:checked').value === 'custom') {
formData.append('token', document.getElementById('tokenInput').value);
}
const response = await fetch('/v1/audio/transcriptions', { method: 'POST', body: formData });
if (!response.ok) throw new Error((await response.json()).error?.message || '转录失败');
document.getElementById('transcriptionText').value = (await response.json()).text || '';
document.getElementById('transcriptionLoading').style.display = 'none';
document.getElementById('transcriptionSuccess').style.display = 'block';
} catch (err) {
document.getElementById('transcriptionLoading').style.display = 'none';
document.getElementById('transcriptionError').style.display = 'block';
document.getElementById('transcriptionError').textContent = '错误: ' + err.message;
} finally {
btn.disabled = false; btn.innerHTML = '<span>🎧</span><span>开始语音转录</span>';
}
});
});
</script>
</body>
</html>
`;
// ==========================================
// 2. 全新:AI 智能 5角色 专属控制台 UI
// ==========================================
const LEGADO_UI = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 智能 5 角色听书后台</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 p-6 font-sans">
<div class="max-w-4xl mx-auto bg-white p-8 rounded-xl shadow-md">
<div class="flex justify-between items-center mb-6 border-b pb-4">
<h1 class="text-2xl font-bold text-blue-600">🎙️ AI 智能 5角色听书后台</h1>
<a href="/" class="text-gray-500 hover:text-blue-500 font-medium transition">返回主配音界面 &rarr;</a>
</div>
<div class="space-y-6">
<div class="bg-blue-50 p-5 rounded-lg border border-blue-100">
<h2 class="text-lg font-semibold text-blue-800 mb-3">1. 大模型配置 (用于分析文本角色)</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="text" id="llmUrl" placeholder="API Base URL (例如: https://api.deepseek.com/v1)" class="w-full p-2 border rounded">
<input type="text" id="llmModel" placeholder="模型名称 (例如: gpt-4o-mini)" class="w-full p-2 border rounded">
</div>
<input type="password" id="llmKey" placeholder="API Key (sk-...)" class="w-full p-2 mt-4 border rounded">
</div>
<div class="bg-green-50 p-5 rounded-lg border border-green-100">
<h2 class="text-lg font-semibold text-green-800 mb-3">2. 微软稳定版发音人 (5角色)</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mt-2">
<div><label class="text-sm font-medium text-gray-700">旁白:</label><input type="text" id="voiceNarrator" class="w-full p-2 border rounded mt-1"></div>
<div><label class="text-sm font-medium text-gray-700">男 1 (青年主角):</label><input type="text" id="voiceMale1" class="w-full p-2 border rounded mt-1"></div>
<div><label class="text-sm font-medium text-gray-700">男 2 (大叔反派):</label><input type="text" id="voiceMale2" class="w-full p-2 border rounded mt-1"></div>
<div><label class="text-sm font-medium text-gray-700">女 1 (少女主角):</label><input type="text" id="voiceFemale1" class="w-full p-2 border rounded mt-1"></div>
<div><label class="text-sm font-medium text-gray-700">女 2 (御姐配角):</label><input type="text" id="voiceFemale2" class="w-full p-2 border rounded mt-1"></div>
</div>
<p class="text-xs text-gray-500 mt-3 bg-white p-2 rounded border">参考音色: zh-CN-YunxiNeural(云希), zh-CN-YunyangNeural(云扬), zh-CN-YunfengNeural(云枫), zh-CN-XiaoxiaoNeural(晓晓), zh-CN-XiaomoNeural(晓墨)</p>
</div>
<button onclick="saveConfig()" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg shadow-md transition">💾 保存并应用</button>
<div class="mt-8 border-t pt-6">
<h2 class="text-lg font-semibold text-gray-800 mb-2">🚀 阅读 App (Legado) 接入地址:</h2>
<code class="block bg-gray-800 text-green-400 p-3 rounded text-sm break-all shadow-inner">http://你的服务器IP:3000/api/legado</code>
</div>
</div>
</div>
<script>
const FIELDS = ['llmUrl', 'llmKey', 'llmModel', 'voiceNarrator', 'voiceMale1', 'voiceMale2', 'voiceFemale1', 'voiceFemale2'];
async function loadConfig() {
const res = await fetch('/api/config').then(r => r.json());
FIELDS.forEach(id => { if(res[id]) document.getElementById(id).value = res[id]; });
}
async function saveConfig() {
const payload = {};
FIELDS.forEach(id => { payload[id] = document.getElementById(id).value.trim(); });
await fetch('/api/config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
alert('配置已生效!请去阅读App中测试多角色朗读吧!');
}
loadConfig();
</script>
</body>
</html>`;
// ==========================================
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function getSsml(text, voiceName, rate, pitch, volume, style, slien = 0) {
const escapedText = escapeXmlText(text);
let slien_str = slien > 0 ? `<break time="${slien}ms" />` : '';
return `<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" version="1.0" xml:lang="zh-CN">
<voice name="${voiceName}">
<mstts:express-as style="${style}" styledegree="2.0" role="default" >
<prosody rate="${rate}" pitch="${pitch}" volume="${volume}">${escapedText}</prosody>
</mstts:express-as>
${slien_str}
</voice>
</speak>`;
}
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}`);
});