Spaces:
Sleeping
Sleeping
| 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">返回主配音界面 →</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, '&').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 ? `<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}`); | |
| }); |