fix: voice — recorder codec chain with iOS mp4 fallback, audio-session unlock on voice gestures, unbounded image poll until completion

This commit is contained in:
Daniel 2026-09-09 03:11:43 +02:00
parent 6760f34143
commit 8a03fbb689
3 changed files with 30 additions and 11 deletions

View file

@ -9,7 +9,7 @@
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
.assistant-status.error .assistant-dot { background:var(--red); }
.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:start; }
.assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:stretch; }
.assistant-layout > * { min-width:0; }
.assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:52px; }
.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; }
@ -117,8 +117,8 @@
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
/* Sources own the full right column now that the image panel is gone */
.assistant-side { display:flex; flex-direction:column; gap:12px; min-height:0; }
/* Sources: a full-page right column; the panel fills the height and scrolls */
.assistant-side { display:flex; flex-direction:column; gap:12px; height:100%; min-height:0; }
.assistant-side .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.assistant-generated-image { display:grid; gap:8px; }
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
@ -129,7 +129,7 @@
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
.assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
.assistant-sources { padding:10px 12px; display:flex; flex-direction:column; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
.assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
.assistant-saved-chat { border:1px solid transparent; border-radius:10px; padding:7px 10px; background:transparent; display:flex; flex-direction:column; gap:1px; width:100%; text-align:left; cursor:pointer; color:inherit; font:inherit; min-width:0; }
.assistant-saved-chat:hover { background:var(--g100, #f3f4f6); border-color:transparent; }
@ -241,7 +241,8 @@
@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } }
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
body.assistant-workspace .assistant-layout { height: 100vh; }
body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-height: 0; }
.assistant-layout > * { min-height: 0; height: 100%; }
body.assistant-workspace .assistant-main { min-height: 0; }
body.assistant-workspace #assistant-chat-view { min-height: 0; }
body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh; overflow-y: auto; }

View file

@ -635,9 +635,14 @@ AudioRecorder.prototype.start = function() {
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
.then(function(stream) {
self.stream = stream;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
// 32kbps Opus is excellent for speech — small files, fast upload, great quality
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
// Codec chain: Opus webm, plain webm, then mp4 (iOS Safari), then the browser default.
var options = { audioBitsPerSecond: 32000 };
var mime = '';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) mime = 'audio/webm;codecs=opus';
else if (MediaRecorder.isTypeSupported('audio/webm')) mime = 'audio/webm';
else if (MediaRecorder.isTypeSupported('audio/mp4')) mime = 'audio/mp4';
if (mime) options.mimeType = mime;
self.mediaRecorder = new MediaRecorder(stream, options);
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
self.mediaRecorder.start(1000);
});

View file

@ -897,6 +897,7 @@ import {
}
function startAssistantMic() {
unlockAudioPlayback();
var input = document.getElementById('assistant-input');
assistantMic.finalText = String(input && input.value || '');
assistantMic.sessionFinals = '';
@ -982,7 +983,21 @@ import {
else conversationStartListening();
}
// iOS/Safari blocks programmatic audio playback until a user gesture
// unlocks the audio session. Unlock on every tap of a voice control.
var AUDIO_UNLOCK_WAV = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
function unlockAudioPlayback() {
try {
var probe = new Audio(AUDIO_UNLOCK_WAV);
probe.muted = true;
var p = probe.play();
if (p && typeof p.catch === 'function') p.catch(function() {});
setTimeout(function() { try { probe.pause(); probe.src = ''; } catch (e) {} }, 250);
} catch (e) {}
}
function conversationStartListening() {
unlockAudioPlayback();
var status = document.getElementById('assistant-voice-status');
if (status) status.textContent = 'Listening…';
conversationMode.listening = true;
@ -1191,10 +1206,8 @@ import {
exporter.invalidate();
if (typeof hooks.onStatus === 'function') hooks.onStatus('Generating image…');
// The job runs server-side; closing the popup never cancels it.
var attempts = 0;
var poll = function () {
attempts += 1;
if (attempts > 60) { if (typeof hooks.onError === 'function') hooks.onError('Image is still generating — it will appear in your images shortly.'); return; }
// Keep polling until the job finishes; image models can take minutes.
fetchAssistantImageJob(data.jobId).then(function (job) {
if (!job || !job.success) { setTimeout(poll, 2500); return; }
if (job.imageUrl) {