diff --git a/internal/web/server.go b/internal/web/server.go index 2fe4b65b..8c128465 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -689,6 +689,43 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { return grid.querySelector(".chat-repo-card[aria-expanded='true'] .chat-repo-prompt"); } + function activeChatSpeechStream() { + const input = activeChatPromptInput(); + return input ? input.closest(".speech-stream-wrap")?.querySelector(".speech-stream") : null; + } + + function clampSpeechStreamLevel(level) { + if (!Number.isFinite(level)) return 0; + return Math.max(0, Math.min(1, level)); + } + + function setSpeechStreamActive(stream, active) { + if (!(stream instanceof HTMLElement)) return; + stream.classList.toggle("is-active", Boolean(active)); + if (!active) { + stream.style.setProperty("--speech-level", "0"); + } + } + + function setSpeechStreamLevel(stream, level) { + if (!(stream instanceof HTMLElement)) return; + stream.style.setProperty("--speech-level", clampSpeechStreamLevel(level).toFixed(3)); + } + + function speechStreamLevelFromSamples(input) { + if (!input || !input.length) return 0; + let sum = 0; + let peak = 0; + for (let i = 0; i < input.length; i += 1) { + const sample = Math.max(-1, Math.min(1, Number(input[i]) || 0)); + const absSample = Math.abs(sample); + if (absSample > peak) peak = absSample; + sum += sample * sample; + } + const rms = Math.sqrt(sum / input.length); + return Math.max(peak * 1.8, rms * 16); + } + function setChatSpeechStatus(tone, message) { status.textContent = message; status.dataset.tone = tone || ""; @@ -754,6 +791,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { setChatSpeechStatus("error", "Open a repository prompt before dictating."); return; } + const stream = activeChatSpeechStream(); if (!browserSpeechCaptureSupported()) { setChatSpeechStatus("error", "This browser does not support microphone capture."); return; @@ -790,6 +828,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { const inputBuffer = event.inputBuffer.getChannelData(0); const downsampled = downsampleSpeech(inputBuffer, speech.audioContext.sampleRate, SPEECH_SAMPLE_RATE); updateSpeechCaptureStats(downsampled); + setSpeechStreamLevel(stream, speechStreamLevelFromSamples(downsampled)); speech.chunks.push(floatToSpeechPCM(downsampled)); }; speech.source.connect(speech.processor); @@ -797,6 +836,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { speech.zeroGain.connect(speech.audioContext.destination); speech.recording = true; syncChatSpeechButton(); + setSpeechStreamActive(stream, true); input.focus(); setChatSpeechStatus("warn", "Listening. Press the mic again to stop."); trackChatEvent("chat_speech_started"); @@ -871,6 +911,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { speech.zeroGain = null; speech.chunks = []; resetSpeechCaptureStats(); + setSpeechStreamActive(activeChatSpeechStream(), false); syncChatSpeechButton(); } @@ -1078,6 +1119,12 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { input.rows = 3; input.placeholder = "Prompt for default branch"; input.setAttribute("aria-label", "Prompt for " + String(repo.full_name || repo.name || "repository")); + const inputWrap = document.createElement("span"); + inputWrap.className = "speech-stream-wrap chat-repo-prompt-wrap"; + const speechStream = document.createElement("span"); + speechStream.className = "speech-stream chat-speech-stream"; + speechStream.setAttribute("aria-hidden", "true"); + inputWrap.append(input, speechStream); const panelStatus = document.createElement("span"); panelStatus.className = "chat-repo-submit-status"; @@ -1096,7 +1143,7 @@ func (s Server) handleChat(w http.ResponseWriter, r *http.Request) { input.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); - panel.append(input, panelStatus); + panel.append(inputWrap, panelStatus); card.appendChild(panel); const openPrompt = () => { diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 6322abca..b5f13792 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -185,12 +185,15 @@ @@ -1913,6 +1916,7 @@

Connect to Hub

const localPromptStatusDefaultText = String(localPromptStatus?.dataset.defaultText || "").trim(); let localPromptStatusTimer = 0; const promptSpeechToggle = document.getElementById("prompt-speech-toggle"); + const builderSpeechStream = document.getElementById("builder-speech-stream"); const promptModeBuilder = document.getElementById("prompt-mode-builder"); const promptModeLibrary = document.getElementById("prompt-mode-library"); const promptModeJSON = document.getElementById("prompt-mode-json"); @@ -5167,6 +5171,38 @@

Connect to Hub

replaceLucideIcons(promptSpeechToggle); } + function clampSpeechStreamLevel(level) { + if (!Number.isFinite(level)) return 0; + return Math.max(0, Math.min(1, level)); + } + + function setSpeechStreamActive(stream, active) { + if (!(stream instanceof HTMLElement)) return; + stream.classList.toggle("is-active", Boolean(active)); + if (!active) { + stream.style.setProperty("--speech-level", "0"); + } + } + + function setSpeechStreamLevel(stream, level) { + if (!(stream instanceof HTMLElement)) return; + stream.style.setProperty("--speech-level", clampSpeechStreamLevel(level).toFixed(3)); + } + + function speechStreamLevelFromSamples(input) { + if (!input || !input.length) return 0; + let sum = 0; + let peak = 0; + for (let i = 0; i < input.length; i += 1) { + const sample = Math.max(-1, Math.min(1, Number(input[i]) || 0)); + const absSample = Math.abs(sample); + if (absSample > peak) peak = absSample; + sum += sample * sample; + } + const rms = Math.sqrt(sum / input.length); + return Math.max(peak * 1.8, rms * 16); + } + async function refreshSpeechStatus() { try { const response = await fetch("/api/speech/status", { cache: "no-store" }); @@ -5239,6 +5275,7 @@

Connect to Hub

const input = event.inputBuffer.getChannelData(0); const downsampled = downsampleSpeech(input, state.speech.audioContext.sampleRate, SPEECH_SAMPLE_RATE); updateSpeechCaptureStats(downsampled); + setSpeechStreamLevel(builderSpeechStream, speechStreamLevelFromSamples(downsampled)); state.speech.chunks.push(floatToSpeechPCM(downsampled)); }; state.speech.source.connect(state.speech.processor); @@ -5246,6 +5283,7 @@

Connect to Hub

state.speech.zeroGain.connect(state.speech.audioContext.destination); state.speech.recording = true; syncPromptSpeechButton(); + setSpeechStreamActive(builderSpeechStream, true); setLocalPromptStatus("warn", "Listening. Press the mic again to stop."); trackAnalyticsEvent("prompt_speech_started", { prompt_mode: state.promptMode }); } catch (err) { @@ -5324,6 +5362,7 @@

Connect to Hub

state.speech.zeroGain = null; state.speech.chunks = []; resetSpeechCaptureStats(); + setSpeechStreamActive(builderSpeechStream, false); syncPromptSpeechButton(); } @@ -6556,6 +6595,12 @@

Connect to Hub

if (repoKey && state.chatPromptDrafts.has(repoKey)) { prompt.value = state.chatPromptDrafts.get(repoKey); } + const promptWrap = document.createElement("span"); + promptWrap.className = "speech-stream-wrap chat-repo-prompt-wrap"; + const speechStream = document.createElement("span"); + speechStream.className = "speech-stream chat-speech-stream"; + speechStream.setAttribute("aria-hidden", "true"); + promptWrap.append(prompt, speechStream); const pasteTarget = document.createElement("div"); pasteTarget.className = "prompt-control prompt-action-paste chat-repo-image-paste"; @@ -6634,7 +6679,7 @@

Connect to Hub

event.stopPropagation(); }); imageActions.append(pasteTarget, submitStatus); - panel.append(promptLog, prompt, imageActions); + panel.append(promptLog, promptWrap, imageActions); card.appendChild(panel); const openPrompt = () => { diff --git a/internal/web/static/style.css b/internal/web/static/style.css index fe1ab343..85fe2eac 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1711,6 +1711,64 @@ select.prompt-control { background: var(--surface-control-bg-strong); } +.speech-stream-wrap { + position: relative; + display: block; + min-width: 0; +} + +.speech-stream { + --speech-level: 0; + position: absolute; + top: 9px; + left: 10px; + right: 10px; + height: 18px; + overflow: hidden; + border-radius: 999px; + opacity: 0; + pointer-events: none; + transform: translateY(-2px); + transition: opacity 0.16s ease, transform 0.16s ease; +} + +.speech-stream.is-active { + opacity: calc(0.28 + (var(--speech-level) * 0.72)); + transform: translateY(0); +} + +.speech-stream::before { + content: ""; + position: absolute; + inset: 0; + width: 220%; + background: + linear-gradient(90deg, transparent 0%, rgba(10, 132, 255, 0.2) 12%, rgba(10, 132, 255, 0.68) 22%, transparent 34%), + repeating-linear-gradient(90deg, transparent 0 8px, rgba(10, 132, 255, 0.32) 8px 10px, transparent 10px 16px); + filter: drop-shadow(0 0 5px rgba(10, 132, 255, 0.28)); + transform: translateX(0); + animation: speech-stream-flow 1.1s linear infinite; +} + +.speech-stream::after { + content: ""; + position: absolute; + inset: 4px 0; + background: linear-gradient(90deg, transparent, rgba(10, 132, 255, 0.54), transparent); + opacity: calc(0.16 + var(--speech-level) * 0.38); + transform: scaleY(calc(0.35 + (var(--speech-level) * 0.9))); + transform-origin: center; +} + +@keyframes speech-stream-flow { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + #builder-prompt-input, #local-prompt-input { width: 100%; @@ -3699,6 +3757,10 @@ select.prompt-control { line-height: 1.35; } +.chat-repo-prompt-wrap { + display: block; +} + .chat-repo-prompt:focus { border-color: var(--border-strong); outline: none;