Spaces:
Running
Running
{% extends "base.html" %} | |
{% block title %}Arena - Voice Clone Arena{% endblock %} | |
{% block current_page %}Arena{% endblock %} | |
{% block content %} | |
<div class="tabs"> | |
<div class="tab active" data-tab="tts">TTS</div> | |
</div> | |
<div id="tts-tab" class="tab-content active"> | |
<form class="input-container"> | |
<div class="input-group"> | |
<button type="button" class="segmented-btn random-voice-btn" title="Roll random voice"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" | |
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" | |
class="lucide lucide-shuffle-icon lucide-shuffle"> | |
<path d="m18 14 4 4-4 4"/> | |
<path d="m18 2 4 4-4 4"/> | |
<path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/> | |
<path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/> | |
<path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/> | |
</svg> | |
</button> | |
<input type="file" id="voice-file" accept="audio/*"> | |
<audio id="voice-preview" controls style="display:none;"></audio> | |
</div> | |
<hr> | |
<div id="random-voice-loading-msg" style="display:none;color:#6658ea;font-size:14px;margin-bottom:8px;"> | |
Loading random prompt audio... | |
</div> | |
<div class="input-group"> | |
<button type="button" class="segmented-btn random-btn" title="Roll random text"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" | |
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" | |
class="lucide lucide-shuffle-icon lucide-shuffle"> | |
<path d="m18 14 4 4-4 4"/> | |
<path d="m18 2 4 4-4 4"/> | |
<path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/> | |
<path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/> | |
<path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/> | |
</svg> | |
</button> | |
<input type="text" class="text-input" placeholder="Enter text to synthesize..."> | |
<button type="submit" class="segmented-btn synth-btn">Synthesize</button> | |
</div> | |
<button type="submit" class="mobile-synth-btn">Synthesize</button> | |
</form> | |
<div id="initial-keyboard-hint" class="keyboard-hint"> | |
Press <kbd>R</kbd> for random text, <kbd>V</kbd> for random reference voice, <kbd>N</kbd> for next random | |
round, <kbd>Enter</kbd> to generate | |
</div> | |
<div class="loading-container" style="display: none;"> | |
<div class="loader-wrapper"> | |
<div class="loader-animation"> | |
<div class="sound-wave"> | |
<span></span> | |
<span></span> | |
<span></span> | |
<span></span> | |
<span></span> | |
<span></span> | |
</div> | |
</div> | |
<div class="loader-text">Generating audio samples...</div> | |
<div class="loader-subtext">This may take up to 30 seconds</div> | |
</div> | |
</div> | |
<div class="players-container" style="display: none;"> | |
<div class="players-row"> | |
<div class="player"> | |
<div class="player-label">Model A <span class="model-name-display"></span></div> | |
<div class="wave-player-container" data-model="a"></div> | |
<button class="vote-btn" data-model="a" disabled> | |
Vote for A | |
<span class="shortcut-key">A</span> | |
<span class="vote-loader" style="display: none;"> | |
<div class="vote-spinner"></div> | |
</span> | |
</button> | |
</div> | |
<div class="player"> | |
<div class="player-label">Model B <span class="model-name-display"></span></div> | |
<div class="wave-player-container" data-model="b"></div> | |
<button class="vote-btn" data-model="b" disabled> | |
Vote for B | |
<span class="shortcut-key">B</span> | |
<span class="vote-loader" style="display: none;"> | |
<div class="vote-spinner"></div> | |
</span> | |
</button> | |
</div> | |
</div> | |
</div> | |
<div class="vote-results" style="display: none;"> | |
<h3 class="results-heading">Vote Recorded!</h3> | |
<div class="results-content"> | |
<div class="chosen-model"> | |
<strong>You chose:</strong> <span class="chosen-model-name"></span> | |
</div> | |
<div class="rejected-model"> | |
<strong>Over:</strong> <span class="rejected-model-name"></span> | |
</div> | |
</div> | |
</div> | |
<div class="next-round-container" style="display: none;"> | |
<button class="next-round-btn">Next Round</button> | |
</div> | |
<div id="playback-keyboard-hint" class="keyboard-hint" style="display: none;"> | |
Press <kbd>Space</kbd> to play/pause, <kbd>A</kbd>/<kbd>B</kbd> to vote, <kbd>R</kbd> for random text, <kbd>V</kbd> | |
for random reference voice, <kbd>N</kbd> | |
for next random round | |
</div> | |
</div> | |
{% endblock %} | |
{% block extra_head %} | |
<link rel="stylesheet" href="{{ url_for('static', filename='css/waveplayer.css') }}"> | |
<script src="https://unpkg.com/wavesurfer.js@6/dist/wavesurfer.min.js"></script> | |
<style> | |
.input-container { | |
display: flex; | |
flex-direction: column; | |
margin-bottom: 24px; | |
} | |
.input-group { | |
display: flex; | |
width: 100%; | |
border-radius: var(--radius); | |
border: 1px solid var(--border-color); | |
overflow: hidden; | |
} | |
.voice-input-container { | |
display: flex; | |
align-items: center; | |
margin-top: 8px; | |
position: relative; | |
} | |
.random-voice-btn { | |
height: 36px; | |
width: 48px; | |
border: 1px solid var(--border-color); | |
border-radius: var(--radius); | |
margin-right: 10px; | |
flex-shrink: 0; | |
} | |
.random-voice-btn svg { | |
color: var(--primary-color); | |
} | |
/* 保持音频控件在按钮旁边显示 */ | |
#voice-preview { | |
margin-left: 10px; | |
} | |
/* Override base styles to remove duplicate borders */ | |
.input-group .text-input { | |
flex: 1; | |
padding: 12px 16px; | |
border: none; | |
border-radius: 0; | |
font-size: 16px; | |
outline: none; | |
height: 48px; | |
transition: none; | |
} | |
.input-group .text-input:focus { | |
border: none; | |
outline: none; | |
background-color: rgba(80, 70, 229, 0.03); | |
} | |
.segmented-btn { | |
background-color: white; | |
border: none; | |
height: 48px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
cursor: pointer; | |
transition: background-color 0.2s; | |
} | |
.random-btn { | |
width: 48px; | |
border-right: 1px solid var(--border-color); | |
} | |
.random-btn svg { | |
color: var(--primary-color); | |
} | |
.synth-btn { | |
padding: 0 24px; | |
font-weight: 500; | |
border-left: 1px solid var(--border-color); | |
background-color: var(--primary-color); | |
color: white; | |
font-size: 1em; | |
} | |
.synth-btn:hover { | |
background-color: #4038c7; | |
} | |
.random-btn:hover { | |
background-color: var(--light-gray); | |
} | |
.mobile-synth-btn { | |
display: none; | |
width: 100%; | |
padding: 12px; | |
margin-top: 12px; | |
background-color: var(--primary-color); | |
color: white; | |
border: none; | |
border-radius: var(--radius); | |
font-weight: 500; | |
cursor: pointer; | |
font-size: 1em; | |
} | |
.loading-container { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
margin: 40px 0; | |
} | |
.loader-wrapper { | |
text-align: center; | |
} | |
.loader-animation { | |
margin-bottom: 24px; | |
} | |
.loader-text { | |
font-size: 18px; | |
font-weight: 600; | |
margin-bottom: 8px; | |
color: var(--text-color); | |
} | |
.loader-subtext { | |
font-size: 14px; | |
color: #666; | |
} | |
.sound-wave { | |
height: 60px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
gap: 8px; | |
} | |
.sound-wave span { | |
display: block; | |
width: 6px; | |
height: 20px; | |
background-color: var(--primary-color); | |
border-radius: 8px; | |
animation: sound-wave-animation 1.2s infinite ease-in-out; | |
} | |
.sound-wave span:nth-child(2) { | |
animation-delay: 0.2s; | |
} | |
.sound-wave span:nth-child(3) { | |
animation-delay: 0.4s; | |
} | |
.sound-wave span:nth-child(4) { | |
animation-delay: 0.6s; | |
} | |
.sound-wave span:nth-child(5) { | |
animation-delay: 0.8s; | |
} | |
.sound-wave span:nth-child(6) { | |
animation-delay: 1s; | |
} | |
@keyframes sound-wave-animation { | |
0%, 100% { | |
height: 20px; | |
} | |
50% { | |
height: 50px; | |
} | |
} | |
.vote-btn { | |
position: relative; | |
color: black; | |
font-size: 1rem; | |
} | |
.vote-btn.selected { | |
background-color: var(--primary-color); | |
color: white; | |
} | |
.vote-btn:disabled { | |
opacity: 0.7; | |
cursor: not-allowed; | |
} | |
.vote-loader { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
background-color: rgba(255, 255, 255, 0.8); | |
} | |
.vote-spinner { | |
width: 20px; | |
height: 20px; | |
border: 2px solid rgba(80, 70, 229, 0.3); | |
border-radius: 50%; | |
border-top-color: var(--primary-color); | |
animation: spin 1s linear infinite; | |
} | |
.next-round-container { | |
margin-top: 24px; | |
text-align: center; | |
} | |
.next-round-btn { | |
padding: 12px 24px; | |
background-color: var(--primary-color); | |
color: white; | |
border: none; | |
border-radius: var(--radius); | |
font-weight: 500; | |
cursor: pointer; | |
position: relative; | |
width: 100%; | |
font-size: 1rem; | |
transition: background-color 0.2s; | |
} | |
.next-round-btn:hover { | |
background-color: #4038c7; | |
} | |
/* Vote results styling */ | |
.vote-results { | |
background-color: #f0f4ff; | |
border: 1px solid #d0d7f7; | |
border-radius: var(--radius); | |
padding: 16px; | |
margin: 24px 0; | |
} | |
.results-heading { | |
color: var(--primary-color); | |
margin-bottom: 12px; | |
font-size: 18px; | |
} | |
.results-content { | |
display: flex; | |
flex-direction: column; | |
gap: 8px; | |
} | |
@keyframes spin { | |
to { | |
transform: rotate(360deg); | |
} | |
} | |
/* Tab styling */ | |
.tabs { | |
display: flex; | |
border-bottom: 1px solid var(--border-color); | |
margin-bottom: 24px; | |
} | |
.tab { | |
padding: 12px 24px; | |
cursor: pointer; | |
position: relative; | |
font-weight: 500; | |
} | |
.tab.active { | |
color: var(--primary-color); | |
} | |
.tab.active::after { | |
content: ''; | |
position: absolute; | |
bottom: -1px; | |
left: 0; | |
width: 100%; | |
height: 2px; | |
background-color: var(--primary-color); | |
} | |
.tab-content { | |
display: none; | |
} | |
.tab-content.active { | |
display: block; | |
} | |
.model-name-display { | |
font-size: 0.9em; | |
color: #666; | |
font-style: italic; | |
} | |
/* WaveSurfer Custom Styles */ | |
.player { | |
padding-bottom: 20px; | |
} | |
.wave-player-container { | |
margin-bottom: 16px; | |
} | |
/* Keyboard shortcut hint */ | |
.keyboard-hint { | |
text-align: center; | |
margin-top: 8px; | |
font-size: 13px; | |
color: #888; | |
} | |
.keyboard-hint kbd { | |
display: inline-block; | |
padding: 3px 5px; | |
font-size: 11px; | |
line-height: 10px; | |
color: #444; | |
vertical-align: middle; | |
background-color: #fafafa; | |
border: 1px solid #ccc; | |
border-radius: 3px; | |
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); | |
margin: 0 2px; | |
} | |
@media (max-width: 768px) { | |
.input-group { | |
border-radius: var(--radius); | |
} | |
.synth-btn { | |
display: none; | |
} | |
.mobile-synth-btn { | |
display: block; | |
} | |
/* Stack players vertically on mobile */ | |
.players-row { | |
flex-direction: column; | |
gap: 16px; | |
} | |
} | |
/* Dark mode styles */ | |
@media (prefers-color-scheme: dark) { | |
.coming-soon-container { | |
background-color: var(--light-gray); | |
} | |
.coming-soon-text { | |
color: #aaa; | |
} | |
.model-name-display { | |
color: #aaa; | |
} | |
/* Fix vote recorded section in dark mode */ | |
.vote-results { | |
background-color: var(--light-gray); | |
border-color: var(--border-color); | |
} | |
.results-heading { | |
color: var(--primary-color); | |
} | |
.results-content { | |
color: var(--text-color); | |
} | |
.chosen-model, | |
.rejected-model { | |
color: var(--text-color); | |
} | |
.chosen-model strong, | |
.rejected-model strong { | |
color: var(--text-color); | |
} | |
.chosen-model-name, | |
.rejected-model-name { | |
color: var(--text-color); | |
} | |
.vote-btn { | |
background-color: var(--light-gray); | |
color: var(--text-color); | |
border-color: var(--border-color); | |
} | |
.vote-btn:hover { | |
background-color: rgba(255, 255, 255, 0.1); | |
border-color: var(--border-color); | |
} | |
.vote-btn.selected { | |
background-color: var(--primary-color); | |
color: white; | |
border-color: var(--primary-color); | |
} | |
.shortcut-key { | |
background-color: rgba(255, 255, 255, 0.1); | |
color: var(--text-color); | |
border-color: var(--border-color); | |
} | |
.vote-btn.selected .shortcut-key { | |
background-color: rgba(255, 255, 255, 0.2); | |
color: white; | |
border-color: transparent; | |
} | |
.random-btn { | |
background-color: var(--light-gray); | |
color: var(--text-color); | |
border-color: var(--border-color); | |
} | |
.random-btn:hover { | |
background-color: rgba(255, 255, 255, 0.1); | |
} | |
.vote-recorded { | |
background-color: var(--light-gray); | |
border-color: var(--border-color); | |
} | |
/* Ensure border-radius is maintained during loading state */ | |
.vote-btn.loading { | |
border-radius: var(--radius); | |
} | |
/* Dark mode keyboard hint */ | |
.keyboard-hint { | |
color: #aaa; | |
} | |
.keyboard-hint kbd { | |
color: #ddd; | |
background-color: #333; | |
border-color: #555; | |
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.1); | |
} | |
} | |
.random-voice-btn.loading svg { | |
animation: spin 1s linear infinite; | |
} | |
@keyframes spin { | |
0% { | |
transform: rotate(0deg); | |
} | |
100% { | |
transform: rotate(360deg); | |
} | |
} | |
/* Stack podcast players vertically on mobile */ | |
.podcast-player-container .players-row { | |
flex-direction: column; | |
gap: 16px; | |
} | |
/* Dark mode adjustments for mobile */ | |
@media (prefers-color-scheme: dark) { | |
.remove-line-btn { | |
background-color: rgba(50, 50, 60, 0.7); | |
} | |
} | |
</style> | |
{% endblock %} | |
{% block extra_scripts %} | |
<script src="{{ url_for('static', filename='js/waveplayer.js') }}"></script> | |
<script> | |
document.addEventListener('DOMContentLoaded', function () { | |
// Reference voice preview function | |
const randomVoiceBtn = document.querySelector('.random-voice-btn'); | |
const voiceFileInput = document.getElementById('voice-file'); | |
const voicePreview = document.getElementById('voice-preview'); | |
if (randomVoiceBtn && voiceFileInput && voicePreview) { | |
randomVoiceBtn.addEventListener('click', handleRandomVoice); | |
voiceFileInput.addEventListener('change', function () { | |
const file = this.files[0]; | |
if (file) { | |
const url = URL.createObjectURL(file); | |
voicePreview.src = url; | |
voicePreview.style.display = 'inline-block'; | |
voicePreview.load(); | |
} else { | |
voicePreview.src = ''; | |
voicePreview.style.display = 'none'; | |
} | |
}); | |
} | |
const synthForm = document.querySelector('.input-container'); | |
const synthBtn = document.querySelector('.synth-btn'); | |
const mobileSynthBtn = document.querySelector('.mobile-synth-btn'); | |
const loadingContainer = document.querySelector('.loading-container'); | |
const playersContainer = document.querySelector('.players-container'); | |
const voteButtons = document.querySelectorAll('.vote-btn'); | |
const textInput = document.querySelector('.text-input'); | |
const nextRoundBtn = document.querySelector('.next-round-btn'); | |
const nextRoundContainer = document.querySelector('.next-round-container'); | |
const randomBtn = document.querySelector('.random-btn'); | |
const tabs = document.querySelectorAll('.tab'); | |
const tabContents = document.querySelectorAll('.tab-content'); | |
const voteResultsContainer = document.querySelector('.vote-results'); | |
const chosenModelNameElement = document.querySelector('.chosen-model-name'); | |
const rejectedModelNameElement = document.querySelector('.rejected-model-name'); | |
const modelNameDisplays = document.querySelectorAll('.model-name-display'); | |
const wavePlayerContainers = document.querySelectorAll('.wave-player-container'); | |
// Get references to the keyboard hint elements | |
const initialKeyboardHint = document.getElementById('initial-keyboard-hint'); | |
const playbackKeyboardHint = document.getElementById('playback-keyboard-hint'); | |
let bothSamplesPlayed = false; | |
let currentSessionId = null; | |
let modelNames = {a: '', b: ''}; | |
let wavePlayers = {a: null, b: null}; | |
let cachedSentences = []; // To store sentences available in cache | |
let hasVoted = false; // Prevent duplicate voting | |
// Initialize WavePlayers with mobile settings | |
wavePlayerContainers.forEach(container => { | |
const model = container.dataset.model; | |
wavePlayers[model] = new WavePlayer(container, { | |
// Add mobile-friendly options but hide native controls | |
backend: 'MediaElement', | |
mediaControls: false // Hide native audio controls | |
}); | |
}); | |
// Load fallback sentences directly from Flask variable (JSON string) | |
// Assign to a variable first to help linters | |
// eslint-disable-next-line | |
const fallbackSentencesJson = {{ harvard_sentences | tojson | safe }}; | |
const fallbackRandomTexts = JSON.parse(fallbackSentencesJson); | |
// Fetch cached sentences on load | |
function fetchCachedSentences() { | |
fetch('/api/tts/cached-sentences') | |
.then(response => response.ok ? response.json() : Promise.reject('Failed to fetch cached sentences')) | |
.then( data => { | |
cachedSentences = data; | |
console.log(`Fetched ${cachedSentences.length} cached sentences.`); | |
}) | |
.catch(error => { | |
console.error('Error fetching cached sentences:', error); | |
// Keep cachedSentences as empty array, fallback will be used | |
}); | |
} | |
// Check URL hash for direct tab access | |
function checkHashAndSetTab() { | |
const hash = window.location.hash.toLowerCase(); | |
if (hash === '#conversational') { | |
// Switch to conversational tab | |
tabs.forEach(t => t.classList.remove('active')); | |
tabContents.forEach(c => c.classList.remove('active')); | |
document.querySelector('.tab[data-tab="conversational"]').classList.add('active'); | |
document.getElementById('conversational-tab').classList.add('active'); | |
} else if (hash === '#tts') { | |
// Switch to TTS tab (explicit) | |
tabs.forEach(t => t.classList.remove('active')); | |
tabContents.forEach(c => c.classList.remove('active')); | |
document.querySelector('.tab[data-tab="tts"]').classList.add('active'); | |
document.getElementById('tts-tab').classList.add('active'); | |
} | |
} | |
// Check hash on page load | |
checkHashAndSetTab(); | |
// Listen for hash changes | |
window.addEventListener('hashchange', checkHashAndSetTab); | |
// Tab switching functionality | |
tabs.forEach(tab => { | |
tab.addEventListener('click', function () { | |
const tabId = this.dataset.tab; | |
// Update URL hash without page reload | |
history.replaceState(null, null, `#${tabId}`); | |
// Remove active class from all tabs and contents | |
tabs.forEach(t => t.classList.remove('active')); | |
tabContents.forEach(c => c.classList.remove('active')); | |
// Add active class to clicked tab and corresponding content | |
this.classList.add('active'); | |
document.getElementById(`${tabId}-tab`).classList.add('active'); | |
// Reset TTS tab state if switching away from it | |
if (tabId !== 'tts') { | |
resetToInitialState(); | |
} | |
}); | |
}); | |
function handleSynthesize(e) { | |
if (e) { | |
e.preventDefault(); | |
} | |
const text = textInput.value.trim(); | |
if (!text) { | |
openToast("Please enter some text to synthesize", "warning"); | |
return; | |
} | |
if (text.length > 1000) { | |
openToast("Text is too long. Please keep it under 1000 characters.", "warning"); | |
return; | |
} | |
textInput.blur(); | |
// Show loading animation and hide hints | |
loadingContainer.style.display = 'flex'; | |
playersContainer.style.display = 'none'; | |
voteResultsContainer.style.display = 'none'; | |
nextRoundContainer.style.display = 'none'; | |
initialKeyboardHint.style.display = 'none'; | |
playbackKeyboardHint.style.display = 'none'; | |
// Reset vote buttons | |
voteButtons.forEach(btn => { | |
btn.disabled = true; | |
btn.classList.remove('selected'); | |
btn.querySelector('.vote-loader').style.display = 'none'; | |
}); | |
// Clear model name displays | |
modelNameDisplays.forEach(display => { | |
display.textContent = ''; | |
}); | |
// Reset the flag for both samples played | |
bothSamplesPlayed = false; | |
// 新增:处理参���音色文件上传 | |
const voiceFileInput = document.getElementById('voice-file'); | |
const file = voiceFileInput.files[0]; | |
let fetchOptions; | |
if (file) { | |
const formData = new FormData(); | |
formData.append('text', text); | |
formData.append('voice_file', file); | |
fetchOptions = { | |
method: 'POST', | |
body: formData | |
}; | |
} else { | |
fetchOptions = { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({text: text}), | |
}; | |
} | |
// Call the API to generate TTS | |
fetch('/api/tts/generate', fetchOptions) | |
.then(response => { | |
if (!response.ok) { | |
return response.json().then(err => { | |
throw new Error(err.error || 'Failed to generate TTS'); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
currentSessionId = data.session_id; | |
// Load audio in waveplayers | |
wavePlayers.a.loadAudio(data.audio_a); | |
wavePlayers.b.loadAudio(data.audio_b); | |
// Show players and playback hint, hide initial hint | |
loadingContainer.style.display = 'none'; | |
playersContainer.style.display = 'flex'; | |
initialKeyboardHint.style.display = 'none'; | |
playbackKeyboardHint.style.display = 'block'; | |
// Setup automatic sequential playback | |
wavePlayers.a.wavesurfer.once('ready', function () { | |
wavePlayers.a.play(); | |
// When audio A ends, play audio B | |
wavePlayers.a.wavesurfer.once('finish', function () { | |
// Wait a short moment before playing B | |
setTimeout(() => { | |
wavePlayers.b.play(); | |
// When audio B ends, enable voting | |
wavePlayers.b.wavesurfer.once('finish', function () { | |
bothSamplesPlayed = true; | |
voteButtons.forEach(btn => { | |
btn.disabled = false; | |
}); | |
}); | |
}, 500); | |
}); | |
}); | |
// Fetch cached sentences again to update the list | |
fetchCachedSentences(); | |
}) | |
.catch(error => { | |
loadingContainer.style.display = 'none'; | |
openToast(error.message, "error"); | |
console.error('Error:', error); | |
}); | |
} | |
function handleVote(model) { | |
if (hasVoted) { | |
openToast("You have already voted. Duplicate voting is not allowed.", "warning"); | |
return; | |
} | |
hasVoted = true; | |
// Disable both vote buttons | |
voteButtons.forEach(btn => { | |
btn.disabled = true; | |
if (btn.dataset.model === model) { | |
btn.querySelector('.vote-loader').style.display = 'flex'; | |
} | |
}); | |
// Send vote to server | |
fetch('/api/tts/vote', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
session_id: currentSessionId, | |
chosen_model: model | |
}), | |
}) | |
.then(response => { | |
if (!response.ok) { | |
hasVoted = false; // allow retry | |
return response.json().then(err => { | |
throw new Error(err.error || 'Vote failed, please try again later.'); | |
}); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
// Hide loaders | |
voteButtons.forEach(btn => { | |
btn.querySelector('.vote-loader').style.display = 'none'; | |
// Highlight the selected button | |
if (btn.dataset.model === model) { | |
btn.classList.add('selected'); | |
} | |
}); | |
// Store model names from vote response | |
if (data.chosen_model && data.chosen_model.name) { | |
modelNames.a = data.names.a; | |
modelNames.b = data.names.b; | |
} | |
// Now display model names after voting | |
modelNameDisplays[0].textContent = modelNames.a ? `(${modelNames.a})` : ''; | |
modelNameDisplays[1].textContent = modelNames.b ? `(${modelNames.b})` : ''; | |
// Show vote results | |
chosenModelNameElement.textContent = data.chosen_model.name; | |
rejectedModelNameElement.textContent = data.rejected_model.name; | |
voteResultsContainer.style.display = 'block'; | |
// Show next round button | |
nextRoundContainer.style.display = 'block'; | |
// Show success toast | |
openToast("Vote successful!", "success"); | |
}) | |
.catch(error => { | |
hasVoted = false; | |
// Re-enable vote buttons | |
voteButtons.forEach(btn => { | |
btn.disabled = false; | |
btn.querySelector('.vote-loader').style.display = 'none'; | |
}); | |
openToast(error.message, "error"); | |
console.error('Error:', error); | |
}); | |
} | |
function resetToInitialState() { | |
// Hide players, results, and next round button | |
playersContainer.style.display = 'none'; | |
voteResultsContainer.style.display = 'none'; | |
nextRoundContainer.style.display = 'none'; | |
// Reset vote buttons | |
voteButtons.forEach(btn => { | |
btn.disabled = true; | |
btn.classList.remove('selected'); | |
btn.querySelector('.vote-loader').style.display = 'none'; | |
}); | |
// Clear model name displays | |
modelNameDisplays.forEach(display => { | |
display.textContent = ''; | |
}); | |
// Reset model names | |
modelNames = {a: '', b: ''}; | |
// Clear text input | |
textInput.value = ''; | |
// Stop any playing audio and destroy wavesurfers | |
for (const model in wavePlayers) { | |
if (wavePlayers[model]) { | |
wavePlayers[model].stop(); | |
} | |
} | |
// Reset session | |
currentSessionId = null; | |
// Reset the flag for both samples played | |
bothSamplesPlayed = false; | |
// Show initial hint, hide playback hint | |
initialKeyboardHint.style.display = 'block'; | |
playbackKeyboardHint.style.display = 'none'; | |
hasVoted = false; | |
// 自动加载随机音频 | |
handleRandom(); | |
handleRandomVoice(); | |
} | |
function handleRandom() { | |
let selectedText = ''; | |
if (cachedSentences && cachedSentences.length > 0) { | |
// Select a random text from the cache | |
selectedText = cachedSentences[Math.floor(Math.random() * cachedSentences.length)]; | |
console.log("Using random sentence from cache."); | |
} else { | |
// Fallback to the initial list if cache is empty or failed to load | |
console.log("Cache empty or unavailable, using random sentence from fallback list."); | |
if (fallbackRandomTexts && fallbackRandomTexts.length > 0) { | |
selectedText = fallbackRandomTexts[Math.floor(Math.random() * fallbackRandomTexts.length)]; | |
} else { | |
// If fallback list is also empty, do nothing. Log an error. | |
console.error("Both cached sentences and fallback sentences are unavailable."); | |
return; | |
} | |
} | |
textInput.value = selectedText; | |
textInput.focus(); | |
} | |
function handleRandomVoice() { | |
const randomVoiceBtn = document.querySelector('.random-voice-btn'); | |
const voiceFileInput = document.getElementById('voice-file'); | |
const voicePreview = document.getElementById('voice-preview'); | |
const loadingMsg = document.getElementById('random-voice-loading-msg'); | |
// 显示加载提示 | |
if (loadingMsg) loadingMsg.style.display = 'block'; | |
// 显示加载状态 | |
randomVoiceBtn.classList.add('loading'); | |
// 获取随机参考音色 | |
fetch('/api/voice/random') | |
.then(response => { | |
if (!response.ok) { | |
throw new Error('获取随机音色失败'); | |
} | |
return response.blob(); | |
}) | |
.then(audioBlob => { | |
// 创建文件对象,用于合成时提交 | |
const fileName = 'random_voice_sample.' + | |
(audioBlob.type.split('/')[1] || 'mp3'); | |
const audioFile = new File([audioBlob], fileName, {type: audioBlob.type}); | |
const dataTransfer = new DataTransfer(); | |
dataTransfer.items.add(audioFile); | |
voiceFileInput.files = dataTransfer.files; | |
// 更新音频预览 | |
const audioUrl = URL.createObjectURL(audioBlob); | |
voicePreview.src = audioUrl; | |
voicePreview.style.display = 'inline-block'; | |
voicePreview.load(); | |
voicePreview.play(); | |
// 触发change事件,确保其他监听器知道文件已更改 | |
const event = new Event('change', {bubbles: true}); | |
voiceFileInput.dispatchEvent(event); | |
}) | |
.catch(error => { | |
console.error('获取随机音色出错:', error); | |
// 失败时也隐藏提示 | |
}) | |
.finally(() => { | |
// 移除加载状态 | |
randomVoiceBtn.classList.remove('loading'); | |
if (loadingMsg) loadingMsg.style.display = 'none'; | |
}); | |
} | |
function showListenToastMessage() { | |
openToast("Please listen to both audio samples before voting", "info"); | |
} | |
// New function for N shortcut: Random + Synthesize | |
function handleNextRandomRound() { | |
console.log("Handling Next Random Round (N shortcut)"); | |
handleRandom(); // Selects random text and puts it in input | |
// Use setTimeout to ensure the input value is updated before synthesizing | |
// Especially important if handleRandom involves async operations (though it doesn't currently) | |
setTimeout(() => { | |
handleSynthesize(); // Triggers synthesis with the text now in the input | |
}, 0); | |
} | |
// Add submit event listener to form | |
synthForm.addEventListener('submit', handleSynthesize); | |
// Add click event listeners to vote buttons | |
voteButtons.forEach(btn => { | |
btn.addEventListener('click', function () { | |
if (bothSamplesPlayed) { | |
const model = this.dataset.model; | |
handleVote(model); | |
} else { | |
showListenToastMessage(); | |
} | |
}); | |
}); | |
// Add keyboard shortcut listeners | |
document.addEventListener('keydown', function (e) { | |
// Check if TTS tab is active | |
const ttsTab = document.getElementById('tts-tab'); | |
if (!ttsTab.classList.contains('active')) return; | |
// Only process keyboard shortcuts if text input is not focused | |
if (document.activeElement === textInput) { | |
// Allow Enter key to submit form from text input | |
if (e.key === 'Enter') { | |
// Check if Shift, Ctrl, Alt, or Meta keys are pressed | |
if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { | |
e.preventDefault(); // Prevent default form submission if needed | |
handleSynthesize(); // Trigger synthesis | |
} | |
} | |
return; // Don't process other keys if input is focused | |
} | |
// Allow Enter key to submit form when button is focused maybe? | |
// Or just generally allow Enter if not focused on input | |
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey) { | |
// Check if the initial form is visible (or loading is not happening) | |
if (playersContainer.style.display === 'none' && loadingContainer.style.display === 'none') { | |
e.preventDefault(); | |
handleSynthesize(); | |
} | |
// Do nothing if players are visible (don't want Enter to re-submit) | |
} else if (e.key.toLowerCase() === 'a') { | |
if (bothSamplesPlayed && !voteButtons[0].disabled) { | |
handleVote('a'); | |
} else if (playersContainer.style.display !== 'none' && !bothSamplesPlayed) { | |
showListenToastMessage(); | |
} | |
} else if (e.key.toLowerCase() === 'b') { | |
if (bothSamplesPlayed && !voteButtons[1].disabled) { | |
handleVote('b'); | |
} else if (playersContainer.style.display !== 'none' && !bothSamplesPlayed) { | |
showListenToastMessage(); | |
} | |
} else if (e.key.toLowerCase() === 'n') { | |
// N for Next Random Round (works anytime except when input focused) | |
if (!e.ctrlKey && !e.metaKey && !e.altKey) { // Ensure Alt isn't pressed either | |
e.preventDefault(); | |
handleNextRandomRound(); // New function for random + synthesize | |
} | |
} else if (e.key.toLowerCase() === 'r') { | |
// R for Random Text (works anytime except when input focused) | |
if (!e.ctrlKey && !e.metaKey && !e.altKey) { // Ensure Alt isn't pressed either | |
e.preventDefault(); | |
handleRandom(); | |
} | |
} else if (e.key.toLowerCase() === 'v') { | |
if (!e.ctrlKey && !e.metaKey && !e.altKey) { | |
e.preventDefault(); | |
handleRandomVoice(); | |
} | |
} else if (e.key === ' ') { | |
// Space to play/pause current audio | |
if (playersContainer.style.display !== 'none') { | |
e.preventDefault(); | |
// If A is playing, toggle A, else if B is playing, toggle B, else play A | |
if (wavePlayers.a.isPlaying) { | |
wavePlayers.a.togglePlayPause(); | |
} else if (wavePlayers.b.isPlaying) { | |
wavePlayers.b.togglePlayPause(); | |
} else { | |
wavePlayers.a.play(); | |
} | |
} | |
} | |
}); | |
// Add event listener for random button | |
randomBtn.addEventListener('click', handleRandom); | |
// Add event listener for next round button | |
nextRoundBtn.addEventListener('click', resetToInitialState); | |
// Fetch cached sentences when the DOM is ready | |
fetchCachedSentences(); | |
handleRandom(); | |
handleRandomVoice(); | |
} | |
); | |
</script> | |
{% endblock %} | |
{% block scripts %} | |
{{ super() }} | |
<script> | |
// 2. 阻止输入框Enter触发合成,只允许点击按钮合成 | |
const ttsForm = document.querySelector('#tts-tab form.input-container'); | |
const textInput = ttsForm.querySelector('.text-input'); | |
const synthBtn = ttsForm.querySelector('.synth-btn'); | |
textInput.addEventListener('keydown', function (e) { | |
if (e.key === 'Enter') { | |
e.preventDefault(); // Prevent Enter submit | |
} | |
}); | |
// Optional: prevent form Enter auto submit | |
ttsForm.addEventListener('submit', function (e) { | |
e.preventDefault(); // Prevent default submit | |
// Only trigger synth when clicking the synth button | |
if (document.activeElement === synthBtn || e.submitter === synthBtn) { | |
// Call original synth logic if exists | |
if (typeof window.triggerSynthesize === 'function') { | |
window.triggerSynthesize(); | |
} | |
} | |
}); | |
</script> | |
{% endblock %} | |