feat: stacked Conversation config + browser WAV decode; changelog (v1.10.1)

- Conversation Playground: STT/LLM/TTS/System prompt now full-width stacked.
- Fix conversation mic upload on ARM64: decode recording in-browser to
  16 kHz mono WAV, bypassing server ffmpeg webm/EBML parser (with fallback).
- Document v1.10.0 (unified Library + character tags) and v1.10.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 14:35:57 +02:00
parent 73c27944ac
commit fb7dcb52f8
5 changed files with 85 additions and 4 deletions

View File

@ -9,6 +9,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.10.1] — 2026-06-27
### Added
- **Collapsible nested sidebar**: group headers *Voice Actions*, *Speak* and *Setup* are now expandable parent menus; **Tags** nests under *Library*, and *Integrations* (App Routing · Connect Apps) and *Settings* nest under *Setup*. Opening a section auto-expands its whole ancestor chain.
### Changed
- **Conversation Playground** config stacks full-width — Speech to Text, Language Model, Text to Speech and System Prompt each on their own row for clarity.
### Fixed
- **Conversation microphone failed with "Audio upload failed: Decoding failed / EBML header parsing failed"** on ARM64: recorded audio is now decoded in the browser and uploaded as 16 kHz mono WAV, bypassing the server-side ffmpeg webm parser entirely (falls back to the raw blob if browser decoding is unavailable).
---
## [1.10.0] — 2026-06-27 ## [1.10.0] — 2026-06-27
### Added ### Added

View File

@ -1 +1 @@
1.10.0 1.10.1

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.10.0"> <link rel="stylesheet" href="/static/style.css?v=1.10.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -332,7 +332,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.10.0"></script> <script src="/static/loader.js?v=1.10.1"></script>
</body> </body>
</html> </html>

View File

@ -808,6 +808,59 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
} }
// ── Send turn via SSE ───────────────────────────────────────────────────── // ── Send turn via SSE ─────────────────────────────────────────────────────
// Decode a recorded blob (webm/opus, ogg, …) in the browser and re-encode it
// as a 16 kHz mono WAV. This sidesteps server-side ffmpeg, whose webm/EBML
// parser fails on some platforms (ARM64): "EBML header parsing failed".
// Returns a WAV Blob, or null if decoding isn't possible (caller falls back).
async function blobToWav16k(blob) {
try {
if (!blob || !blob.size) return null;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
const buf = await blob.arrayBuffer();
const ctx = new AC();
let audio;
try { audio = await ctx.decodeAudioData(buf.slice(0)); }
finally { try { ctx.close(); } catch (_) {} }
if (!audio || !audio.length) return null;
const targetRate = 16000;
const inRate = audio.sampleRate;
const chs = audio.numberOfChannels;
// Mix down to mono
const mono = new Float32Array(audio.length);
for (let c = 0; c < chs; c++) {
const d = audio.getChannelData(c);
for (let i = 0; i < d.length; i++) mono[i] += d[i] / chs;
}
// Linear resample to 16 kHz
const outLen = Math.max(1, Math.round(mono.length * targetRate / inRate));
const out = new Float32Array(outLen);
const ratio = mono.length / outLen;
for (let i = 0; i < outLen; i++) {
const pos = i * ratio, i0 = Math.floor(pos), i1 = Math.min(i0 + 1, mono.length - 1);
const f = pos - i0;
out[i] = mono[i0] * (1 - f) + mono[i1] * f;
}
// Encode 16-bit PCM WAV
const bytes = 44 + out.length * 2;
const ab = new ArrayBuffer(bytes);
const view = new DataView(ab);
const ws = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
ws(0, 'RIFF'); view.setUint32(4, bytes - 8, true); ws(8, 'WAVE');
ws(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, 1, true); view.setUint32(24, targetRate, true);
view.setUint32(28, targetRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
ws(36, 'data'); view.setUint32(40, out.length * 2, true);
let off = 44;
for (let i = 0; i < out.length; i++) {
let s = Math.max(-1, Math.min(1, out[i]));
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2;
}
return new Blob([ab], { type: 'audio/wav' });
} catch (_) { return null; }
}
async function processBlob(blob) { async function processBlob(blob) {
autoMicGeneration++; // cancel any pending auto-mic from previous turn autoMicGeneration++; // cancel any pending auto-mic from previous turn
clearAudio(); clearAudio();
@ -832,8 +885,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let assistantText = ''; let assistantText = '';
let lastStats = null; let lastStats = null;
// Convert to WAV in-browser so the server never has to ffmpeg-decode webm.
const wav = await blobToWav16k(blob);
const upBlob = wav || blob;
const upName = wav ? 'audio.wav' : 'audio.webm';
const form = new FormData(); const form = new FormData();
form.append('audio', blob, 'audio.webm'); form.append('audio', upBlob, upName);
form.append('stt_backend', sttSel?.value || 'configured'); form.append('stt_backend', sttSel?.value || 'configured');
form.append('llm_url', llmUrlInp?.value.trim() || ''); form.append('llm_url', llmUrlInp?.value.trim() || '');
form.append('llm_model', llmModelSel?.value || ''); form.append('llm_model', llmModelSel?.value || '');

View File

@ -2965,6 +2965,16 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Conversation Playground ─────────────────────────────────────────────── */ /* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; } .conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
/* Conversation: stack STT · LLM · TTS · System prompt full-width, one per row */
.conv-config-bar .engine-setup-row { flex-direction: column; flex-wrap: nowrap; gap: 12px; }
.conv-config-bar .engine-setup-col {
flex: 1 1 auto; width: 100%; min-width: 0;
border-right: none; padding-right: 0;
border-bottom: 1px solid var(--border); padding-bottom: 12px;
}
.conv-config-bar .engine-setup-col:last-child { border-bottom: none; padding-bottom: 0; }
.conv-config-bar .conv-prompt-row { margin-top: 12px; }
.conv-config-bar .conv-system-textarea { width: 100%; }
/* ── Unified Engine Config Rows (used across tools) ───────────────── */ /* ── Unified Engine Config Rows (used across tools) ───────────────── */
.engine-setup-row { .engine-setup-row {
display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start; display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;