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:
parent
b33829ded5
commit
f4688ecc0c
155
CHANGELOG.md
155
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
})();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user