Spaces:
Paused
Paused
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>火山引擎语音合成</title> | |
| <style> | |
| :root { | |
| --primary-color: #007bff; | |
| --secondary-color: #6c757d; | |
| --background-color: #f0f2f5; | |
| --surface-color: #ffffff; | |
| --text-color: #212529; | |
| --border-color: #dee2e6; | |
| --error-color: #dc3545; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; | |
| background-color: var(--background-color); | |
| color: var(--text-color); | |
| margin: 0; | |
| padding: 2rem; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| box-sizing: border-box; | |
| } | |
| .container { | |
| width: 100%; | |
| max-width: 600px; | |
| background: var(--surface-color); | |
| padding: 2rem; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| } | |
| h1 { | |
| text-align: center; | |
| color: var(--primary-color); | |
| margin-bottom: 1.5rem; | |
| } | |
| .form-group { | |
| margin-bottom: 1.5rem; | |
| } | |
| .form-group.inline-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| .form-group .slider-container { | |
| flex-grow: 1; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| label { | |
| display: block; | |
| margin-bottom: 0.5rem; | |
| font-weight: bold; | |
| } | |
| input[type="text"], input[type="password"], textarea, select, input[type="number"] { | |
| width: 100%; | |
| padding: 0.75rem; | |
| border: 1px solid var(--border-color); | |
| border-radius: 4px; | |
| font-size: 1rem; | |
| box-sizing: border-box; | |
| transition: border-color 0.2s; | |
| } | |
| input[type="range"] { flex-grow: 1; } | |
| input:focus, textarea:focus, select:focus { | |
| outline: none; | |
| border-color: var(--primary-color); | |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); | |
| } | |
| textarea { height: 120px; resize: vertical; } | |
| .submit-btn { | |
| width: 100%; | |
| padding: 0.8rem; | |
| background-color: var(--primary-color); | |
| color: white; | |
| border: none; | |
| border-radius: 4px; | |
| font-size: 1.1rem; | |
| cursor: pointer; | |
| transition: background-color 0.2s; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .submit-btn:hover:not(:disabled) { background-color: #0056b3; } | |
| .submit-btn:disabled { | |
| background-color: var(--secondary-color); | |
| cursor: not-allowed; | |
| } | |
| .spinner { | |
| border: 4px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top: 4px solid #fff; | |
| width: 20px; | |
| height: 20px; | |
| animation: spin 1s linear infinite; | |
| margin-right: 10px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .hidden { display: none; } | |
| #audio-player-container { margin-top: 1.5rem; } | |
| audio { width: 100%; } | |
| #error-message { | |
| margin-top: 1rem; | |
| color: var(--error-color); | |
| background-color: #f8d7da; | |
| border: 1px solid #f5c6cb; | |
| padding: 1rem; | |
| border-radius: 4px; | |
| text-align: center; | |
| word-break: break-all; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>火山引擎语音合成</h1> | |
| <form id="tts-form"> | |
| <div class="form-group"> | |
| <label for="token-input">Access Token:</label> | |
| <input type="password" id="token-input" value="NYAUtOWZSO24yAVv9vN7RdaXc96eh7BH" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="appid-input">AppID:</label> | |
| <input type="text" id="appid-input" value="6135904441" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="text-input">输入文本:</label> | |
| <textarea id="text-input" placeholder="在这里输入想要转换为语音的文字..." required>字节跳动语音合成,让机器像人一样开口说话。</textarea> | |
| </div> | |
| <div class="form-group"> | |
| <label for="voice-select">选择音色:</label> | |
| <select id="voice-select" required> | |
| <option value="">正在加载音色列表...</option> | |
| </select> | |
| </div> | |
| <div class="form-group inline-label"> | |
| <label for="speed-slider">语速:</label> | |
| <div class="slider-container"> | |
| <input type="range" id="speed-slider" min="0.8" max="2" step="0.1" value="1.0"> | |
| <span id="speed-value">1.0</span> | |
| </div> | |
| </div> | |
| <div class="form-group inline-label"> | |
| <label for="loudness-slider">音量:</label> | |
| <div class="slider-container"> | |
| <input type="range" id="loudness-slider" min="0.5" max="2" step="0.1" value="1.0"> | |
| <span id="loudness-value">1.0</span> | |
| </div> | |
| </div> | |
| <div class="form-group inline-label"> | |
| <label for="silence-input">句末停顿(ms):</label> | |
| <input type="number" id="silence-input" min="0" max="30000" step="100" value="0"> | |
| </div> | |
| <div id="emotion-controls" class="hidden"> | |
| <div class="form-group"> | |
| <label style="display: inline-block; margin-right: 10px;">开启情感:</label> | |
| <input type="checkbox" id="enable-emotion-checkbox"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="emotion-select">选择情感:</label> | |
| <select id="emotion-select" disabled></select> | |
| </div> | |
| <div class="form-group inline-label"> | |
| <label for="emotion-scale-slider">情绪强度:</label> | |
| <div class="slider-container"> | |
| <input type="range" id="emotion-scale-slider" min="1" max="5" step="0.1" value="4.0" disabled> | |
| <span id="emotion-scale-value">4.0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <button type="submit" class="submit-btn" id="submit-button"> | |
| <span id="button-spinner" class="spinner hidden"></span> | |
| <span id="button-text">开始合成</span> | |
| </button> | |
| </form> | |
| <div id="audio-player-container" class="hidden"> | |
| <label>合成结果:</label> | |
| <audio id="audio-player" controls></audio> | |
| </div> | |
| <div id="error-message" class="hidden"></div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const voiceSelect = document.getElementById('voice-select'); | |
| const ttsForm = document.getElementById('tts-form'); | |
| const submitButton = document.getElementById('submit-button'); | |
| const buttonSpinner = document.getElementById('button-spinner'); | |
| const buttonText = document.getElementById('button-text'); | |
| const audioPlayerContainer = document.getElementById('audio-player-container'); | |
| const audioPlayer = document.getElementById('audio-player'); | |
| const errorMessageDiv = document.getElementById('error-message'); | |
| const speedSlider = document.getElementById('speed-slider'); | |
| const speedValue = document.getElementById('speed-value'); | |
| const loudnessSlider = document.getElementById('loudness-slider'); | |
| const loudnessValue = document.getElementById('loudness-value'); | |
| const emotionControls = document.getElementById('emotion-controls'); | |
| const enableEmotionCheckbox = document.getElementById('enable-emotion-checkbox'); | |
| const emotionSelect = document.getElementById('emotion-select'); | |
| const emotionScaleSlider = document.getElementById('emotion-scale-slider'); | |
| const emotionScaleValue = document.getElementById('emotion-scale-value'); | |
| speedSlider.oninput = () => { speedValue.textContent = speedSlider.value; }; | |
| loudnessSlider.oninput = () => { loudnessValue.textContent = loudnessSlider.value; }; | |
| emotionScaleSlider.oninput = () => { emotionScaleValue.textContent = emotionScaleSlider.value; }; | |
| let allVoiceData = {}; | |
| fetch('/api/voices') | |
| .then(response => { | |
| if (!response.ok) throw new Error('网络响应错误'); | |
| return response.json(); | |
| }) | |
| .then(voices => { | |
| // 后端返回的是一个有序数组,我们需要先转成map方便查找 | |
| voices.forEach(v => { allVoiceData[v.code] = v; }); | |
| populateVoiceSelector(voices); | |
| voiceSelect.dispatchEvent(new Event('change')); | |
| }) | |
| .catch(error => { | |
| showError('获取音色列表失败: ' + error.message); | |
| }); | |
| function populateVoiceSelector(voiceList) { | |
| voiceSelect.innerHTML = ''; | |
| const categories = {}; | |
| voiceList.forEach(voice => { | |
| if (!categories[voice.category]) { | |
| categories[voice.category] = []; | |
| } | |
| categories[voice.category].push({ code: voice.code, name: voice.displayName }); | |
| }); | |
| for (const categoryName in categories) { | |
| const optgroup = document.createElement('optgroup'); | |
| optgroup.label = categoryName; | |
| categories[categoryName].forEach(voice => { | |
| const option = document.createElement('option'); | |
| option.value = voice.code; | |
| option.textContent = voice.name; | |
| optgroup.appendChild(option); | |
| }); | |
| voiceSelect.appendChild(optgroup); | |
| } | |
| } | |
| voiceSelect.addEventListener('change', () => { | |
| const selectedVoiceCode = voiceSelect.value; | |
| if (!allVoiceData || !selectedVoiceCode) return; | |
| const voiceInfo = allVoiceData[selectedVoiceCode]; | |
| if (voiceInfo && voiceInfo.emotions && voiceInfo.emotions.length > 0) { | |
| emotionControls.classList.remove('hidden'); | |
| emotionSelect.innerHTML = ''; | |
| voiceInfo.emotions.forEach(emo => { | |
| const option = document.createElement('option'); | |
| option.value = emo; | |
| option.textContent = emo; | |
| emotionSelect.appendChild(option); | |
| }); | |
| enableEmotionCheckbox.dispatchEvent(new Event('change')); | |
| } else { | |
| emotionControls.classList.add('hidden'); | |
| enableEmotionCheckbox.checked = false; | |
| emotionSelect.disabled = true; | |
| emotionScaleSlider.disabled = true; | |
| } | |
| }); | |
| enableEmotionCheckbox.addEventListener('change', () => { | |
| const isEnabled = enableEmotionCheckbox.checked; | |
| emotionSelect.disabled = !isEnabled; | |
| emotionScaleSlider.disabled = !isEnabled; | |
| }); | |
| ttsForm.addEventListener('submit', async (event) => { | |
| event.preventDefault(); | |
| setLoading(true); | |
| hideError(); | |
| audioPlayerContainer.classList.add('hidden'); | |
| const requestBody = { | |
| token: document.getElementById('token-input').value, | |
| app_id: document.getElementById('appid-input').value, | |
| text: document.getElementById('text-input').value, | |
| voice: voiceSelect.value, | |
| speed_ratio: parseFloat(speedSlider.value), | |
| loudness_ratio: parseFloat(loudnessSlider.value), | |
| silence_duration: parseInt(document.getElementById('silence-input').value, 10), | |
| enable_emotion: enableEmotionCheckbox.checked, | |
| emotion: enableEmotionCheckbox.checked ? emotionSelect.value : "", | |
| emotion_scale: enableEmotionCheckbox.checked ? parseFloat(emotionScaleSlider.value) : 0, | |
| }; | |
| try { | |
| const response = await fetch('/api/synthesize', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(errorText || `合成失败,服务器状态码: ${response.status}`); | |
| } | |
| const audioBlob = await response.blob(); | |
| const audioUrl = URL.createObjectURL(audioBlob); | |
| audioPlayer.src = audioUrl; | |
| audioPlayerContainer.classList.remove('hidden'); | |
| audioPlayer.play(); | |
| } catch (error) { | |
| showError(error.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }); | |
| function setLoading(isLoading) { | |
| submitButton.disabled = isLoading; | |
| buttonSpinner.classList.toggle('hidden', !isLoading); | |
| buttonText.textContent = isLoading ? '正在合成...' : '开始合成'; | |
| } | |
| function showError(message) { | |
| errorMessageDiv.textContent = message; | |
| errorMessageDiv.classList.remove('hidden'); | |
| } | |
| function hideError() { | |
| errorMessageDiv.classList.add('hidden'); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |