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:
parent
73c27944ac
commit
fb7dcb52f8
13
CHANGELOG.md
13
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
|
## [1.10.0] — 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 || '');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user