From c1a69cd50d960ddb3cfac9929dde58bccd6ed5ca Mon Sep 17 00:00:00 2001 From: zwbcc Date: Sat, 28 Mar 2026 20:26:10 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=88=86=E7=A6=BB?= =?UTF-8?q?=20HTML/CSS/JS=EF=BC=8C=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E6=9B=B4=E6=B8=85=E6=99=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- app.js | 507 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 171 ++++++++++++++++++ style.css | 80 +++++++++ 4 files changed, 764 insertions(+), 6 deletions(-) create mode 100644 app.js create mode 100644 index.html create mode 100644 style.css diff --git a/README.md b/README.md index e67f82d..c75200b 100644 --- a/README.md +++ b/README.md @@ -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` 端口 diff --git a/app.js b/app.js new file mode 100644 index 0000000..98fcda7 --- /dev/null +++ b/app.js @@ -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 = `[${ts}]${esc(msg)}`; + logArea.appendChild(d); + logArea.scrollTop = logArea.scrollHeight; +} + +function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } +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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..886b705 --- /dev/null +++ b/index.html @@ -0,0 +1,171 @@ + + + + + + MiniMax Speech HD TTS + + + +
+

🎙️ MiniMax Speech HD TTS

+

HTTP 流式文字转语音 · speech-2.8-hd

+ + + +
+ + +

填写后回车或点击空白处保存。刷新页面不丢失。

+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ 0 / 10000 +
+
+ +
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ + 空闲 +
+
+
+
+ + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..7c7fa7b --- /dev/null +++ b/style.css @@ -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; }