Download index.html from wwqg/ai: direct link, hf CLI and curl.
- Browser
- Download file 50.3 kB
-
https://huggingface.co/spaces/wwqg/ai/resolve/add9eddaa48002516fb08ef3010ad60212690ddb/index.html
- Command line
-
hf download hf://spaces/wwqg/ai@add9eddaa48002516fb08ef3010ad60212690ddb/index.html
-
curl -L -o index.html https://huggingface.co/spaces/wwqg/ai/resolve/add9eddaa48002516fb08ef3010ad60212690ddb/index.html
50.3 kB
| <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> | |
| /* 自定义CSS补充Tailwind */ | |
| .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"> | |
| <!-- 语音选项将通过JS动态生成 --> | |
| </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> | |
| <!-- API设置 --> | |
| <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> | |
| <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="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="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 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"> | |
| <!-- 历史记录项将通过JS动态生成 --> | |
| <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: '', | |
| nlpApi: '', | |
| apiKey: '' | |
| }, | |
| isPlaying: false, | |
| audioDuration: 0, | |
| currentAudioTime: 0, | |
| audioContext: null, | |
| analyser: null, | |
| audioSource: null, | |
| animationFrameId: null | |
| }; | |
| // DOM元素 | |
| 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); | |
| // API设置 | |
| 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) => { | |
| // 在实际应用中,这里会调用TTS API生成样本语音 | |
| // 这里使用Web Speech API作为演示 | |
| 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 { | |
| // 模拟API调用延迟 | |
| await new Promise(resolve => setTimeout(resolve, 1500)); | |
| // 在实际应用中,这里会调用TTS API | |
| // 这里使用模拟的音频数据作为演示 | |
| 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>'; | |
| } | |
| }; | |
| // 模拟TTS生成 | |
| const simulateTTSGeneration = (text) => { | |
| // 创建一个模拟的音频Blob | |
| 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; | |
| } | |
| // 模拟音频持续时间 (0.1秒每字符) | |
| const duration = Math.min(text.length * 0.1, 30); // 最长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; | |
| } | |
| } | |
| // 转换为Blob | |
| 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'); | |
| }; | |
| // 将AudioBuffer转换为WAV Blob | |
| 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); | |
| // WAV头部 | |
| 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); // PCM格式 | |
| 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') { | |
| // 在实际应用中,这里会将WAV转换为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 = () => { | |
| // 只保存必要信息,Blob太大不保存 | |
| 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)); | |
| }; | |
| // 加载API设置 | |
| const loadApiSettings = () => { | |
| elements.ttsApi.value = state.apiSettings.ttsApi || ''; | |
| elements.nlpApi.value = state.apiSettings.nlpApi || ''; | |
| elements.apiKey.value = state.apiSettings.apiKey || ''; | |
| }; | |
| // 切换API设置显示 | |
| 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> 关闭设置'; | |
| }; | |
| // 保存API设置 | |
| const saveApiSettings = () => { | |
| state.apiSettings = { | |
| ttsApi: elements.ttsApi.value.trim(), | |
| nlpApi: elements.nlpApi.value.trim(), | |
| apiKey: elements.apiKey.value.trim() | |
| }; | |
| 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> |