kemuriririn's picture
update arena page
050b9af
{% 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 %}