758 lines
26 KiB
HTML
758 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Chatterbox TTS Test</title>
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
background: white;
|
||
border-radius: 20px;
|
||
padding: 40px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
h1 {
|
||
text-align: center;
|
||
color: #333;
|
||
margin-bottom: 30px;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #555;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
}
|
||
|
||
input[type="text"],
|
||
input[type="number"],
|
||
select,
|
||
textarea {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
border: 2px solid #e0e0e0;
|
||
border-radius: 10px;
|
||
font-size: 15px;
|
||
transition: border-color 0.3s;
|
||
}
|
||
|
||
input:focus,
|
||
select:focus,
|
||
textarea:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
textarea {
|
||
resize: vertical;
|
||
min-height: 80px;
|
||
}
|
||
|
||
/* Voice Recording Section */
|
||
.voice-section {
|
||
background: #f8f9ff;
|
||
border-radius: 15px;
|
||
padding: 25px;
|
||
margin-bottom: 25px;
|
||
border: 2px solid #e0e0e0;
|
||
}
|
||
|
||
.voice-section.recording {
|
||
border-color: #e74c3c;
|
||
background: #fdf2f2;
|
||
}
|
||
|
||
.voice-section.has-recording {
|
||
border-color: #4caf50;
|
||
background: #f1f8f4;
|
||
}
|
||
|
||
.voice-label {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.record-btn {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50%;
|
||
width: 70px;
|
||
height: 70px;
|
||
font-size: 28px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto;
|
||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.record-btn:hover {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.record-btn.recording {
|
||
background: #e74c3c;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { transform: scale(1); }
|
||
50% { transform: scale(1.1); }
|
||
}
|
||
|
||
.record-status {
|
||
text-align: center;
|
||
margin-top: 15px;
|
||
color: #666;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.record-timer {
|
||
text-align: center;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #667eea;
|
||
margin-top: 10px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.voice-text {
|
||
background: white;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-top: 20px;
|
||
text-align: center;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
border: 2px dashed #667eea;
|
||
display: none;
|
||
}
|
||
|
||
.voice-text.show {
|
||
display: block;
|
||
}
|
||
|
||
.voice-preview {
|
||
margin-top: 15px;
|
||
display: none;
|
||
}
|
||
|
||
.voice-preview.show {
|
||
display: block;
|
||
}
|
||
|
||
.voice-preview audio {
|
||
width: 100%;
|
||
}
|
||
|
||
.voice-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.voice-actions button {
|
||
flex: 1;
|
||
padding: 10px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn-retry {
|
||
background: #e0e0e0;
|
||
color: #333;
|
||
}
|
||
|
||
.btn-use {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
}
|
||
|
||
/* File upload alternative */
|
||
.or-divider {
|
||
text-align: center;
|
||
margin: 20px 0;
|
||
color: #999;
|
||
font-size: 14px;
|
||
position: relative;
|
||
}
|
||
|
||
.or-divider::before,
|
||
.or-divider::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 40%;
|
||
height: 1px;
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
.or-divider::before { left: 0; }
|
||
.or-divider::after { right: 0; }
|
||
|
||
.file-input-wrapper {
|
||
border: 2px dashed #ccc;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.file-input-wrapper:hover {
|
||
border-color: #667eea;
|
||
background: #f8f9ff;
|
||
}
|
||
|
||
.file-input-wrapper.has-file {
|
||
border-color: #4caf50;
|
||
background: #f1f8f4;
|
||
}
|
||
|
||
input[type="file"] {
|
||
display: none;
|
||
}
|
||
|
||
.slider-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
|
||
input[type="range"] {
|
||
flex: 1;
|
||
}
|
||
|
||
.slider-value {
|
||
min-width: 50px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
color: #667eea;
|
||
}
|
||
|
||
button[type="submit"] {
|
||
width: 100%;
|
||
padding: 16px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 10px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
button[type="submit"]:hover:not(:disabled) {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
button[type="submit"]:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.result {
|
||
margin-top: 30px;
|
||
padding: 20px;
|
||
background: #f8f9fa;
|
||
border-radius: 10px;
|
||
display: none;
|
||
}
|
||
|
||
.result.show {
|
||
display: block;
|
||
}
|
||
|
||
.result audio {
|
||
width: 100%;
|
||
margin-top: 15px;
|
||
}
|
||
|
||
.download-btn {
|
||
display: inline-block;
|
||
margin-top: 15px;
|
||
padding: 10px 20px;
|
||
background: #4caf50;
|
||
color: white;
|
||
text-decoration: none;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.loading {
|
||
display: none;
|
||
text-align: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.loading.show {
|
||
display: block;
|
||
}
|
||
|
||
.spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 4px solid #f3f3f3;
|
||
border-top: 4px solid #667eea;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 15px;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
|
||
.error {
|
||
color: #e74c3c;
|
||
background: #fdf2f2;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-top: 20px;
|
||
display: none;
|
||
}
|
||
|
||
.error.show {
|
||
display: block;
|
||
}
|
||
|
||
.two-col {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 15px;
|
||
}
|
||
|
||
.check-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
background: #4caf50;
|
||
color: white;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
margin-left: 10px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🎙️ Chatterbox TTS</h1>
|
||
|
||
<form id="ttsForm">
|
||
<!-- Голосовая запись -->
|
||
<div class="voice-section" id="voiceSection">
|
||
<div class="voice-label">
|
||
🎤 Запишите голосовое сообщение для проверки на бота
|
||
<span class="check-badge" id="checkBadge" style="display: none;">✓ Готово</span>
|
||
</div>
|
||
|
||
<button type="button" class="record-btn" id="recordBtn">⏺</button>
|
||
|
||
<div class="record-status" id="recordStatus">Нажмите для записи</div>
|
||
<div class="record-timer" id="recordTimer" style="display: none;">00:00</div>
|
||
|
||
<div class="voice-text" id="voiceText">
|
||
📢 Произнесите: <span style="color: #667eea;">"Хакатон 2026 французский стиль"</span>
|
||
</div>
|
||
|
||
<div class="voice-preview" id="voicePreview">
|
||
<audio id="recordedAudio" controls></audio>
|
||
<div class="voice-actions">
|
||
<button type="button" class="btn-retry" id="retryBtn">🔄 Перезаписать</button>
|
||
<button type="button" class="btn-use" id="useBtn">✅ Использовать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Или загрузить файл -->
|
||
<div class="or-divider">или загрузите файл</div>
|
||
|
||
<div class="form-group">
|
||
<div class="file-input-wrapper" id="dropZone">
|
||
<input type="file" id="audioFile" accept="audio/*">
|
||
<div id="fileLabel">📁 Кликни или перетащи аудио файл</div>
|
||
<div id="fileName" style="margin-top: 10px; font-weight: 600; color: #667eea;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Текст для синтеза -->
|
||
<div class="form-group">
|
||
<label for="text">Текст для синтеза (макс 300 символов)</label>
|
||
<textarea id="text" maxlength="300" placeholder="Введите текст...">В прошлом месяце мы достигли нового рубежа.</textarea>
|
||
<div style="text-align: right; font-size: 12px; color: #999; margin-top: 5px;">
|
||
<span id="charCount">0</span>/300
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Язык -->
|
||
<div class="form-group">
|
||
<label for="language">Язык</label>
|
||
<select id="language">
|
||
<option value="en">English</option>
|
||
<option value="ru" selected>Russian</option>
|
||
<option value="de">German</option>
|
||
<option value="es">Spanish</option>
|
||
<option value="fr">French</option>
|
||
<option value="it">Italian</option>
|
||
<option value="pt">Portuguese</option>
|
||
<option value="hi">Hindi</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Слайдеры -->
|
||
<div class="form-group">
|
||
<label>Exaggeration (экспрессивность)</label>
|
||
<div class="slider-group">
|
||
<input type="range" id="exaggeration" min="0.25" max="2" step="0.05" value="0.5">
|
||
<span class="slider-value" id="exaggerationVal">0.5</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Temperature (разнообразие)</label>
|
||
<div class="slider-group">
|
||
<input type="range" id="temperature" min="0.05" max="5" step="0.05" value="0.8">
|
||
<span class="slider-value" id="temperatureVal">0.8</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>CFG Weight</label>
|
||
<div class="slider-group">
|
||
<input type="range" id="cfgWeight" min="0.2" max="1" step="0.05" value="0.5">
|
||
<span class="slider-value" id="cfgWeightVal">0.5</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="two-col">
|
||
<div class="form-group">
|
||
<label for="seed">Random Seed (0 = random)</label>
|
||
<input type="number" id="seed" value="0" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<button type="submit" id="submitBtn">🚀 Сгенерировать аудио</button>
|
||
</form>
|
||
|
||
<!-- Лоадер -->
|
||
<div class="loading" id="loading">
|
||
<div class="spinner"></div>
|
||
<p>Генерируем аудио... Это может занять 10-30 секунд</p>
|
||
</div>
|
||
|
||
<!-- Ошибка -->
|
||
<div class="error" id="error"></div>
|
||
|
||
<!-- Результат -->
|
||
<div class="result" id="result">
|
||
<h3 style="margin-bottom: 15px; color: #333;">✅ Готово!</h3>
|
||
<audio id="audioPlayer" controls></audio>
|
||
<br>
|
||
<a href="#" class="download-btn" id="downloadBtn" download="output.wav">💾 Скачать WAV</a>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API_URL = 'https://back.hack.kinsle.ru/process-audio';
|
||
|
||
// Элементы
|
||
const form = document.getElementById('ttsForm');
|
||
const voiceSection = document.getElementById('voiceSection');
|
||
const recordBtn = document.getElementById('recordBtn');
|
||
const recordStatus = document.getElementById('recordStatus');
|
||
const recordTimer = document.getElementById('recordTimer');
|
||
const voiceText = document.getElementById('voiceText');
|
||
const voicePreview = document.getElementById('voicePreview');
|
||
const recordedAudio = document.getElementById('recordedAudio');
|
||
const retryBtn = document.getElementById('retryBtn');
|
||
const useBtn = document.getElementById('useBtn');
|
||
const checkBadge = document.getElementById('checkBadge');
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('audioFile');
|
||
const fileLabel = document.getElementById('fileLabel');
|
||
const fileName = document.getElementById('fileName');
|
||
const text = document.getElementById('text');
|
||
const charCount = document.getElementById('charCount');
|
||
const loading = document.getElementById('loading');
|
||
const result = document.getElementById('result');
|
||
const error = document.getElementById('error');
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
const downloadBtn = document.getElementById('downloadBtn');
|
||
|
||
// Переменные для записи
|
||
let mediaRecorder = null;
|
||
let recordedChunks = [];
|
||
let recordingStartTime = null;
|
||
let recordingTimer = null;
|
||
let recordedBlob = null;
|
||
let isUsingRecordedVoice = false;
|
||
|
||
// Запрос разрешения на микрофон при загрузке
|
||
async function initMicrophone() {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
stream.getTracks().forEach(track => track.stop());
|
||
} catch (err) {
|
||
console.log('Микрофон не доступен:', err);
|
||
}
|
||
}
|
||
initMicrophone();
|
||
|
||
// Начать/остановить запись
|
||
recordBtn.addEventListener('click', async () => {
|
||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||
stopRecording();
|
||
} else {
|
||
startRecording();
|
||
}
|
||
});
|
||
|
||
async function startRecording() {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
||
const mimeType =
|
||
MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' :
|
||
MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' :
|
||
MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') ? 'audio/ogg;codecs=opus' : '';
|
||
|
||
mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
||
recordedChunks = [];
|
||
|
||
mediaRecorder.ondataavailable = (e) => {
|
||
if (e.data.size > 0) {
|
||
recordedChunks.push(e.data);
|
||
}
|
||
};
|
||
|
||
mediaRecorder.onstop = () => {
|
||
const actualType = mediaRecorder.mimeType || 'audio/webm';
|
||
recordedBlob = new Blob(recordedChunks, { type: actualType });
|
||
const url = URL.createObjectURL(recordedBlob);
|
||
recordedAudio.src = url;
|
||
|
||
voicePreview.classList.add('show');
|
||
recordStatus.textContent = 'Запись завершена';
|
||
recordBtn.textContent = '⏺';
|
||
recordBtn.classList.remove('recording');
|
||
voiceSection.classList.remove('recording');
|
||
voiceSection.classList.add('has-recording');
|
||
|
||
// Останавливаем все треки
|
||
stream.getTracks().forEach(track => track.stop());
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
|
||
// UI обновления
|
||
recordBtn.textContent = '⏹';
|
||
recordBtn.classList.add('recording');
|
||
voiceSection.classList.add('recording');
|
||
recordStatus.textContent = 'Идёт запись...';
|
||
voiceText.classList.add('show');
|
||
recordTimer.style.display = 'block';
|
||
voicePreview.classList.remove('show');
|
||
isUsingRecordedVoice = false;
|
||
checkBadge.style.display = 'none';
|
||
|
||
// Таймер
|
||
recordingStartTime = Date.now();
|
||
updateTimer();
|
||
recordingTimer = setInterval(updateTimer, 1000);
|
||
|
||
} catch (err) {
|
||
alert('Не удалось получить доступ к микрофону: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function stopRecording() {
|
||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||
mediaRecorder.stop();
|
||
clearInterval(recordingTimer);
|
||
recordTimer.style.display = 'none';
|
||
voiceText.classList.remove('show');
|
||
}
|
||
}
|
||
|
||
function updateTimer() {
|
||
const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000);
|
||
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
|
||
const secs = (elapsed % 60).toString().padStart(2, '0');
|
||
recordTimer.textContent = `${mins}:${secs}`;
|
||
}
|
||
|
||
// Перезаписать
|
||
retryBtn.addEventListener('click', () => {
|
||
voicePreview.classList.remove('show');
|
||
voiceSection.classList.remove('has-recording');
|
||
recordStatus.textContent = 'Нажмите для записи';
|
||
recordedBlob = null;
|
||
isUsingRecordedVoice = false;
|
||
checkBadge.style.display = 'none';
|
||
});
|
||
|
||
// Использовать запись
|
||
useBtn.addEventListener('click', () => {
|
||
isUsingRecordedVoice = true;
|
||
checkBadge.style.display = 'inline-flex';
|
||
recordStatus.textContent = '✅ Голосовое сообщение сохранено';
|
||
|
||
// Сбрасываем файл если был
|
||
fileInput.value = '';
|
||
fileLabel.textContent = '📁 Кликни или перетащи аудио файл';
|
||
fileName.textContent = '';
|
||
dropZone.classList.remove('has-file');
|
||
});
|
||
|
||
// File upload (drag & drop)
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
dropZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
dropZone.style.borderColor = '#667eea';
|
||
});
|
||
dropZone.addEventListener('dragleave', () => {
|
||
dropZone.style.borderColor = '#ccc';
|
||
});
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.style.borderColor = '#ccc';
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) {
|
||
fileInput.files = files;
|
||
updateFileName(files[0].name);
|
||
}
|
||
});
|
||
fileInput.addEventListener('change', () => {
|
||
if (fileInput.files.length) {
|
||
updateFileName(fileInput.files[0].name);
|
||
// Сбрасываем голосовую запись
|
||
isUsingRecordedVoice = false;
|
||
checkBadge.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
function updateFileName(name) {
|
||
fileLabel.textContent = '✅ Файл выбран';
|
||
fileName.textContent = name;
|
||
dropZone.classList.add('has-file');
|
||
}
|
||
|
||
// Character counter
|
||
text.addEventListener('input', () => {
|
||
charCount.textContent = text.value.length;
|
||
});
|
||
charCount.textContent = text.value.length;
|
||
|
||
// Slider updates
|
||
document.getElementById('exaggeration').addEventListener('input', (e) => {
|
||
document.getElementById('exaggerationVal').textContent = e.target.value;
|
||
});
|
||
document.getElementById('temperature').addEventListener('input', (e) => {
|
||
document.getElementById('temperatureVal').textContent = e.target.value;
|
||
});
|
||
document.getElementById('cfgWeight').addEventListener('input', (e) => {
|
||
document.getElementById('cfgWeightVal').textContent = e.target.value;
|
||
});
|
||
|
||
// Form submit
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
// Проверяем что есть источник аудио
|
||
if (!isUsingRecordedVoice && !fileInput.files.length) {
|
||
alert('Пожалуйста, запишите голосовое сообщение или загрузите аудио файл');
|
||
return;
|
||
}
|
||
|
||
result.classList.remove('show');
|
||
error.classList.remove('show');
|
||
loading.classList.add('show');
|
||
|
||
const submitBtn = document.getElementById('submitBtn');
|
||
submitBtn.disabled = true;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
|
||
// Добавляем аудио (голосовое или файл)
|
||
if (isUsingRecordedVoice && recordedBlob) {
|
||
const ext = recordedBlob.type.includes('ogg') ? 'ogg' : 'webm';
|
||
formData.append('audio_file', recordedBlob, `voice_recording.${ext}`);
|
||
} else if (fileInput.files.length > 0) {
|
||
formData.append('audio_file', fileInput.files[0]);
|
||
}
|
||
|
||
formData.append('text', text.value);
|
||
formData.append('language_id', document.getElementById('language').value);
|
||
formData.append('exaggeration', document.getElementById('exaggeration').value);
|
||
formData.append('temperature', document.getElementById('temperature').value);
|
||
formData.append('seed_num', document.getElementById('seed').value);
|
||
formData.append('cfg_weight', document.getElementById('cfgWeight').value);
|
||
|
||
const response = await fetch(API_URL, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errData = await response.json().catch(() => ({}));
|
||
throw new Error(errData.error || `HTTP ${response.status}`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
audioPlayer.src = url;
|
||
downloadBtn.href = url;
|
||
result.classList.add('show');
|
||
|
||
} catch (err) {
|
||
error.textContent = '❌ Ошибка: ' + err.message;
|
||
error.classList.add('show');
|
||
console.error(err);
|
||
} finally {
|
||
loading.classList.remove('show');
|
||
submitBtn.disabled = false;
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|