From 8a03fbb689f329140f965cd280946e7a84a575af Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 03:11:43 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20voice=20=E2=80=94=20recorder=20codec=20c?= =?UTF-8?q?hain=20with=20iOS=20mp4=20fallback,=20audio-session=20unlock=20?= =?UTF-8?q?on=20voice=20gestures,=20unbounded=20image=20poll=20until=20com?= =?UTF-8?q?pletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/assistant.css | 11 ++++++----- public/js/app.js | 11 ++++++++--- public/js/clinicalAssistant.js | 19 ++++++++++++++++--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/public/css/assistant.css b/public/css/assistant.css index 36f64ab..796bc6b 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -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; } diff --git a/public/js/app.js b/public/js/app.js index 0ba1ce9..d6859a2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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); }); diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index f11bedb..a6e4439 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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) {