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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Performance
|
---
|
||||||
|
|
||||||
- **Self-hosted WaveSurfer and MDI icon font** — removed two render-blocking
|
## [1.2.0] — 2026-05-29
|
||||||
`<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.
|
|
||||||
|
|
||||||
### Added
|
### 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.
|
- **`scripts/release.py`** — automates version bump + CHANGELOG promotion.
|
||||||
`python scripts/release.py --patch|--minor|--major [--dry-run]` renames
|
`python scripts/release.py --patch|--minor|--major [--dry-run]` renames
|
||||||
`[Unreleased]` to the new version, updates compare links, writes `VERSION`,
|
`[Unreleased]` to the new version, updates compare links, writes `VERSION`,
|
||||||
commits, and creates an annotated git tag in one command.
|
commits, and creates an annotated git tag in one command.
|
||||||
- **Git pre-commit hook** (`scripts/hooks/pre-commit`) — warns (does not block)
|
- **Git pre-commit hook** (`scripts/hooks/pre-commit`) — warns (does not
|
||||||
when `.py`/`.js`/`.css`/`.html` files are staged but `CHANGELOG.md` or
|
block) when `.py`/`.js`/`.css`/`.html` files are staged but `CHANGELOG.md`
|
||||||
`VERSION` are not. Run `bash scripts/install-hooks.sh` after cloning.
|
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
|
- **`scripts/install-hooks.sh`** — one-liner to install the hook after a
|
||||||
clone: `bash scripts/install-hooks.sh`.
|
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.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
|
[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 conversationHistory = [];
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
let isProcessing = false;
|
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 ────────────────────────────────────────────────
|
// ── Populate STT backends ────────────────────────────────────────────────
|
||||||
async function loadConvSttBackends() {
|
async function loadConvSttBackends() {
|
||||||
@ -363,6 +376,45 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
if (micTimer) micTimer.textContent = '';
|
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() {
|
async function startRecording() {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
@ -377,23 +429,119 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
toast('Microphone access denied: ' + e.message, 'error');
|
toast('Microphone access denied: ' + e.message, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveInterimText = '';
|
||||||
|
previewTranscribing = false;
|
||||||
recChunks = [];
|
recChunks = [];
|
||||||
mediaRecorder = new MediaRecorder(stream);
|
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 = () => {
|
mediaRecorder.onstop = () => {
|
||||||
stream.getTracks().forEach(t => t.stop());
|
stream.getTracks().forEach(t => t.stop());
|
||||||
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||||
processBlob(blob);
|
processBlob(blob);
|
||||||
};
|
};
|
||||||
mediaRecorder.start();
|
// timeslice=2500: ondataavailable fires every 2.5 s → interim Whisper preview
|
||||||
|
mediaRecorder.start(2500);
|
||||||
micBtn.classList.add('recording');
|
micBtn.classList.add('recording');
|
||||||
micIcon.className = 'mdi mdi-stop';
|
micIcon.className = 'mdi mdi-stop';
|
||||||
if (micStatus) micStatus.textContent = 'Recording… click to stop';
|
if (micStatus) micStatus.textContent = 'Recording…';
|
||||||
startRecTimer();
|
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() {
|
function stopRecording() {
|
||||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
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();
|
mediaRecorder.stop();
|
||||||
stopRecTimer();
|
stopRecTimer();
|
||||||
micBtn.classList.remove('recording');
|
micBtn.classList.remove('recording');
|
||||||
@ -405,12 +553,24 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
|
|
||||||
// ── Send turn via SSE ─────────────────────────────────────────────────────
|
// ── Send turn via SSE ─────────────────────────────────────────────────────
|
||||||
async function processBlob(blob) {
|
async function processBlob(blob) {
|
||||||
|
autoMicGeneration++; // cancel any pending auto-mic from previous turn
|
||||||
|
convCurrentAudio = null;
|
||||||
turnCount++;
|
turnCount++;
|
||||||
const turnN = turnCount;
|
const turnN = turnCount;
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
|
||||||
// Show user bubble with placeholder
|
// Transition input from listening/readOnly state to processing/disabled state
|
||||||
const userBubble = addBubble('user', '…');
|
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();
|
const assistantBubble = addTypingBubble();
|
||||||
let assistantText = '';
|
let assistantText = '';
|
||||||
let lastStats = null;
|
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);
|
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
|
||||||
const audioBlob = new Blob([arr], { type: mime });
|
const audioBlob = new Blob([arr], { type: mime });
|
||||||
const url = URL.createObjectURL(audioBlob);
|
const url = URL.createObjectURL(audioBlob);
|
||||||
const audio = new Audio(url);
|
const audioEl = new Audio(url);
|
||||||
audio.onended = () => URL.revokeObjectURL(url);
|
convCurrentAudio = audioEl;
|
||||||
audio.play().catch(() => {});
|
audioEl.addEventListener('ended', () => { URL.revokeObjectURL(url); convCurrentAudio = null; });
|
||||||
|
audioEl.play().catch(() => {});
|
||||||
if (micStatus) micStatus.textContent = 'Speaking…';
|
if (micStatus) micStatus.textContent = 'Speaking…';
|
||||||
} else if (evt.type === 'stats') {
|
} else if (evt.type === 'stats') {
|
||||||
lastStats = evt;
|
lastStats = evt;
|
||||||
@ -475,7 +636,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
conversationHistory.push({ role: 'user', content: userBubble.textContent });
|
conversationHistory.push({ role: 'user', content: userBubble.textContent });
|
||||||
conversationHistory.push({ role: 'assistant', content: assistantText });
|
conversationHistory.push({ role: 'assistant', content: assistantText });
|
||||||
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
|
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') {
|
} else if (evt.type === 'error') {
|
||||||
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
||||||
if (wrap) wrap.remove();
|
if (wrap) wrap.remove();
|
||||||
@ -499,11 +661,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
addHistoryItem(turnN, Date.now() - t0, false);
|
addHistoryItem(turnN, Date.now() - t0, false);
|
||||||
if (micStatus) micStatus.textContent = 'Error — ready';
|
if (micStatus) micStatus.textContent = 'Error — ready';
|
||||||
} finally {
|
} finally {
|
||||||
|
liveInterimText = '';
|
||||||
|
previewTranscribing = false;
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
micBtn.classList.remove('processing');
|
micBtn.classList.remove('processing');
|
||||||
micIcon.className = 'mdi mdi-microphone';
|
micIcon.className = 'mdi mdi-microphone';
|
||||||
if (sendBtn) sendBtn.disabled = false;
|
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) {
|
async function processText(text) {
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
if (!text || isProcessing) return;
|
if (!text || isProcessing) return;
|
||||||
|
autoMicGeneration++; // cancel any pending auto-mic
|
||||||
|
convCurrentAudio = null;
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
if (sendBtn) sendBtn.disabled = true;
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
if (textInput) { textInput.disabled = true; textInput.value = ''; }
|
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);
|
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
|
||||||
const audioBlob = new Blob([arr], { type: mime });
|
const audioBlob = new Blob([arr], { type: mime });
|
||||||
const url = URL.createObjectURL(audioBlob);
|
const url = URL.createObjectURL(audioBlob);
|
||||||
const audio = new Audio(url);
|
const audioEl = new Audio(url);
|
||||||
audio.onended = () => URL.revokeObjectURL(url);
|
convCurrentAudio = audioEl;
|
||||||
audio.play().catch(() => {});
|
audioEl.addEventListener('ended', () => { URL.revokeObjectURL(url); convCurrentAudio = null; });
|
||||||
|
audioEl.play().catch(() => {});
|
||||||
if (micStatus) micStatus.textContent = 'Speaking…';
|
if (micStatus) micStatus.textContent = 'Speaking…';
|
||||||
} else if (evt.type === 'stats') {
|
} else if (evt.type === 'stats') {
|
||||||
lastStats = evt;
|
lastStats = evt;
|
||||||
@ -584,7 +751,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
conversationHistory.push({ role: 'user', content: text });
|
conversationHistory.push({ role: 'user', content: text });
|
||||||
conversationHistory.push({ role: 'assistant', content: assistantText });
|
conversationHistory.push({ role: 'assistant', content: assistantText });
|
||||||
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
|
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') {
|
} else if (evt.type === 'error') {
|
||||||
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
const wrap = assistantBubble.closest('.conv-bubble-wrap');
|
||||||
if (wrap) wrap.remove();
|
if (wrap) wrap.remove();
|
||||||
@ -612,6 +780,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
// ── Wire up events ───────────────────────────────────────────────────────
|
// ── Wire up events ───────────────────────────────────────────────────────
|
||||||
micBtn.addEventListener('click', () => {
|
micBtn.addEventListener('click', () => {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
autoMicGeneration++; // cancel any pending hands-free auto-restart
|
||||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
stopRecording();
|
stopRecording();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSection(sectionId) {
|
function showSection(sectionId) {
|
||||||
|
localStorage.setItem('ttsvc_section', sectionId);
|
||||||
SECTIONS.forEach(function (id) {
|
SECTIONS.forEach(function (id) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (el) el.classList.toggle('is-active', id === sectionId);
|
if (el) el.classList.toggle('is-active', id === sectionId);
|
||||||
@ -124,6 +125,7 @@
|
|||||||
|
|
||||||
window.navSettingsCat = function (cat) {
|
window.navSettingsCat = function (cat) {
|
||||||
window._settingsSidebarCat = cat;
|
window._settingsSidebarCat = cat;
|
||||||
|
localStorage.setItem('ttsvc_settings_cat', cat);
|
||||||
navTo('s-settings'); // showSection will call applySettingsPage(cat)
|
navTo('s-settings'); // showSection will call applySettingsPage(cat)
|
||||||
if (cat === 'logs' && typeof loadSettingsLogs === 'function') loadSettingsLogs();
|
if (cat === 'logs' && typeof loadSettingsLogs === 'function') loadSettingsLogs();
|
||||||
if (cat === 'about' && typeof renderSettingsAbout === 'function') renderSettingsAbout();
|
if (cat === 'about' && typeof renderSettingsAbout === 'function') renderSettingsAbout();
|
||||||
@ -143,11 +145,17 @@
|
|||||||
|
|
||||||
window.navEnginesCat = function (cat) {
|
window.navEnginesCat = function (cat) {
|
||||||
window._enginesSidebarCat = cat;
|
window._enginesSidebarCat = cat;
|
||||||
|
localStorage.setItem('ttsvc_engines_cat', cat);
|
||||||
navTo('s-llms'); // showSection will call applyEnginesPage(cat)
|
navTo('s-llms'); // showSection will call applyEnginesPage(cat)
|
||||||
if ((cat === 'tts' || cat === 'stt') && typeof loadLocalContainers === 'function') loadLocalContainers();
|
if ((cat === 'tts' || cat === 'stt') && typeof loadLocalContainers === 'function') loadLocalContainers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show default section on load
|
// Restore last-used section from localStorage (fallback: My Voices)
|
||||||
showSection('s-voices');
|
var _savedSection = localStorage.getItem('ttsvc_section');
|
||||||
runSideEffects('library');
|
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 -->
|
<!-- Input bar: text field + send + mic -->
|
||||||
<div class="conv-input-bar">
|
<div class="conv-input-bar">
|
||||||
<div class="conv-mic-status" id="conv-mic-status">Ready</div>
|
<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">
|
<div class="conv-text-row">
|
||||||
<input id="conv-text-input" class="conv-text-inp"
|
<input id="conv-text-input" class="conv-text-inp"
|
||||||
type="text" placeholder="Type a message and press Enter or →"
|
type="text" placeholder="Type a message and press Enter or →"
|
||||||
@ -66,6 +67,14 @@
|
|||||||
<span class="mdi mdi-microphone" id="conv-mic-icon"></span>
|
<span class="mdi mdi-microphone" id="conv-mic-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="conv-mic-timer" id="conv-mic-timer"></div>
|
<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>
|
</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; }
|
.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); } }
|
@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-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 */
|
/* Stats panel */
|
||||||
.conv-stats-head { font-size: 10px; font-weight: 800; color: var(--subtext); text-transform: uppercase; letter-spacing: .08em; }
|
.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