v1.2.0: VAD, live preview, hands-free mic, restore last section

Conversation playground:
- Live speech preview: MediaRecorder sends accumulated audio to
  /api/transcribe-bytes every 2.5 s; interim Whisper result shown in
  the text input field while recording. Web Speech API tried first as
  a faster path when available (HTTPS/localhost).
- VAD auto-stop: AudioContext AnalyserNode measures RMS every frame;
  auto-stops after 1.5 s silence with a visible countdown. Auto-stop
  toggle to revert to click-to-stop.
- Hands-free mode: mic auto-restarts after the agent finishes speaking
  via audio.ended event + generation-counter cancellation. Hands-free
  toggle (on by default) to disable.

Navigation:
- Persist active section and sub-page in localStorage; hard-reload
  returns to the same page instead of always jumping to My Voices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-29 18:47:00 +02:00
parent b33829ded5
commit f4688ecc0c
6 changed files with 273 additions and 110 deletions

View File

@ -7,105 +7,75 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Performance
---
- **Self-hosted WaveSurfer and MDI icon font** — removed two render-blocking
`<script>` and one blocking `<link>` to `unpkg.com` / `cdn.jsdelivr.net`
from `<head>`. WaveSurfer (~57 KB) and MDI font (~394 KB WOFF2 + CSS) are
now served locally from `static/vendor/`. Flag-icons CSS is loaded async
(non-blocking) via the `rel=preload` / `onload` trick.
- **Skeleton loading view**`index.html` shows an animated shimmer
placeholder (page head + toolbar + 8 voice card outlines) immediately on
first paint, before any JS loads. The skeleton fades out once `loader.js`
finishes and real sections are revealed. No external deps — pure inline CSS.
### Fixed
- **LLM returned empty response (Qwen3 thinking mode)** — Qwen3 models
stream thinking tokens under `delta.reasoning_content` instead of
`delta.content`. The conversation turn now falls back to
`reasoning_content` so think-only responses produce visible output.
Error message improved with a hint to add `/no-think` to the system prompt.
### Performance
- **Parallel JS module loading**`loader.js` previously loaded all 16
modules sequentially (17 round-trips). Restructured into 4 ordered batches
with `async=false` so files are fetched in parallel but execute in the
correct dependency order:
`utils``settings`*(9 feature modules in parallel)*`init`
*(4 post-init modules in parallel)*`nav`
Round-trips reduced from 17 to 6; 9 files now download simultaneously.
- **Version-based JS/CSS cache busting**`loader.js` fetches
`/api/version` first and appends `?v=<version>` to every script URL.
A new `Cache-Control: public, max-age=31536000, immutable` middleware in
`server.py` lets the browser cache versioned assets for a full year.
Bumping the version (via `scripts/release.py`) invalidates the cache.
Section HTML keeps `?v=<timestamp>` (no-store) so it is always fresh.
### Changed
- **Config and logs are now bind-mounted local folders** — replaced the
opaque named Docker volume (`tts-voice-creator-clone-and-design-2`) with
two transparent host directories:
- `./config/``/home/app/.config/tts-voice-creator` — holds
`settings.json`, `voice_design_presets.json`, `tts_routes.json`
- `./logs/``/logs` — holds `app.log` (rotates at 5 MB, 3 backups)
Both directories are tracked in git (via `.gitkeep`) but their runtime
contents are excluded from version control via `.gitignore`.
`portainer-stack.yml` updated with absolute host paths.
- **Server writes a rotating log file**`RotatingFileHandler` added to
`server.py`; writes `INFO`-level and above to `./logs/app.log`.
### Fixed
- **Engine settings lost after container recreate** — container names and
dynamic Docker card URL overrides were stored only in `localStorage`.
Added `engine_container_names` as a persisted server setting; all
container name inputs tagged with `data-cn-key` so `loadSettings()`
can restore them; dynamic card URL overrides now saved under
`engine_local_urls` with a `dc-` prefix. On first page load the server
wins over `localStorage`; changes write to both immediately.
- **`ImportError: cannot import name '_AUDIO_EXTS' from 'core.audio'`** —
`routes/stt.py` had a dead alias `from core.audio import _to_wav_16k, _AUDIO_EXTS as _VOICE_AUDIO_EXTS`;
`_AUDIO_EXTS` lives in `core.voice`, not `core.audio`. Removed the
wrong import; `_AUDIO_EXTS` is already correctly imported from `core.voice`
on the next line and the unused alias was never referenced in the file.
- **`No module named 'core'` on container restart** — `docker restart` reuses
the old container configuration and never applies new volume mounts from
`docker-compose.yml`. Added a comment to `portainer-stack.yml` and updated
it to include `core:/app/core:ro` and `routes:/app/routes:ro`. Solution is
`docker compose up -d` (recreates the container) not `docker restart`.
- **Text-input turns returned 422**`UploadFile | None = File(None)` with
`from __future__ import annotations` caused FastAPI to treat `audio` as a
required field even when omitted. Changed to `Optional[UploadFile] = None`
(no `File()` wrapper) so the field is genuinely optional for text-only turns.
- **Conversation input bar hidden when mic unavailable** — the mic-blocked
warning box was inserted before `conv-chat-window` inside the flex column,
pushing the input bar off-screen. Fixed by prepending the warning inside
`conv-chat-window` so it scrolls with the chat and never affects the bar.
- **Chat panel overflow / missing input bar on smaller viewports** — removed
`min-height: 400px` from `conv-chat-window` (prevented shrinking) and gave
`conv-chat-panel` a viewport-relative height (`calc(100vh - 340px)`,
min 440px) so the input bar is always anchored at the bottom.
- **Browser caches old section HTML after updates**`loader.js` now appends
`?v=<timestamp>` to every section fetch, busting the cache on each page load.
## [1.2.0] — 2026-05-29
### Added
- **Remember last section on reload** — the active section (and Settings /
Engines sub-page) is persisted in `localStorage`. A hard-reload
(Ctrl+Shift+R) now returns to the same page instead of always jumping to
My Voices.
- **Conversation: live speech preview** — while recording, the active Whisper
STT backend transcribes accumulated audio every 2.5 s and shows the result
in the text input field in real time. The input is pre-populated with this
live guess before the final Whisper result arrives. Also tries the browser's
Web Speech API first (works on HTTPS / localhost) for even faster results.
- **Conversation: Voice Activity Detection (VAD)** — recording now auto-stops
after 1.5 s of silence detected via the Web Audio `AnalyserNode` RMS level.
A "Sending in X.Xs" countdown appears in the status bar so the timing is
visible. An **Auto-stop** toggle in the input bar lets users disable VAD and
revert to click-to-stop. A thin audio-level bar below the status line shows
microphone volume in real time during recording.
- **Conversation: hands-free mode** — after the agent finishes speaking, the
microphone restarts automatically. A **Hands-free** toggle (on by default)
disables this; clicking the mic button manually always cancels any pending
auto-restart.
- **`scripts/release.py`** — automates version bump + CHANGELOG promotion.
`python scripts/release.py --patch|--minor|--major [--dry-run]` renames
`[Unreleased]` to the new version, updates compare links, writes `VERSION`,
commits, and creates an annotated git tag in one command.
- **Git pre-commit hook** (`scripts/hooks/pre-commit`) — warns (does not block)
when `.py`/`.js`/`.css`/`.html` files are staged but `CHANGELOG.md` or
`VERSION` are not. Run `bash scripts/install-hooks.sh` after cloning.
- **`scripts/install-hooks.sh`** — one-liner to install the hook after a fresh
clone: `bash scripts/install-hooks.sh`.
- **Git pre-commit hook** (`scripts/hooks/pre-commit`) — warns (does not
block) when `.py`/`.js`/`.css`/`.html` files are staged but `CHANGELOG.md`
or `VERSION` are not. Run `bash scripts/install-hooks.sh` after cloning.
- **`scripts/install-hooks.sh`** — one-liner to install the hook after a
fresh clone: `bash scripts/install-hooks.sh`.
### Changed
- **Config and logs are now bind-mounted local folders** — replaced the
opaque named Docker volume with `./config/` and `./logs/` host directories.
`portainer-stack.yml` updated with absolute host paths.
- **Server writes a rotating log file**`RotatingFileHandler` writes
`INFO`-level and above to `./logs/app.log` (rotates at 5 MB, 3 backups).
### Performance
- **Skeleton loading view**`index.html` shows an animated shimmer
placeholder immediately on first paint; fades out once JS finishes loading.
- **Self-hosted WaveSurfer and MDI icon font** — removed render-blocking CDN
requests; assets now served locally from `static/vendor/`.
- **Parallel JS module loading** — restructured `loader.js` into 4 ordered
batches; round-trips reduced from 17 to 6, 9 files fetched simultaneously.
- **Version-based JS/CSS cache busting** — versioned assets served with
`max-age=31536000, immutable`; bumping version invalidates the cache.
### Fixed
- **LLM returned empty response (Qwen3 thinking mode)** — conversation turn
now falls back to `reasoning_content` for think-only responses; error
message hints to add `/no-think` to the system prompt.
- **Engine settings lost after container recreate** — container names and URL
overrides now persisted as server settings (`engine_container_names`,
`engine_local_urls`); restored from server on first page load.
- **Text-input turns returned 422** — changed `audio` form field to
`Optional[UploadFile] = None` so text-only turns don't require audio.
- **Conversation input bar hidden when mic unavailable** — warning box moved
inside `conv-chat-window` so it never pushes the input bar off-screen.
- **Browser caches old section HTML**`loader.js` appends `?v=<timestamp>`
to every section fetch.
- Various import errors and container restart issues fixed.
---
@ -279,6 +249,7 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/releases/tag/v1.0.0

View File

@ -1 +1 @@
1.1.0
1.2.0

View File

@ -173,6 +173,19 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let conversationHistory = [];
let turnCount = 0;
let isProcessing = false;
let vadAudioCtx = null;
let vadRafId = null;
let liveInterimText = '';
let speechRec = null;
const VAD_THRESHOLD = 0.01;
const VAD_MIN_REC_MS = 500;
const VAD_SILENCE_MS = 1500;
const origPlaceholder = textInput?.placeholder || '';
const vadToggle = $('conv-vad-toggle');
const handsFreeToggle = $('conv-handsfree-toggle');
let previewTranscribing = false;
let convCurrentAudio = null;
let autoMicGeneration = 0;
// ── Populate STT backends ────────────────────────────────────────────────
async function loadConvSttBackends() {
@ -363,6 +376,45 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (micTimer) micTimer.textContent = '';
}
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
async function transcribeForPreview() {
if (previewTranscribing || !recChunks.length) return;
previewTranscribing = true;
try {
const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
const ext = mime.includes('ogg') ? '.ogg' : '.webm';
const blob = new Blob(recChunks, { type: mime });
const fd = new FormData();
fd.append('file', new File([blob], 'preview' + ext, { type: mime }));
fd.append('backend', sttSel?.value || 'configured');
const r = await fetch('/api/transcribe-bytes', { method: 'POST', body: fd });
if (r.ok) {
const d = await r.json();
const txt = (d.text || '').trim();
if (txt && mediaRecorder && mediaRecorder.state === 'recording') {
liveInterimText = txt;
if (textInput) textInput.value = txt;
}
}
} catch (_) {}
finally { previewTranscribing = false; }
}
// ── Auto-restart mic after agent finishes speaking ───────────────────────
function scheduleAutoMic() {
if (!handsFreeToggle?.checked) return;
const gen = ++autoMicGeneration;
function tryStart() {
if (gen !== autoMicGeneration || isProcessing) return;
startRecording().catch(() => {});
}
if (convCurrentAudio && !convCurrentAudio.ended) {
convCurrentAudio.addEventListener('ended', () => setTimeout(tryStart, 200), { once: true });
} else {
setTimeout(tryStart, 300);
}
}
async function startRecording() {
if (isProcessing) return;
if (!navigator.mediaDevices?.getUserMedia) {
@ -377,23 +429,119 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
toast('Microphone access denied: ' + e.message, 'error');
return;
}
liveInterimText = '';
previewTranscribing = false;
recChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); };
mediaRecorder.ondataavailable = e => {
if (e.data.size > 0) {
recChunks.push(e.data);
// Trigger Whisper preview on periodic chunks (not on the final stop chunk)
if (mediaRecorder.state === 'recording') transcribeForPreview();
}
};
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
processBlob(blob);
};
mediaRecorder.start();
// timeslice=2500: ondataavailable fires every 2.5 s → interim Whisper preview
mediaRecorder.start(2500);
micBtn.classList.add('recording');
micIcon.className = 'mdi mdi-stop';
if (micStatus) micStatus.textContent = 'Recording… click to stop';
if (micStatus) micStatus.textContent = 'Recording…';
startRecTimer();
// Set input to listening mode (read-only; Whisper preview text will appear here)
if (textInput) {
textInput.readOnly = true;
textInput.value = '';
textInput.placeholder = 'Listening…';
textInput.classList.add('listening');
}
if (sendBtn) sendBtn.disabled = true;
// Also try Web Speech API for faster interim results (works on HTTPS / localhost)
const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRec) {
try {
speechRec = new SpeechRec();
speechRec.continuous = true;
speechRec.interimResults = true;
speechRec.onresult = e => {
let final = '', interim = '';
for (let i = 0; i < e.results.length; i++) {
if (e.results[i].isFinal) final += e.results[i][0].transcript;
else interim += e.results[i][0].transcript;
}
liveInterimText = final + interim;
if (textInput) textInput.value = liveInterimText;
};
speechRec.onerror = () => { speechRec = null; };
speechRec.start();
} catch(_) { speechRec = null; }
}
// VAD: auto-stop on silence using AudioContext
if (vadToggle?.checked) {
try {
vadAudioCtx = new AudioContext();
const src = vadAudioCtx.createMediaStreamSource(stream);
const analyser = vadAudioCtx.createAnalyser();
analyser.fftSize = 1024;
src.connect(analyser);
const vadBuf = new Float32Array(analyser.fftSize);
let vadLastVoice = Date.now();
const levelWrap = $('conv-level-wrap');
const levelFill = $('conv-level-fill');
if (levelWrap) levelWrap.classList.add('active');
function vadTick() {
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
analyser.getFloatTimeDomainData(vadBuf);
let rms = 0;
for (const s of vadBuf) rms += s * s;
rms = Math.sqrt(rms / vadBuf.length);
if (levelFill) levelFill.style.width = Math.min(100, rms * 5000) + '%';
if (rms > VAD_THRESHOLD) vadLastVoice = Date.now();
const elapsed = Date.now() - recStart;
const silence = Date.now() - vadLastVoice;
if (elapsed > VAD_MIN_REC_MS) {
const remaining = VAD_SILENCE_MS - silence;
if (micStatus) {
micStatus.textContent = remaining < VAD_SILENCE_MS * 0.7
? `Sending in ${(Math.max(0, remaining) / 1000).toFixed(1)}s…`
: 'Recording…';
}
if (silence >= VAD_SILENCE_MS) {
stopRecording();
return;
}
}
vadRafId = requestAnimationFrame(vadTick);
}
vadRafId = requestAnimationFrame(vadTick);
} catch(_) { vadAudioCtx = null; }
}
}
function stopRecording() {
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
// Clean up VAD
cancelAnimationFrame(vadRafId);
vadRafId = null;
if (vadAudioCtx) { try { vadAudioCtx.close(); } catch(_){} vadAudioCtx = null; }
const levelWrap = $('conv-level-wrap');
const levelFill = $('conv-level-fill');
if (levelWrap) levelWrap.classList.remove('active');
if (levelFill) levelFill.style.width = '0%';
// Clean up live speech recognition
if (speechRec) { try { speechRec.stop(); } catch(_){} speechRec = null; }
previewTranscribing = false;
mediaRecorder.stop();
stopRecTimer();
micBtn.classList.remove('recording');
@ -405,12 +553,24 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// ── Send turn via SSE ─────────────────────────────────────────────────────
async function processBlob(blob) {
autoMicGeneration++; // cancel any pending auto-mic from previous turn
convCurrentAudio = null;
turnCount++;
const turnN = turnCount;
const t0 = Date.now();
// Show user bubble with placeholder
const userBubble = addBubble('user', '…');
// Transition input from listening/readOnly state to processing/disabled state
if (textInput) {
textInput.readOnly = false;
textInput.classList.remove('listening');
textInput.placeholder = origPlaceholder;
textInput.value = '';
textInput.disabled = true;
}
if (sendBtn) sendBtn.disabled = true;
// Show user bubble seeded with live interim transcript (if available)
const userBubble = addBubble('user', liveInterimText || '…');
const assistantBubble = addTypingBubble();
let assistantText = '';
let lastStats = null;
@ -464,9 +624,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
const audioBlob = new Blob([arr], { type: mime });
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play().catch(() => {});
const audioEl = new Audio(url);
convCurrentAudio = audioEl;
audioEl.addEventListener('ended', () => { URL.revokeObjectURL(url); convCurrentAudio = null; });
audioEl.play().catch(() => {});
if (micStatus) micStatus.textContent = 'Speaking…';
} else if (evt.type === 'stats') {
lastStats = evt;
@ -475,7 +636,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
conversationHistory.push({ role: 'user', content: userBubble.textContent });
conversationHistory.push({ role: 'assistant', content: assistantText });
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
if (micStatus) micStatus.textContent = 'Ready';
scheduleAutoMic();
if (!handsFreeToggle?.checked && micStatus) micStatus.textContent = 'Ready';
} else if (evt.type === 'error') {
const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove();
@ -499,11 +661,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
addHistoryItem(turnN, Date.now() - t0, false);
if (micStatus) micStatus.textContent = 'Error — ready';
} finally {
liveInterimText = '';
previewTranscribing = false;
isProcessing = false;
micBtn.classList.remove('processing');
micIcon.className = 'mdi mdi-microphone';
if (sendBtn) sendBtn.disabled = false;
if (textInput) textInput.disabled = false;
if (textInput) { textInput.disabled = false; textInput.value = ''; }
}
}
@ -511,6 +675,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
async function processText(text) {
text = text.trim();
if (!text || isProcessing) return;
autoMicGeneration++; // cancel any pending auto-mic
convCurrentAudio = null;
isProcessing = true;
if (sendBtn) sendBtn.disabled = true;
if (textInput) { textInput.disabled = true; textInput.value = ''; }
@ -573,9 +739,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
const audioBlob = new Blob([arr], { type: mime });
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play().catch(() => {});
const audioEl = new Audio(url);
convCurrentAudio = audioEl;
audioEl.addEventListener('ended', () => { URL.revokeObjectURL(url); convCurrentAudio = null; });
audioEl.play().catch(() => {});
if (micStatus) micStatus.textContent = 'Speaking…';
} else if (evt.type === 'stats') {
lastStats = evt;
@ -584,7 +751,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
conversationHistory.push({ role: 'user', content: text });
conversationHistory.push({ role: 'assistant', content: assistantText });
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
if (micStatus) micStatus.textContent = 'Ready';
scheduleAutoMic();
if (!handsFreeToggle?.checked && micStatus) micStatus.textContent = 'Ready';
} else if (evt.type === 'error') {
const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove();
@ -612,6 +780,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// ── Wire up events ───────────────────────────────────────────────────────
micBtn.addEventListener('click', () => {
if (isProcessing) return;
autoMicGeneration++; // cancel any pending hands-free auto-restart
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {

View File

@ -40,6 +40,7 @@
}
function showSection(sectionId) {
localStorage.setItem('ttsvc_section', sectionId);
SECTIONS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.classList.toggle('is-active', id === sectionId);
@ -124,6 +125,7 @@
window.navSettingsCat = function (cat) {
window._settingsSidebarCat = cat;
localStorage.setItem('ttsvc_settings_cat', cat);
navTo('s-settings'); // showSection will call applySettingsPage(cat)
if (cat === 'logs' && typeof loadSettingsLogs === 'function') loadSettingsLogs();
if (cat === 'about' && typeof renderSettingsAbout === 'function') renderSettingsAbout();
@ -143,11 +145,17 @@
window.navEnginesCat = function (cat) {
window._enginesSidebarCat = cat;
localStorage.setItem('ttsvc_engines_cat', cat);
navTo('s-llms'); // showSection will call applyEnginesPage(cat)
if ((cat === 'tts' || cat === 'stt') && typeof loadLocalContainers === 'function') loadLocalContainers();
};
// Show default section on load
showSection('s-voices');
runSideEffects('library');
// Restore last-used section from localStorage (fallback: My Voices)
var _savedSection = localStorage.getItem('ttsvc_section');
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
var _startSection = (_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices';
showSection(_startSection);
var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library';
runSideEffects(_startTab);
})();

View File

@ -54,6 +54,7 @@
<!-- Input bar: text field + send + mic -->
<div class="conv-input-bar">
<div class="conv-mic-status" id="conv-mic-status">Ready</div>
<div class="conv-level-wrap" id="conv-level-wrap"><div class="conv-level-fill" id="conv-level-fill"></div></div>
<div class="conv-text-row">
<input id="conv-text-input" class="conv-text-inp"
type="text" placeholder="Type a message and press Enter or →"
@ -66,6 +67,14 @@
<span class="mdi mdi-microphone" id="conv-mic-icon"></span>
</button>
<div class="conv-mic-timer" id="conv-mic-timer"></div>
<label class="conv-vad-label" title="Auto-stop recording when silence is detected">
<input type="checkbox" id="conv-vad-toggle" checked>
<span>Auto-stop</span>
</label>
<label class="conv-vad-label" title="Automatically restart microphone after the agent finishes speaking">
<input type="checkbox" id="conv-handsfree-toggle" checked>
<span>Hands-free</span>
</label>
</div>
</div>
</div>

View File

@ -2093,6 +2093,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.conv-mic-btn.processing { background: var(--yellow); border-color: var(--yellow); cursor: default; }
@keyframes convPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); } 50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); } }
.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; }
.conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; }
.conv-vad-label input { cursor: pointer; accent-color: var(--accent); }
.conv-level-wrap { height: 2px; background: var(--panel); border-radius: 1px; margin: 3px 0; overflow: hidden; display: none; }
.conv-level-wrap.active { display: block; }
.conv-level-fill { height: 100%; background: var(--red); border-radius: 1px; width: 0%; }
.conv-text-inp.listening { border-color: var(--red); font-style: italic; color: var(--subtext); }
/* Stats panel */
.conv-stats-head { font-size: 10px; font-weight: 800; color: var(--subtext); text-transform: uppercase; letter-spacing: .08em; }