重构:分离 HTML/CSS/JS,项目结构更清晰

This commit is contained in:
zwbcc
2026-03-28 20:26:10 +08:00
parent cf7a5f365a
commit c1a69cd50d
4 changed files with 764 additions and 6 deletions

View File

@@ -30,8 +30,8 @@
直接用浏览器打开 HTML 文件,无需任何安装:
1. 下载 `speech-t2a.html` 文件
2. 双击用浏览器Chrome/Edge/Firefox打开即可
1. 下载并解压本项目
2. 双击 `index.html` 用浏览器Chrome/Edge/Firefox打开即可
---
@@ -58,7 +58,7 @@ cd /path/to/speech-t2a
python3 -m http.server 8197
```
4. 打开浏览器,访问:**http://localhost:8197/speech-t2a.html**
4. 打开浏览器,访问:**http://localhost:8197/index.html**
> ⚠️ 关闭终端会停止服务,需要保持窗口开启
@@ -70,7 +70,7 @@ python3 -m http.server 8197
**步骤:**
1.`speech-t2a.html` 上传到服务器,例如 `/var/www/speech-t2a/`
1.`index.html` 上传到服务器,例如 `/var/www/speech-t2a/`
2. 编辑 Nginx 配置:
```bash
@@ -86,7 +86,7 @@ server {
# 静态文件目录
root /var/www/speech-t2a;
index speech-t2a.html;
index index.html;
# 允许浏览器访问
location / {
@@ -106,7 +106,7 @@ sudo nginx -t
sudo systemctl restart nginx
```
5. 访问:**http://你的服务器IP:8197/speech-t2a.html**
5. 访问:**http://你的服务器IP:8197/index.html**
> 💡 阿里云/腾讯云等云服务器需在安全组开放 `8197` 端口

507
app.js Normal file
View File

@@ -0,0 +1,507 @@
'use strict';
// ── State ──────────────────────────────────────────────
let audioChunks = [];
let audioBuffer = null;
let audioBlobUrl = null;
let isPlaying = false;
let abortCtrl = null;
let animId = null;
let analyserNode = null;
let audioCtxViz = null;
// ── DOM ───────────────────────────────────────────────
const $ = id => document.getElementById(id);
const btnPlay = $('btnPlay');
const btnStop = $('btnStop');
const btnDownload = $('btnDownload');
const btnReplay = $('btnReplay');
const logArea = $('logArea');
const statusBadge = $('statusBadge');
const progressBar = $('progressBar');
const progressFill= $('progressFill');
const charCount = $('charCount');
const speedVal = $('speedVal');
const canvas = $('waveform');
const canvasCtx = canvas.getContext('2d');
const nativeAudio = $('nativeAudio');
// ── Persistence: API Key ──────────────────────────────
const LS_KEY = 'mm_tts_apikey';
function saveApiKey() {
const val = $('apiKey').value.trim();
if (val) localStorage.setItem(LS_KEY, val);
}
function loadApiKey() {
const saved = localStorage.getItem(LS_KEY);
if (saved) $('apiKey').value = saved;
}
// ── Init ───────────────────────────────────────────────
function init() {
loadApiKey();
$('speed').addEventListener('input', e => speedVal.textContent = parseFloat(e.target.value).toFixed(2) + 'x');
$('textInput').addEventListener('input', e => charCount.textContent = e.target.value.length);
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
drawIdle();
nativeAudio.addEventListener('ended', () => {
isPlaying = false;
stopVisualizer();
setStatus('完成', 'status-done');
log('✅ 播放完毕', 'info');
onDone();
});
nativeAudio.addEventListener('error', e => {
log('⚠️ 音频播放错误: ' + (nativeAudio.error && nativeAudio.error.message), 'err');
isPlaying = false;
onDone();
});
nativeAudio.addEventListener('playing', () => {
setStatus('播放中', 'status-playing');
startVisualizer();
});
}
// ── Canvas ─────────────────────────────────────────────
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
canvasCtx.scale(dpr, dpr);
}
function drawIdle() {
const w = canvas.offsetWidth, h = canvas.offsetHeight;
canvasCtx.clearRect(0, 0, w, h);
for (let i = 0; i < 72; i++) {
const x = i * (w / 72 - 1), y = (h - 4) / 2;
canvasCtx.fillStyle = '#2a2d3a';
canvasCtx.beginPath();
canvasCtx.roundRect(x, y, w / 72 - 1, 4, 2);
canvasCtx.fill();
}
}
function startVisualizer() {
if (!audioCtxViz) {
audioCtxViz = new (window.AudioContext || window.webkitAudioContext)();
}
if (!analyserNode) {
analyserNode = audioCtxViz.createAnalyser();
analyserNode.fftSize = 256;
}
try {
const track = nativeAudio.captureStream ? nativeAudio.captureStream().getAudioTracks()[0] : null;
if (track) {
const src = audioCtxViz.createMediaStreamSource(nativeAudio.captureStream());
src.connect(analyserNode);
}
} catch(e) {}
drawActive();
}
function drawActive() {
if (!isPlaying) { drawIdle(); return; }
const w = canvas.offsetWidth, h = canvas.offsetHeight;
canvasCtx.clearRect(0, 0, w, h);
const t = Date.now() / 200;
for (let i = 0; i < 72; i++) {
const phase = (i / 72) * Math.PI * 2;
const v = Math.abs(Math.sin(t + phase)) * 0.7 + 0.1;
const barH = Math.max(3, v * h * 0.85);
const x = i * (w / 72 - 1);
const y = (h - barH) / 2;
const grad = canvasCtx.createLinearGradient(x, y, x, y + barH);
grad.addColorStop(0, '#a78bfa'); grad.addColorStop(1, '#7c6af7');
canvasCtx.fillStyle = grad;
canvasCtx.beginPath();
canvasCtx.roundRect(x, y, w / 72 - 1, barH, 2);
canvasCtx.fill();
}
animId = requestAnimationFrame(drawActive);
}
function stopVisualizer() {
if (animId) { cancelAnimationFrame(animId); animId = null; }
if (analyserNode) { try { analyserNode.disconnect(); } catch(e){} }
drawIdle();
}
// ── Logging ────────────────────────────────────────────
function log(msg, type = 'info') {
const ts = new Date().toLocaleTimeString('zh-CN',{hour12:false});
const d = document.createElement('div');
d.className = 'log-line';
d.innerHTML = `<span class="log-ts">[${ts}]</span><span class="log-${type}">${esc(msg)}</span>`;
logArea.appendChild(d);
logArea.scrollTop = logArea.scrollHeight;
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function setStatus(s, cls) { statusBadge.textContent = s; statusBadge.className = 'status-badge ' + cls; }
// ── Audio helpers ─────────────────────────────────────
function stopPlayback() {
if (isPlaying) {
nativeAudio.pause();
nativeAudio.currentTime = 0;
}
isPlaying = false;
stopVisualizer();
}
function mimeOf(fmt) {
return { mp3:'audio/mpeg', wav:'audio/wav', flac:'audio/flac' }[fmt] || 'audio/mpeg';
}
function hexToBytes(hex) {
hex = hex.replace(/\s/g, '');
if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) {
return new Uint8Array(0);
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
function buildBlobUrl(hexChunks, fmt) {
const allBytes = [];
for (const hex of hexChunks) {
const b = hexToBytes(hex);
if (b.length > 0) allBytes.push(b);
}
if (allBytes.length === 0) return null;
const totalLen = allBytes.reduce((s, b) => s + b.length, 0);
const combined = new Uint8Array(totalLen);
let off = 0;
for (const b of allBytes) { combined.set(b, off); off += b.length; }
const mime = mimeOf(fmt);
const blob = new Blob([combined], { type: mime });
if (audioBlobUrl) URL.revokeObjectURL(audioBlobUrl);
audioBlobUrl = URL.createObjectURL(blob);
return audioBlobUrl;
}
function startAudioPlayback(blobUrl) {
stopPlayback();
nativeAudio.src = blobUrl;
nativeAudio.play().then(() => {
isPlaying = true;
setStatus('播放中', 'status-playing');
log('▶️ 开始播放', 'info');
startVisualizer();
}).catch(e => {
log('❌ 播放失败: ' + e.message, 'err');
onDone();
});
}
function getAudioBlob() {
if (!audioChunks.length) return null;
const allBytes = [];
for (const hex of audioChunks) {
const b = hexToBytes(hex);
if (b.length > 0) allBytes.push(b);
}
if (allBytes.length === 0) return null;
const total = allBytes.reduce((s, b) => s + b.length, 0);
const combined = new Uint8Array(total);
let off = 0;
for (const b of allBytes) { combined.set(b, off); off += b.length; }
return new Blob([combined], { type: mimeOf($('format').value) });
}
// ── Main synthesis ─────────────────────────────────────
async function startSynthesis() {
const rawKey = $('apiKey').value.trim();
const text = $('textInput').value.trim();
if (!rawKey) { log('❌ 请先填写 API Key', 'err'); return; }
if (!text) { log('❌ 请先填写要转换的文字', 'err'); return; }
const cleanKey = rawKey.replace(/[^\x20-\x7E]/g, '').trim();
if (!cleanKey) {
log('❌ API Key 包含无效字符(仅支持 ASCII请重新复制粘贴正确的 Key', 'err');
return;
}
if (cleanKey !== rawKey) {
log('⚠️ API Key 中有不可用字符已自动清除,请确认 Key 正确', 'warn');
}
saveApiKey();
let apiKey = cleanKey.startsWith('Bearer ') ? cleanKey : 'Bearer ' + cleanKey;
try {
new Headers({ 'Authorization': apiKey });
} catch(e) {
log('❌ API Key 包含非法字符,无法作为 HTTP Header: ' + e.message, 'err');
return;
}
audioChunks = [];
stopPlayback();
progressFill.style.width = '0%';
progressBar.classList.add('active');
btnPlay.disabled = true; btnStop.disabled = false;
btnDownload.disabled = true; btnReplay.disabled = true;
const isStream = $('streamMode').value === 'true';
log(`🚀 发起 HTTP 请求 (${isStream ? '流式' : '非流式'})`, 'info');
setStatus('请求中', 'status-connecting');
abortCtrl = new AbortController();
const body = {
model: $('model').value,
text,
stream: isStream,
voice_setting: {
voice_id: $('voiceId').value,
speed: parseFloat($('speed').value),
vol: parseFloat($('vol').value),
pitch: parseInt($('pitch').value),
...($('emotion').value ? { emotion: $('emotion').value } : {})
},
audio_setting: {
sample_rate: parseInt($('sampleRate').value),
bitrate: 128000,
format: $('format').value,
channel: 1
}
};
try {
const response = await fetch('https://api.minimaxi.com/v1/t2a_v2', {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
signal: abortCtrl.signal
});
log(`📡 HTTP ${response.status} ${response.statusText}`, response.ok ? 'info' : 'err');
if (!response.ok) {
try {
const err = await response.json();
log(`❌ 错误: ${JSON.stringify(err)}`, 'err');
const sr = err.base_resp || {};
if (sr.status_code === 1004) log('💡 原因: 鉴权失败,检查 API Key 是否正确', 'warn');
if (sr.status_code === 1039) log('💡 原因: TPM 触发限流,稍后重试', 'warn');
if (sr.status_code === 1042) log('💡 原因: 非法字符超过 10%,请精简文本', 'warn');
} catch {}
setStatus('失败', 'status-error');
onDone();
return;
}
if (isStream) {
setStatus('合成中', 'status-playing');
await handleStreamResponse(response);
} else {
setStatus('合成中', 'status-playing');
await handleNonStreamResponse(response);
}
} catch(e) {
if (e.name === 'AbortError') {
log('⏹️ 用户取消', 'warn');
} else if (e.message && e.message.includes('ISO-8859-1')) {
log('❌ Header 编码错误API Key 包含非 ASCII 字符', 'err');
} else {
log('❌ 请求异常: ' + e.message, 'err');
log('🔄 尝试备用接口 api-bj.minimaxi.com...', 'warn');
try {
const r2 = await fetch('https://api-bj.minimaxi.com/v1/t2a_v2', {
method: 'POST',
headers: { 'Authorization': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortCtrl.signal
});
if (r2.ok) {
if ($('streamMode').value === 'true') await handleStreamResponse(r2);
else await handleNonStreamResponse(r2);
} else {
const err2 = await r2.json().catch(() => ({}));
log(`❌ 备用接口也失败: ${JSON.stringify(err2)}`, 'err');
setStatus('失败', 'status-error');
onDone();
}
} catch(e2) {
log('❌ 备用接口异常: ' + e2.message, 'err');
setStatus('失败', 'status-error');
onDone();
}
}
}
}
// ── Stream handler ─────────────────────────────────────
async function handleStreamResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let chunkCount = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nlIdx;
while ((nlIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nlIdx).trim();
buffer = buffer.slice(nlIdx + 1);
if (!line || !line.startsWith('data:')) continue;
const dataStr = line.slice(5).trim();
if (dataStr === '[DONE]' || dataStr === '') continue;
try {
const chunk = JSON.parse(dataStr);
const baseResp = chunk.base_resp || {};
if (baseResp.status_code && baseResp.status_code !== 0) {
log(`❌ 流错误 [${baseResp.status_code}]: ${baseResp.status_msg || ''}`, 'err');
return;
}
if (chunk.data && chunk.data.audio) {
chunkCount++;
audioChunks.push(chunk.data.audio);
progressFill.style.width = Math.min((chunkCount / Math.max(parseInt($('textInput').value.length / 200), 5)) * 95, 95) + '%';
log(`🔊 #${chunkCount} (${chunk.data.audio.length}B, status=${chunk.data.status})`, 'audio');
if (chunk.data.status === 2) {
log('✅ 合成完毕,共 ' + chunkCount + ' 片段', 'info');
}
}
if (chunk.extra_info) {
const ei = chunk.extra_info;
log(`📋 音频: ${(ei.audio_length/1000).toFixed(1)}s / ${ei.audio_size}B / ${ei.usage_characters}字符`, 'info');
}
} catch(parseErr) {}
}
}
} finally {
reader.releaseLock();
}
if (!audioChunks.length) {
log('❌ 流接收完毕但无音频数据', 'err');
setStatus('失败', 'status-error');
onDone();
return;
}
progressFill.style.width = '98%';
log('🎵 音频合成完成,开始播放...', 'info');
const blobUrl = buildBlobUrl(audioChunks, $('format').value);
startAudioPlayback(blobUrl);
btnDownload.disabled = false;
btnReplay.disabled = false;
onDone();
}
// ── Non-stream handler ─────────────────────────────────
async function handleNonStreamResponse(response) {
log('📥 接收完整响应...', 'info');
let json;
try {
json = await response.json();
} catch(e) {
log('❌ 响应 JSON 解析失败: ' + e.message, 'err');
log('🔄 尝试作为原始音频处理...', 'warn');
const bytes = await response.arrayBuffer();
if (bytes.byteLength > 0) {
const blob = new Blob([bytes], { type: mimeOf($('format').value) });
if (audioBlobUrl) URL.revokeObjectURL(audioBlobUrl);
audioBlobUrl = URL.createObjectURL(blob);
startAudioPlayback(audioBlobUrl);
btnDownload.disabled = false;
btnReplay.disabled = false;
onDone();
return;
}
setStatus('失败', 'status-error');
onDone();
return;
}
log('📦 JSON: ' + JSON.stringify(json).slice(0, 400), 'info');
const baseResp = json.base_resp || {};
if (baseResp.status_code && baseResp.status_code !== 0) {
log(`❌ 合成失败 [${baseResp.status_code}]: ${baseResp.status_msg || ''}`, 'err');
if (baseResp.status_code === 1004) log('💡 提示: 鉴权失败,检查 API Key 是否正确', 'warn');
setStatus('失败', 'status-error');
onDone();
return;
}
const audioHex = json.data && json.data.audio;
if (!audioHex) {
log('❌ 响应中无 audio 字段', 'err');
setStatus('失败', 'status-error');
onDone();
return;
}
audioChunks = [audioHex];
progressFill.style.width = '90%';
log('🎵 音频就绪,开始播放...', 'info');
const blobUrl = buildBlobUrl(audioChunks, $('format').value);
startAudioPlayback(blobUrl);
if (json.extra_info) {
const ei = json.extra_info;
log(`📋 音频: ${(ei.audio_length/1000).toFixed(1)}s / ${ei.audio_size}B / ${ei.usage_characters}字符`, 'info');
}
btnDownload.disabled = false;
btnReplay.disabled = false;
onDone();
}
// ── Controls ───────────────────────────────────────────
function stopSynthesis() {
log('⏹️ 取消请求', 'warn');
stopPlayback();
if (abortCtrl) abortCtrl.abort();
progressFill.style.width = '0%';
onDone();
}
function onDone() {
btnPlay.disabled = false;
btnStop.disabled = true;
setTimeout(() => progressBar.classList.remove('active'), 2000);
}
function replayAudio() {
if (audioBlobUrl) {
stopPlayback();
startAudioPlayback(audioBlobUrl);
} else {
log('⚠️ 没有可回放的音频', 'warn');
}
}
function downloadAudio() {
const blob = getAudioBlob();
if (!blob) { log('⚠️ 没有可下载的音频', 'warn'); return; }
const fmt = $('format').value;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `speech_${Date.now()}.${fmt}`;
a.click();
URL.revokeObjectURL(a.href);
log(`💾 已下载: ${a.download} (${(blob.size/1024).toFixed(1)} KB)`, 'info');
}
// auto-init
$('apiKey').addEventListener('blur', saveApiKey);
$('apiKey').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); saveApiKey(); $('apiKey').blur(); }
});
init();

171
index.html Normal file
View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MiniMax Speech HD TTS</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<h1>🎙️ MiniMax Speech HD TTS</h1>
<p class="subtitle">HTTP 流式文字转语音 · speech-2.8-hd</p>
<audio id="nativeAudio" controls></audio>
<div class="card">
<label>API Key <span style="font-weight:400;text-transform:none;font-size:0.7rem;color:#4b5563">(自动保存到本地)</span></label>
<input type="password" id="apiKey" placeholder="输入你的 MiniMax API Key如 Group-xxxxxxxx" autocomplete="off" />
<p class="hint">填写后回车或点击空白处保存。刷新页面不丢失。</p>
</div>
<div class="card">
<div class="row">
<div>
<label>音色 (voice_id)</label>
<select id="voiceId">
<optgroup label="中文">
<option value="male-qn-qingse">male-qn-qingse青年女声</option>
<option value="male-qn-baiyang">male-qn-baiyang青年男声-白扬)</option>
<option value="female-tianmei">female-tianmei甜美女声</option>
<option value="female-shaonv">female-shaonv少女声</option>
<option value="male-shaonian">male-shaonian少年声</option>
<option value="male-zhongtan">male-zhongtan中年男声</option>
<option value="female-yujie">female-yujie御姐音</option>
<option value="female-xiaochao">female-xiaochao小潮音色</option>
<option value="male-happyman">male-happyman开心男声</option>
</optgroup>
<optgroup label="英语">
<option value="male-cenek">male-cenek英语男声-Cenek</option>
<option value="female-alice">female-alice英语女声-Alice</option>
<option value="male-joshua">male-joshua英语男声-Joshua</option>
<option value="female-maverick">female-maverick英语女声-Maverick</option>
</optgroup>
<optgroup label="日语">
<option value="male-jp-qingse">male-jp-qingse</option>
<option value="female-jp-qingse">female-jp-qingse</option>
</optgroup>
<optgroup label="韩语">
<option value="male-kr-qingse">male-kr-qingse</option>
<option value="female-kr-qingse">female-kr-qingse</option>
</optgroup>
</select>
</div>
<div>
<label>模型</label>
<select id="model">
<option value="speech-2.8-hd">speech-2.8-hd高清</option>
<option value="speech-2.6-hd">speech-2.6-hd</option>
<option value="speech-02-hd">speech-02-hd克隆音质</option>
<option value="speech-2.8-turbo">speech-2.8-turbo快速</option>
<option value="speech-2.6-turbo">speech-2.6-turbo</option>
<option value="speech-02-turbo">speech-02-turbo</option>
</select>
</div>
</div>
<div style="margin-top:14px">
<label>语速 <span class="speed-val" id="speedVal">1.00x</span></label>
<input type="range" class="speed-slider" id="speed" min="0.5" max="2.0" step="0.05" value="1.0" />
</div>
<div class="row" style="margin-top:14px">
<div>
<label>音频格式</label>
<select id="format">
<option value="mp3">mp3</option>
<option value="wav">wav</option>
<option value="flac">flac</option>
</select>
</div>
<div>
<label>采样率</label>
<select id="sampleRate">
<option value="16000">16000 Hz</option>
<option value="32000" selected>32000 Hz</option>
<option value="44100">44100 Hz</option>
<option value="48000">48000 Hz</option>
</select>
</div>
</div>
<div class="row" style="margin-top:14px">
<div>
<label>音量</label>
<select id="vol">
<option value="0.5">0.5(低)</option>
<option value="0.75">0.75</option>
<option value="1" selected>1.0(正常)</option>
<option value="1.25">1.25</option>
<option value="1.5">1.5(高)</option>
</select>
</div>
<div>
<label>音调</label>
<select id="pitch">
<option value="-5">-5低沉</option>
<option value="-2">-2</option>
<option value="0" selected>0正常</option>
<option value="2">2</option>
<option value="5">5高亢</option>
</select>
</div>
</div>
<div class="row" style="margin-top:14px">
<div>
<label>情绪(仅 HD/Turbo</label>
<select id="emotion">
<option value="">不指定(自动)</option>
<option value="happy">happy</option>
<option value="sad">sad</option>
<option value="angry">angry</option>
<option value="surprised">surprised</option>
<option value="calm">calm</option>
<option value="whisper">whisper2.6turbo</option>
</select>
</div>
<div>
<label>流式输出</label>
<select id="streamMode">
<option value="true">是(实时,边收边播)</option>
<option value="false" selected>否(等完成后再播)</option>
</select>
</div>
</div>
</div>
<div class="card">
<label>输入文字(最多 10000 字符)</label>
<textarea id="textInput" placeholder="输入要转换的文字,例如:真正的危险不是计算机开始像人一样思考,而是人开始像计算机一样思考。" maxlength="10000"></textarea>
<div style="text-align:right;font-size:0.72rem;color:var(--muted);margin-top:4px;">
<span id="charCount">0</span> / 10000
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btnPlay" onclick="startSynthesis()">▶ 开始合成</button>
<button class="btn btn-danger" id="btnStop" onclick="stopSynthesis()" disabled>■ 停止</button>
</div>
<div class="btn-row" style="margin-top:8px">
<button class="btn btn-secondary" id="btnDownload" onclick="downloadAudio()" disabled>💾 下载音频</button>
<button class="btn btn-secondary" id="btnReplay" onclick="replayAudio()" disabled>🔁 重新播放</button>
</div>
<div class="progress-bar" id="progressBar">
<div class="progress-fill" id="progressFill"></div>
</div>
<canvas id="waveform"></canvas>
<div class="card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<label style="margin:0">日志</label>
<span class="status-badge status-idle" id="statusBadge">空闲</span>
</div>
<div class="log-area" id="logArea"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

80
style.css Normal file
View File

@@ -0,0 +1,80 @@
:root {
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
--accent: #7c6af7; --text: #e0e0e0; --muted: #888; --radius: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter','PingFang SC','Microsoft YaHei',sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; display: flex; justify-content: center;
align-items: flex-start; padding: 40px 16px;
}
.container { width: 100%; max-width: 680px; }
h1 {
font-size: 1.5rem; font-weight: 700; text-align: center; margin-bottom: 6px;
background: linear-gradient(135deg,#a78bfa,#f472b6);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.subtitle { text-align: center; color: var(--muted); font-size: 0.8rem; margin-bottom: 28px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.06em; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
input[type="text"], input[type="password"], textarea, select {
width: 100%; background: #11131a; border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 0.9rem;
padding: 10px 12px; outline: none; transition: border-color 0.2s;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
textarea { resize: vertical; min-height: 110px; line-height: 1.6; }
select { cursor: pointer; }
.btn-row { display: flex; gap: 10px; margin-top: 16px; }
.btn {
flex: 1; padding: 12px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: linear-gradient(135deg,#7c6af7,#a78bfa); color: #fff; }
.btn-danger { background: #2a1a1a; border: 1px solid #5a2a2a; color: #ff8080; }
.btn-secondary { background: #1a1d27; border: 1px solid var(--border); color: var(--text); }
.log-area {
background: #0a0b10; border: 1px solid var(--border); border-radius: 6px;
padding: 12px; font-family: 'Fira Code',monospace; font-size: 0.73rem;
color: #9ca3af; max-height: 180px; overflow-y: auto; margin-top: 10px; line-height: 1.7;
}
.log-line { display: flex; gap: 8px; }
.log-ts { color: #4b5563; flex-shrink: 0; }
.log-info { color: #6ee7b7; } .log-warn { color: #fde68a; }
.log-err { color: #f87171; } .log-audio { color: #93c5fd; }
.progress-bar {
height: 3px; background: var(--border); border-radius: 2px;
margin-top: 12px; overflow: hidden; opacity: 0; transition: opacity 0.3s;
}
.progress-bar.active { opacity: 1; }
.progress-fill {
height: 100%; background: linear-gradient(90deg,#7c6af7,#a78bfa);
width: 0%; transition: width 0.4s ease;
}
.status-badge {
display: inline-block; font-size: 0.72rem; padding: 2px 8px;
border-radius: 20px; margin-left: 8px; vertical-align: middle;
}
.status-idle { background:#1f2937; color:#6b7280; }
.status-connecting { background:#1f2a1f; color:#86efac; }
.status-playing { background:#1f1f2e; color:#a78bfa; }
.status-done { background:#1a2e1a; color:#4ade80; }
.status-error { background:#2e1a1a; color:#f87171; }
#waveform { width:100%; height:48px; margin-top:10px; border-radius:4px; display:block; }
.speed-slider {
-webkit-appearance:none; width:100%; height:4px; border-radius:2px;
background: var(--border); outline:none; margin:8px 0;
}
.speed-slider::-webkit-slider-thumb {
-webkit-appearance:none; width:14px; height:14px; border-radius:50%;
background:var(--accent); cursor:pointer;
}
.speed-val { font-size:0.78rem; color:var(--accent); float:right; }
.hint { font-size:0.72rem; color:var(--muted); margin-top:6px; }
.hint a { color:#a78bfa; }
#nativeAudio { display: none; }