| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>AI语音播客工作室</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
| <style> |
| |
| .waveform { |
| background: linear-gradient(90deg, #3b82f6 0%, #3b82f6 var(--progress, 0%), #e5e7eb var(--progress, 0%), #e5e7eb 100%); |
| } |
| |
| .voice-option:hover .play-sample { |
| display: block; |
| } |
| |
| .fade-in { |
| animation: fadeIn 0.3s ease-in; |
| } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; } |
| to { opacity: 1; } |
| } |
| |
| |
| ::-webkit-scrollbar { |
| width: 8px; |
| height: 8px; |
| } |
| |
| ::-webkit-scrollbar-track { |
| background: #f1f1f1; |
| } |
| |
| ::-webkit-scrollbar-thumb { |
| background: #888; |
| border-radius: 4px; |
| } |
| |
| ::-webkit-scrollbar-thumb:hover { |
| background: #555; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-50 text-gray-800 min-h-screen flex flex-col"> |
| |
| <nav class="bg-indigo-600 text-white shadow-lg"> |
| <div class="container mx-auto px-4 py-3 flex justify-between items-center"> |
| <div class="flex items-center space-x-2"> |
| <i class="fas fa-microphone-alt text-2xl"></i> |
| <h1 class="text-xl font-bold">AI语音播客工作室</h1> |
| </div> |
| <div class="hidden md:flex space-x-6"> |
| <a href="#" class="hover:text-indigo-200 transition">首页</a> |
| <a href="#" class="hover:text-indigo-200 transition">教程</a> |
| <a href="#" class="hover:text-indigo-200 transition">示例</a> |
| <a href="#" class="hover:text-indigo-200 transition">关于</a> |
| </div> |
| <button class="md:hidden text-xl"> |
| <i class="fas fa-bars"></i> |
| </button> |
| </div> |
| </nav> |
|
|
| |
| <main class="flex-grow container mx-auto px-4 py-8"> |
| <div class="max-w-6xl mx-auto"> |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> |
| |
| <div class="lg:col-span-2 space-y-6"> |
| |
| <div class="bg-white rounded-xl shadow-md overflow-hidden"> |
| <div class="bg-indigo-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center"> |
| <h2 class="font-semibold text-indigo-700">输入文本</h2> |
| <div class="flex space-x-2"> |
| <button id="clear-text" class="text-gray-500 hover:text-gray-700 transition"> |
| <i class="fas fa-trash-alt"></i> 清空 |
| </button> |
| <button id="sample-text" class="text-indigo-600 hover:text-indigo-800 transition"> |
| <i class="fas fa-lightbulb"></i> 示例 |
| </button> |
| </div> |
| </div> |
| <div class="p-4"> |
| <textarea id="input-text" rows="10" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" placeholder="在这里输入您想要转换为语音的文本..."></textarea> |
| </div> |
| </div> |
| |
| |
| <div class="bg-white rounded-xl shadow-md overflow-hidden"> |
| <div class="bg-indigo-50 px-4 py-3 border-b border-gray-200"> |
| <h2 class="font-semibold text-indigo-700">语音设置</h2> |
| </div> |
| <div class="p-4 space-y-4"> |
| |
| <div> |
| <label class="block text-sm font-medium text-gray-700 mb-1">选择语音</label> |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-3" id="voice-options"> |
| |
| </div> |
| </div> |
| |
| |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| <div> |
| <label for="speed" class="block text-sm font-medium text-gray-700">语速 <span id="speed-value" class="text-indigo-600">1.0</span></label> |
| <input type="range" id="speed" min="0.5" max="2" step="0.1" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> |
| </div> |
| <div> |
| <label for="pitch" class="block text-sm font-medium text-gray-700">音高 <span id="pitch-value" class="text-indigo-600">1.0</span></label> |
| <input type="range" id="pitch" min="0.5" max="1.5" step="0.1" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> |
| </div> |
| <div> |
| <label for="volume" class="block text-sm font-medium text-gray-700">音量 <span id="volume-value" class="text-indigo-600">1.0</span></label> |
| <input type="range" id="volume" min="0" max="1" step="0.1" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> |
| </div> |
| </div> |
| |
| |
| <div> |
| <label class="block text-sm font-medium text-gray-700 mb-1">情感风格</label> |
| <div class="flex flex-wrap gap-2"> |
| <button class="emotion-btn px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-full border border-gray-300 transition" data-emotion="neutral">中性</button> |
| <button class="emotion-btn px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-full border border-gray-300 transition" data-emotion="happy">快乐</button> |
| <button class="emotion-btn px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-full border border-gray-300 transition" data-emotion="sad">悲伤</button> |
| <button class="emotion-btn px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-full border border-gray-300 transition" data-emotion="angry">愤怒</button> |
| <button class="emotion-btn px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-full border border-gray-300 transition" data-emotion="excited">兴奋</button> |
| <button class="emotion-btn px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-full border border-gray-300 transition" data-emotion="calm">平静</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="bg-white rounded-xl shadow-md overflow-hidden"> |
| <div class="bg-indigo-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center"> |
| <h2 class="font-semibold text-indigo-700">API设置</h2> |
| <button id="toggle-api" class="text-indigo-600 hover:text-indigo-800 transition"> |
| <i class="fas fa-cog"></i> 高级设置 |
| </button> |
| </div> |
| <div id="api-settings" class="p-4 hidden space-y-4"> |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| <div> |
| <label for="tts-api" class="block text-sm font-medium text-gray-700 mb-1">TTS API端点</label> |
| <input type="text" id="tts-api" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" placeholder="https://api.example.com/tts"> |
| </div> |
| <div> |
| <label for="tts-model" class="block text-sm font-medium text-gray-700 mb-1">TTS模型</label> |
| <select id="tts-model" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition"> |
| <option value="vits">VITS (默认)</option> |
| <option value="fastspeech2">FastSpeech2</option> |
| <option value="tacotron2">Tacotron2</option> |
| <option value="glowtts">Glow-TTS</option> |
| </select> |
| </div> |
| </div> |
| |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| <div> |
| <label for="nlp-api" class="block text-sm font-medium text-gray-700 mb-1">NLP API端点</label> |
| <input type="text" id="nlp-api" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" placeholder="https://api.example.com/nlp"> |
| </div> |
| <div> |
| <label for="nlp-model" class="block text-sm font-medium text-gray-700 mb-1">NLP模型</label> |
| <select id="nlp-model" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition"> |
| <option value="bert">BERT (默认)</option> |
| <option value="gpt">GPT</option> |
| <option value="roberta">RoBERTa</option> |
| <option value="t5">T5</option> |
| </select> |
| </div> |
| </div> |
| |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| <div> |
| <label for="api-key" class="block text-sm font-medium text-gray-700 mb-1">API密钥</label> |
| <input type="password" id="api-key" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" placeholder="输入您的API密钥"> |
| </div> |
| <div> |
| <label for="api-region" class="block text-sm font-medium text-gray-700 mb-1">服务区域</label> |
| <select id="api-region" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition"> |
| <option value="global">全球 (默认)</option> |
| <option value="cn-east-1">华东1</option> |
| <option value="cn-north-1">华北1</option> |
| <option value="cn-south-1">华南1</option> |
| <option value="us-east-1">美东1</option> |
| <option value="eu-west-1">欧洲西部1</option> |
| </select> |
| </div> |
| </div> |
| <div class="flex justify-end"> |
| <button id="save-api" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition"> |
| 保存设置 |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="space-y-6"> |
| |
| <div class="bg-white rounded-xl shadow-md overflow-hidden"> |
| <div class="bg-indigo-50 px-4 py-3 border-b border-gray-200"> |
| <h2 class="font-semibold text-indigo-700">播放控制</h2> |
| </div> |
| <div class="p-4 space-y-4"> |
| |
| <div class="waveform h-12 rounded-lg" id="waveform" style="--progress: 0%"></div> |
| |
| |
| <div class="flex justify-center space-x-4"> |
| <button id="generate-btn" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition flex items-center space-x-2"> |
| <i class="fas fa-magic"></i> |
| <span>生成语音</span> |
| </button> |
| <button id="play-btn" disabled class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition flex items-center space-x-2"> |
| <i class="fas fa-play"></i> |
| <span>播放</span> |
| </button> |
| <button id="pause-btn" disabled class="px-6 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition flex items-center space-x-2"> |
| <i class="fas fa-pause"></i> |
| <span>暂停</span> |
| </button> |
| </div> |
| |
| |
| <div class="flex justify-between items-center text-sm text-gray-600"> |
| <span id="current-time">00:00</span> |
| <span id="total-time">00:00</span> |
| </div> |
| |
| |
| <div class="pt-4 border-t border-gray-200"> |
| <label class="block text-sm font-medium text-gray-700 mb-2">下载格式</label> |
| <div class="flex space-x-3"> |
| <button id="download-mp3" disabled class="px-4 py-2 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-lg border border-gray-300 transition flex items-center space-x-2"> |
| <i class="fas fa-file-audio"></i> |
| <span>MP3</span> |
| </button> |
| <button id="download-wav" disabled class="px-4 py-2 bg-gray-100 hover:bg-indigo-100 text-gray-800 rounded-lg border border-gray-300 transition flex items-center space-x-2"> |
| <i class="fas fa-file-waveform"></i> |
| <span>WAV</span> |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="bg-white rounded-xl shadow-md overflow-hidden"> |
| <div class="bg-indigo-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center"> |
| <h2 class="font-semibold text-indigo-700">历史记录</h2> |
| <button id="clear-history" class="text-gray-500 hover:text-gray-700 transition text-sm"> |
| <i class="fas fa-trash-alt"></i> 清空 |
| </button> |
| </div> |
| <div class="p-4"> |
| <div id="history-list" class="space-y-2 max-h-60 overflow-y-auto"> |
| |
| <div class="text-center text-gray-500 py-4"> |
| <i class="fas fa-history text-2xl mb-2"></i> |
| <p>暂无历史记录</p> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="bg-indigo-50 rounded-xl p-4 border border-indigo-100"> |
| <h3 class="font-medium text-indigo-700 mb-2 flex items-center"> |
| <i class="fas fa-lightbulb mr-2"></i> 使用小贴士 |
| </h3> |
| <ul class="text-sm text-indigo-800 space-y-1"> |
| <li>• 使用不同的情感风格来匹配文本内容</li> |
| <li>• 调整语速和音高以获得最佳效果</li> |
| <li>• 长文本会自动分段处理</li> |
| <li>• 点击示例按钮获取灵感</li> |
| </ul> |
| </div> |
| </div> |
| </div> |
| </div> |
| </main> |
|
|
| |
| <footer class="bg-gray-800 text-white py-6"> |
| <div class="container mx-auto px-4"> |
| <div class="flex flex-col md:flex-row justify-between items-center"> |
| <div class="mb-4 md:mb-0"> |
| <div class="flex items-center space-x-2"> |
| <i class="fas fa-microphone-alt text-2xl text-indigo-400"></i> |
| <span class="text-xl font-bold">AI语音播客工作室</span> |
| </div> |
| <p class="text-gray-400 text-sm mt-1">让文字生动起来</p> |
| </div> |
| <div class="flex space-x-6"> |
| <a href="#" class="hover:text-indigo-400 transition">隐私政策</a> |
| <a href="#" class="hover:text-indigo-400 transition">服务条款</a> |
| <a href="#" class="hover:text-indigo-400 transition">联系我们</a> |
| </div> |
| </div> |
| <div class="border-t border-gray-700 mt-6 pt-6 text-center text-gray-400 text-sm"> |
| <p>© 2023 AI语音播客工作室. 保留所有权利.</p> |
| </div> |
| </div> |
| </footer> |
|
|
| |
| <audio id="audio-player" class="hidden"></audio> |
|
|
| |
| <script> |
| |
| const App = (() => { |
| |
| const state = { |
| voices: [ |
| { id: 'voice-1', name: '标准女声', gender: 'female', sampleText: '您好,欢迎使用AI语音播客工作室。', selected: true }, |
| { id: 'voice-2', name: '标准男声', gender: 'male', sampleText: '科技改变生活,语音连接未来。' }, |
| { id: 'voice-3', name: '甜美女声', gender: 'female', sampleText: '今天的天气真好,阳光明媚,适合外出散步。' }, |
| { id: 'voice-4', name: '磁性男声', gender: 'male', sampleText: '深度思考是人类最宝贵的财富之一。' }, |
| { id: 'voice-5', name: '儿童声音', gender: 'child', sampleText: '我最喜欢听故事了,能给我讲一个吗?' }, |
| { id: 'voice-6', name: '老年声音', gender: 'senior', sampleText: '岁月如歌,人生如梦,珍惜当下每一刻。' } |
| ], |
| currentVoice: null, |
| emotion: 'neutral', |
| audioBlob: null, |
| history: JSON.parse(localStorage.getItem('ttsHistory')) || [], |
| apiSettings: JSON.parse(localStorage.getItem('apiSettings')) || { |
| ttsApi: '', |
| ttsModel: 'vits', |
| nlpApi: '', |
| nlpModel: 'bert', |
| apiKey: '', |
| apiRegion: 'global' |
| }, |
| isPlaying: false, |
| audioDuration: 0, |
| currentAudioTime: 0, |
| audioContext: null, |
| analyser: null, |
| audioSource: null, |
| animationFrameId: null |
| }; |
| |
| |
| const elements = { |
| inputText: document.getElementById('input-text'), |
| voiceOptions: document.getElementById('voice-options'), |
| speedSlider: document.getElementById('speed'), |
| speedValue: document.getElementById('speed-value'), |
| pitchSlider: document.getElementById('pitch'), |
| pitchValue: document.getElementById('pitch-value'), |
| volumeSlider: document.getElementById('volume'), |
| volumeValue: document.getElementById('volume-value'), |
| emotionBtns: document.querySelectorAll('.emotion-btn'), |
| generateBtn: document.getElementById('generate-btn'), |
| playBtn: document.getElementById('play-btn'), |
| pauseBtn: document.getElementById('pause-btn'), |
| audioPlayer: document.getElementById('audio-player'), |
| waveform: document.getElementById('waveform'), |
| currentTime: document.getElementById('current-time'), |
| totalTime: document.getElementById('total-time'), |
| downloadMp3: document.getElementById('download-mp3'), |
| downloadWav: document.getElementById('download-wav'), |
| clearText: document.getElementById('clear-text'), |
| sampleText: document.getElementById('sample-text'), |
| historyList: document.getElementById('history-list'), |
| clearHistory: document.getElementById('clear-history'), |
| toggleApi: document.getElementById('toggle-api'), |
| apiSettings: document.getElementById('api-settings'), |
| ttsApi: document.getElementById('tts-api'), |
| nlpApi: document.getElementById('nlp-api'), |
| apiKey: document.getElementById('api-key'), |
| saveApi: document.getElementById('save-api') |
| }; |
| |
| |
| const init = () => { |
| renderVoiceOptions(); |
| bindEvents(); |
| loadApiSettings(); |
| renderHistory(); |
| setupAudioContext(); |
| |
| |
| state.currentVoice = state.voices.find(v => v.selected); |
| }; |
| |
| |
| const renderVoiceOptions = () => { |
| elements.voiceOptions.innerHTML = state.voices.map(voice => ` |
| <div class="voice-option relative p-3 border rounded-lg cursor-pointer transition ${voice.selected ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-indigo-300'}" data-voice-id="${voice.id}"> |
| <div class="flex items-center"> |
| <div class="mr-3"> |
| <i class="fas fa-${voice.gender === 'male' ? 'male' : voice.gender === 'female' ? 'female' : voice.gender === 'child' ? 'child' : 'user'} text-xl ${voice.gender === 'male' ? 'text-blue-500' : voice.gender === 'female' ? 'text-pink-500' : 'text-purple-500'}"></i> |
| </div> |
| <div> |
| <h3 class="font-medium">${voice.name}</h3> |
| <p class="text-sm text-gray-600 truncate">${voice.sampleText}</p> |
| </div> |
| </div> |
| <button class="play-sample absolute right-3 top-3 hidden text-indigo-600 hover:text-indigo-800" data-sample-text="${voice.sampleText}"> |
| <i class="fas fa-play-circle text-lg"></i> |
| </button> |
| </div> |
| `).join(''); |
| }; |
| |
| |
| const bindEvents = () => { |
| |
| document.querySelectorAll('.voice-option').forEach(el => { |
| el.addEventListener('click', (e) => { |
| const voiceId = el.getAttribute('data-voice-id'); |
| selectVoice(voiceId); |
| |
| |
| if (e.target.closest('.play-sample')) { |
| e.stopPropagation(); |
| } |
| }); |
| }); |
| |
| |
| document.querySelectorAll('.play-sample').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| const sampleText = btn.getAttribute('data-sample-text'); |
| playSample(sampleText); |
| }); |
| }); |
| |
| |
| elements.speedSlider.addEventListener('input', () => { |
| const value = elements.speedSlider.value; |
| elements.speedValue.textContent = value; |
| }); |
| |
| elements.pitchSlider.addEventListener('input', () => { |
| const value = elements.pitchSlider.value; |
| elements.pitchValue.textContent = value; |
| }); |
| |
| elements.volumeSlider.addEventListener('input', () => { |
| const value = elements.volumeSlider.value; |
| elements.volumeValue.textContent = value; |
| if (elements.audioPlayer) { |
| elements.audioPlayer.volume = value; |
| } |
| }); |
| |
| |
| elements.emotionBtns.forEach(btn => { |
| btn.addEventListener('click', () => { |
| state.emotion = btn.getAttribute('data-emotion'); |
| updateEmotionButtons(); |
| }); |
| }); |
| |
| |
| elements.generateBtn.addEventListener('click', generateAudio); |
| |
| |
| elements.playBtn.addEventListener('click', playAudio); |
| elements.pauseBtn.addEventListener('click', pauseAudio); |
| |
| |
| elements.audioPlayer.addEventListener('timeupdate', updateAudioProgress); |
| elements.audioPlayer.addEventListener('loadedmetadata', () => { |
| state.audioDuration = elements.audioPlayer.duration; |
| updateTimeDisplay(); |
| }); |
| elements.audioPlayer.addEventListener('ended', () => { |
| state.isPlaying = false; |
| updatePlayButtons(); |
| cancelAnimationFrame(state.animationFrameId); |
| }); |
| elements.audioPlayer.addEventListener('play', () => { |
| state.isPlaying = true; |
| updatePlayButtons(); |
| visualizeAudio(); |
| }); |
| elements.audioPlayer.addEventListener('pause', () => { |
| state.isPlaying = false; |
| updatePlayButtons(); |
| cancelAnimationFrame(state.animationFrameId); |
| }); |
| |
| |
| elements.downloadMp3.addEventListener('click', () => downloadAudio('mp3')); |
| elements.downloadWav.addEventListener('click', () => downloadAudio('wav')); |
| |
| |
| elements.clearText.addEventListener('click', () => { |
| elements.inputText.value = ''; |
| }); |
| |
| elements.sampleText.addEventListener('click', () => { |
| elements.inputText.value = `欢迎来到AI语音播客工作室! |
| |
| 这是一个示例文本,展示了系统如何处理不同情感的表达。 |
| |
| [快乐] 今天真是美好的一天!阳光明媚,心情愉悦! |
| [悲伤] 听到这个消息,我感到非常难过... |
| [愤怒] 这种行为简直不可接受!必须立即停止! |
| [兴奋] 哇!我简直不敢相信自己的眼睛!太棒了! |
| [平静] 深呼吸,放松身心,感受当下的宁静。 |
| |
| 您可以根据需要调整语音参数,创造出符合场景的语音效果。`; |
| }); |
| |
| |
| elements.clearHistory.addEventListener('click', clearHistory); |
| |
| |
| elements.toggleApi.addEventListener('click', toggleApiSettings); |
| elements.saveApi.addEventListener('click', saveApiSettings); |
| }; |
| |
| |
| const selectVoice = (voiceId) => { |
| state.voices.forEach(voice => { |
| voice.selected = voice.id === voiceId; |
| }); |
| state.currentVoice = state.voices.find(v => v.id === voiceId); |
| renderVoiceOptions(); |
| }; |
| |
| |
| const playSample = (text) => { |
| |
| |
| if ('speechSynthesis' in window) { |
| const utterance = new SpeechSynthesisUtterance(text); |
| const voices = window.speechSynthesis.getVoices(); |
| const voice = voices.find(v => v.name.includes(state.currentVoice.gender === 'male' ? 'Male' : 'Female')) || voices[0]; |
| |
| utterance.voice = voice; |
| utterance.rate = parseFloat(elements.speedSlider.value); |
| utterance.pitch = parseFloat(elements.pitchSlider.value); |
| |
| window.speechSynthesis.speak(utterance); |
| } else { |
| alert('您的浏览器不支持语音合成API,请使用Chrome或Edge浏览器。'); |
| } |
| }; |
| |
| |
| const updateEmotionButtons = () => { |
| elements.emotionBtns.forEach(btn => { |
| if (btn.getAttribute('data-emotion') === state.emotion) { |
| btn.classList.add('bg-indigo-100', 'border-indigo-400', 'text-indigo-800'); |
| btn.classList.remove('bg-gray-100', 'text-gray-800'); |
| } else { |
| btn.classList.remove('bg-indigo-100', 'border-indigo-400', 'text-indigo-800'); |
| btn.classList.add('bg-gray-100', 'text-gray-800'); |
| } |
| }); |
| }; |
| |
| |
| const generateAudio = async () => { |
| const text = elements.inputText.value.trim(); |
| if (!text) { |
| alert('请输入要转换的文本'); |
| return; |
| } |
| |
| |
| elements.generateBtn.disabled = true; |
| elements.generateBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>生成中...</span>'; |
| |
| try { |
| |
| await new Promise(resolve => setTimeout(resolve, 1500)); |
| |
| |
| |
| simulateTTSGeneration(text); |
| |
| |
| addToHistory(text); |
| } catch (error) { |
| console.error('生成语音出错:', error); |
| alert('生成语音时出错: ' + error.message); |
| } finally { |
| elements.generateBtn.disabled = false; |
| elements.generateBtn.innerHTML = '<i class="fas fa-magic"></i><span>生成语音</span>'; |
| } |
| }; |
| |
| |
| const simulateTTSGeneration = (text) => { |
| |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| const oscillator = audioContext.createOscillator(); |
| const gainNode = audioContext.createGain(); |
| |
| oscillator.type = 'sine'; |
| oscillator.frequency.value = state.currentVoice.gender === 'male' ? 120 : 220; |
| gainNode.gain.value = 0.5; |
| |
| oscillator.connect(gainNode); |
| gainNode.connect(audioContext.destination); |
| |
| |
| switch (state.emotion) { |
| case 'happy': |
| oscillator.frequency.setValueAtTime(state.currentVoice.gender === 'male' ? 140 : 260, audioContext.currentTime); |
| break; |
| case 'sad': |
| oscillator.frequency.setValueAtTime(state.currentVoice.gender === 'male' ? 100 : 180, audioContext.currentTime); |
| break; |
| case 'angry': |
| oscillator.type = 'square'; |
| oscillator.frequency.setValueAtTime(state.currentVoice.gender === 'male' ? 130 : 230, audioContext.currentTime); |
| break; |
| case 'excited': |
| oscillator.type = 'sawtooth'; |
| oscillator.frequency.setValueAtTime(state.currentVoice.gender === 'male' ? 150 : 280, audioContext.currentTime); |
| break; |
| case 'calm': |
| oscillator.type = 'sine'; |
| oscillator.frequency.setValueAtTime(state.currentVoice.gender === 'male' ? 110 : 200, audioContext.currentTime); |
| break; |
| } |
| |
| |
| const duration = Math.min(text.length * 0.1, 30); |
| |
| |
| const sampleRate = 44100; |
| const numChannels = 1; |
| const numFrames = sampleRate * duration; |
| const buffer = audioContext.createBuffer(numChannels, numFrames, sampleRate); |
| |
| |
| for (let channel = 0; channel < numChannels; channel++) { |
| const nowBuffering = buffer.getChannelData(channel); |
| for (let i = 0; i < numFrames; i++) { |
| const time = i / sampleRate; |
| const frequency = state.currentVoice.gender === 'male' ? 120 : 220; |
| nowBuffering[i] = Math.sin(2 * Math.PI * frequency * time) * 0.5; |
| } |
| } |
| |
| |
| const audioBlob = bufferToWav(buffer); |
| state.audioBlob = audioBlob; |
| |
| |
| const audioUrl = URL.createObjectURL(audioBlob); |
| elements.audioPlayer.src = audioUrl; |
| |
| |
| elements.playBtn.disabled = false; |
| elements.downloadMp3.disabled = false; |
| elements.downloadWav.disabled = false; |
| |
| |
| showNotification('语音生成成功!', 'success'); |
| }; |
| |
| |
| const bufferToWav = (buffer) => { |
| const numChannels = buffer.numberOfChannels; |
| const sampleRate = buffer.sampleRate; |
| const length = buffer.length; |
| |
| const interleaved = new Float32Array(length * numChannels); |
| for (let channel = 0; channel < numChannels; channel++) { |
| const channelData = buffer.getChannelData(channel); |
| for (let i = 0; i < length; i++) { |
| interleaved[i * numChannels + channel] = channelData[i]; |
| } |
| } |
| |
| const bufferLength = length * numChannels * 2; |
| const arrayBuffer = new ArrayBuffer(44 + bufferLength); |
| const view = new DataView(arrayBuffer); |
| |
| |
| writeString(view, 0, 'RIFF'); |
| view.setUint32(4, 36 + bufferLength, true); |
| writeString(view, 8, 'WAVE'); |
| writeString(view, 12, 'fmt '); |
| view.setUint32(16, 16, true); |
| view.setUint16(20, 1, true); |
| view.setUint16(22, numChannels, true); |
| view.setUint32(24, sampleRate, true); |
| view.setUint32(28, sampleRate * numChannels * 2, true); |
| view.setUint16(32, numChannels * 2, true); |
| view.setUint16(34, 16, true); |
| writeString(view, 36, 'data'); |
| view.setUint32(40, bufferLength, true); |
| |
| |
| const offset = 44; |
| for (let i = 0; i < interleaved.length; i++) { |
| const sample = Math.max(-1, Math.min(1, interleaved[i])); |
| view.setInt16(offset + i * 2, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true); |
| } |
| |
| return new Blob([view], { type: 'audio/wav' }); |
| }; |
| |
| const writeString = (view, offset, string) => { |
| for (let i = 0; i < string.length; i++) { |
| view.setUint8(offset + i, string.charCodeAt(i)); |
| } |
| }; |
| |
| |
| const playAudio = () => { |
| if (state.audioBlob) { |
| elements.audioPlayer.play(); |
| } |
| }; |
| |
| |
| const pauseAudio = () => { |
| elements.audioPlayer.pause(); |
| }; |
| |
| |
| const updatePlayButtons = () => { |
| elements.playBtn.disabled = !state.audioBlob || state.isPlaying; |
| elements.pauseBtn.disabled = !state.audioBlob || !state.isPlaying; |
| }; |
| |
| |
| const updateAudioProgress = () => { |
| state.currentAudioTime = elements.audioPlayer.currentTime; |
| const progress = (state.currentAudioTime / state.audioDuration) * 100; |
| elements.waveform.style.setProperty('--progress', `${progress}%`); |
| updateTimeDisplay(); |
| }; |
| |
| |
| const updateTimeDisplay = () => { |
| elements.currentTime.textContent = formatTime(state.currentAudioTime); |
| elements.totalTime.textContent = formatTime(state.audioDuration); |
| }; |
| |
| |
| const formatTime = (seconds) => { |
| const mins = Math.floor(seconds / 60); |
| const secs = Math.floor(seconds % 60); |
| return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; |
| }; |
| |
| |
| const visualizeAudio = () => { |
| if (!state.audioContext) { |
| setupAudioContext(); |
| } |
| |
| if (state.audioSource) { |
| state.audioSource.disconnect(); |
| } |
| |
| state.audioSource = state.audioContext.createMediaElementSource(elements.audioPlayer); |
| state.audioSource.connect(state.analyser); |
| state.analyser.connect(state.audioContext.destination); |
| |
| state.analyser.fftSize = 64; |
| const bufferLength = state.analyser.frequencyBinCount; |
| const dataArray = new Uint8Array(bufferLength); |
| |
| const draw = () => { |
| state.animationFrameId = requestAnimationFrame(draw); |
| state.analyser.getByteFrequencyData(dataArray); |
| |
| |
| const average = dataArray.reduce((a, b) => a + b) / bufferLength; |
| const progress = (elements.audioPlayer.currentTime / elements.audioPlayer.duration) * 100 || 0; |
| elements.waveform.style.setProperty('--progress', `${progress}%`); |
| }; |
| |
| draw(); |
| }; |
| |
| |
| const setupAudioContext = () => { |
| state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| state.analyser = state.audioContext.createAnalyser(); |
| }; |
| |
| |
| const downloadAudio = (format) => { |
| if (!state.audioBlob) return; |
| |
| let blob = state.audioBlob; |
| let extension = 'wav'; |
| let mimeType = 'audio/wav'; |
| |
| if (format === 'mp3') { |
| |
| |
| mimeType = 'audio/mpeg'; |
| extension = 'mp3'; |
| } |
| |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `ai-voice-${new Date().toISOString().slice(0, 10)}.${extension}`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }; |
| |
| |
| const addToHistory = (text) => { |
| const historyItem = { |
| id: Date.now(), |
| text: text.length > 50 ? text.substring(0, 50) + '...' : text, |
| fullText: text, |
| voice: state.currentVoice.name, |
| emotion: state.emotion, |
| date: new Date().toLocaleString(), |
| audioBlob: state.audioBlob |
| }; |
| |
| state.history.unshift(historyItem); |
| if (state.history.length > 10) { |
| state.history.pop(); |
| } |
| |
| saveHistory(); |
| renderHistory(); |
| }; |
| |
| |
| const renderHistory = () => { |
| if (state.history.length === 0) { |
| elements.historyList.innerHTML = ` |
| <div class="text-center text-gray-500 py-4"> |
| <i class="fas fa-history text-2xl mb-2"></i> |
| <p>暂无历史记录</p> |
| </div> |
| `; |
| return; |
| } |
| |
| elements.historyList.innerHTML = state.history.map(item => ` |
| <div class="history-item p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition cursor-pointer fade-in" data-id="${item.id}"> |
| <div class="flex justify-between items-start"> |
| <div class="flex-1 min-w-0"> |
| <h3 class="font-medium truncate">${item.text}</h3> |
| <div class="flex items-center text-xs text-gray-500 mt-1 space-x-2"> |
| <span><i class="fas fa-${item.voice.includes('男') ? 'male' : 'female'} mr-1"></i> ${item.voice}</span> |
| <span><i class="fas fa-heart mr-1"></i> ${getEmotionName(item.emotion)}</span> |
| <span>${item.date}</span> |
| </div> |
| </div> |
| <button class="play-history ml-2 text-indigo-600 hover:text-indigo-800" data-id="${item.id}"> |
| <i class="fas fa-play"></i> |
| </button> |
| </div> |
| </div> |
| `).join(''); |
| |
| |
| document.querySelectorAll('.history-item').forEach(el => { |
| el.addEventListener('click', (e) => { |
| const id = parseInt(el.getAttribute('data-id')); |
| const item = state.history.find(i => i.id === id); |
| |
| if (e.target.closest('.play-history')) { |
| e.stopPropagation(); |
| playHistoryItem(id); |
| } else { |
| loadHistoryItem(id); |
| } |
| }); |
| }); |
| }; |
| |
| |
| const getEmotionName = (emotion) => { |
| const emotions = { |
| neutral: '中性', |
| happy: '快乐', |
| sad: '悲伤', |
| angry: '愤怒', |
| excited: '兴奋', |
| calm: '平静' |
| }; |
| return emotions[emotion] || emotion; |
| }; |
| |
| |
| const playHistoryItem = (id) => { |
| const item = state.history.find(i => i.id === id); |
| if (item && item.audioBlob) { |
| state.audioBlob = item.audioBlob; |
| const audioUrl = URL.createObjectURL(item.audioBlob); |
| elements.audioPlayer.src = audioUrl; |
| elements.audioPlayer.play(); |
| |
| |
| elements.playBtn.disabled = false; |
| elements.downloadMp3.disabled = false; |
| elements.downloadWav.disabled = false; |
| } |
| }; |
| |
| |
| const loadHistoryItem = (id) => { |
| const item = state.history.find(i => i.id === id); |
| if (item) { |
| elements.inputText.value = item.fullText; |
| |
| |
| const voice = state.voices.find(v => v.name === item.voice); |
| if (voice) { |
| selectVoice(voice.id); |
| } |
| |
| state.emotion = item.emotion; |
| updateEmotionButtons(); |
| |
| |
| window.scrollTo({ top: 0, behavior: 'smooth' }); |
| } |
| }; |
| |
| |
| const clearHistory = () => { |
| if (confirm('确定要清空所有历史记录吗?此操作不可撤销。')) { |
| state.history = []; |
| saveHistory(); |
| renderHistory(); |
| } |
| }; |
| |
| |
| const saveHistory = () => { |
| |
| const historyToSave = state.history.map(item => ({ |
| id: item.id, |
| text: item.text, |
| fullText: item.fullText, |
| voice: item.voice, |
| emotion: item.emotion, |
| date: item.date |
| })); |
| |
| localStorage.setItem('ttsHistory', JSON.stringify(historyToSave)); |
| }; |
| |
| |
| const loadApiSettings = () => { |
| elements.ttsApi.value = state.apiSettings.ttsApi || ''; |
| elements.ttsModel.value = state.apiSettings.ttsModel || 'vits'; |
| elements.nlpApi.value = state.apiSettings.nlpApi || ''; |
| elements.nlpModel.value = state.apiSettings.nlpModel || 'bert'; |
| elements.apiKey.value = state.apiSettings.apiKey || ''; |
| elements.apiRegion.value = state.apiSettings.apiRegion || 'global'; |
| }; |
| |
| |
| const toggleApiSettings = () => { |
| elements.apiSettings.classList.toggle('hidden'); |
| const isHidden = elements.apiSettings.classList.contains('hidden'); |
| elements.toggleApi.innerHTML = isHidden |
| ? '<i class="fas fa-cog"></i> 高级设置' |
| : '<i class="fas fa-times"></i> 关闭设置'; |
| }; |
| |
| |
| const saveApiSettings = () => { |
| state.apiSettings = { |
| ttsApi: elements.ttsApi.value.trim(), |
| ttsModel: elements.ttsModel.value, |
| nlpApi: elements.nlpApi.value.trim(), |
| nlpModel: elements.nlpModel.value, |
| apiKey: elements.apiKey.value.trim(), |
| apiRegion: elements.apiRegion.value |
| }; |
| |
| localStorage.setItem('apiSettings', JSON.stringify(state.apiSettings)); |
| showNotification('API设置已保存', 'success'); |
| }; |
| |
| |
| const showNotification = (message, type) => { |
| const notification = document.createElement('div'); |
| notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg shadow-lg text-white ${ |
| type === 'success' ? 'bg-green-500' : 'bg-red-500' |
| } z-50 fade-in`; |
| notification.textContent = message; |
| document.body.appendChild(notification); |
| |
| setTimeout(() => { |
| notification.classList.remove('fade-in'); |
| notification.classList.add('fade-out'); |
| setTimeout(() => { |
| document.body.removeChild(notification); |
| }, 300); |
| }, 3000); |
| }; |
| |
| |
| return { |
| init |
| }; |
| })(); |
| |
| |
| document.addEventListener('DOMContentLoaded', App.init); |
| </script> |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=wwqg/ai" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> |
| </html> |