diff --git a/CHANGELOG.md b/CHANGELOG.md index 709c018..117bfa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Added diff --git a/VERSION b/VERSION index 81c871d..4dae298 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.0 +1.10.1 diff --git a/static/index.html b/static/index.html index 831362f..f806412 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,7 @@ - + @@ -332,7 +332,7 @@ - + diff --git a/static/js/conversation.js b/static/js/conversation.js index 76f068c..6133492 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -808,6 +808,59 @@ $('s-import-voices-file')?.addEventListener('change', async function () { } // ── 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) { autoMicGeneration++; // cancel any pending auto-mic from previous turn clearAudio(); @@ -832,8 +885,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () { let assistantText = ''; 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(); - form.append('audio', blob, 'audio.webm'); + form.append('audio', upBlob, upName); form.append('stt_backend', sttSel?.value || 'configured'); form.append('llm_url', llmUrlInp?.value.trim() || ''); form.append('llm_model', llmModelSel?.value || ''); diff --git a/static/style.css b/static/style.css index 6399554..a07a89c 100644 --- a/static/style.css +++ b/static/style.css @@ -2965,6 +2965,16 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami /* ── Conversation Playground ─────────────────────────────────────────────── */ .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) ───────────────── */ .engine-setup-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;