diff --git a/CLAUDE.md b/CLAUDE.md index 8f0dcca1..c30f2ed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1641 (must match `package.json`) +**Version**: 0.1643 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index ad5338f0..a5ba012e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1641", + "version": "0.1643", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 7bfac3da..99c5178d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -609,7 +609,7 @@ const DeepgramProvider = { /** * Start streaming audio to Deepgram. - * @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd() } + * @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) } */ async start(opts) { this._onResult = opts.onResult; @@ -629,6 +629,8 @@ const DeepgramProvider = { this._cleanup(); return; } + // Notify caller so it can set up audio level meter + opts.onStream?.(this._stream); // 2. Build WebSocket URL const params = new URLSearchParams({ @@ -783,6 +785,14 @@ const VoiceInput = { _stabilityTimer: null, _accumulatedFinal: '', _activeProvider: null, // 'deepgram' | 'webspeech' | null + _recordingStartedAt: 0, // timestamp when recording started + _retryCount: 0, // auto-retry counter for premature Web Speech API ends + _hasReceivedResult: false, // whether any speech result came in this session + _durationInterval: null, // timer for updating elapsed time display + _analyser: null, // AudioContext analyser for level meter + _analyserSource: null, // MediaStreamSource for level meter + _audioContext: null, // AudioContext for level meter + _levelAnimFrame: null, // rAF handle for level meter init() { this._initRecognition(); @@ -847,6 +857,7 @@ const VoiceInput = { app.showToast('No active session', 'warning'); return; } + this._retryCount = 0; if (this._shouldUseDeepgram()) { this._startDeepgram(); @@ -861,18 +872,25 @@ const VoiceInput = { this._activeProvider = 'deepgram'; this._accumulatedFinal = ''; this._lastTranscript = ''; + this._hasReceivedResult = false; + this._recordingStartedAt = Date.now(); this._updateButtons('recording'); this._showPreview('Listening...', 'deepgram'); + this._startDurationTimer(); - const keyterms = (cfg.keyterms || 'claudeman, tmux, respawn, subagent, ralph, fastify, xterm, pty') + const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com') .split(',').map(t => t.trim()).filter(Boolean); DeepgramProvider.start({ apiKey: cfg.apiKey, language: cfg.language || 'en-US', keyterms, + onStream: (stream) => { + this._startLevelMeter(stream); + }, onResult: (text, isFinal) => { if (!this.isRecording) return; + this._hasReceivedResult = true; if (isFinal) { this._accumulatedFinal += text; this._hidePreview(); @@ -917,8 +935,11 @@ const VoiceInput = { this._activeProvider = 'webspeech'; this._accumulatedFinal = ''; this._lastTranscript = ''; + this._hasReceivedResult = false; + this._recordingStartedAt = Date.now(); this._updateButtons('recording'); this._showPreview('Listening...'); + this._startDurationTimer(); try { this.recognition.start(); } catch (e) { @@ -930,6 +951,15 @@ const VoiceInput = { } } this._resetSilenceTimeout(); + // Get mic stream for level meter (non-blocking — level meter is cosmetic) + navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => { + if (this.isRecording && this._activeProvider === 'webspeech') { + this._webSpeechStream = stream; + this._startLevelMeter(stream); + } else { + stream.getTracks().forEach(t => t.stop()); + } + }).catch(() => { /* level meter just won't show */ }); // Haptic feedback on mobile if (navigator.vibrate) navigator.vibrate(50); }, @@ -941,6 +971,9 @@ const VoiceInput = { clearTimeout(this._stabilityTimer); this.silenceTimeout = null; this._stabilityTimer = null; + this._retryCount = 0; + this._stopDurationTimer(); + this._stopLevelMeter(); this._updateButtons('idle'); this._hidePreview(); @@ -952,6 +985,11 @@ const VoiceInput = { } catch (_e) { // Already stopped — ignore } + // Stop the mic stream we opened for the level meter + if (this._webSpeechStream) { + this._webSpeechStream.getTracks().forEach(t => t.stop()); + this._webSpeechStream = null; + } } this._activeProvider = null; @@ -961,6 +999,7 @@ const VoiceInput = { _onWebSpeechResult(event) { if (!this.isRecording) return; + this._hasReceivedResult = true; this._resetSilenceTimeout(); let interim = ''; let finalText = ''; @@ -988,6 +1027,9 @@ const VoiceInput = { }, _onWebSpeechError(event) { + // During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them + if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return; + const wasRecording = this.isRecording; this.stop(); if (!wasRecording) return; @@ -1012,13 +1054,28 @@ const VoiceInput = { _onWebSpeechEnd() { // Recognition ended (browser auto-stopped or we called stop()) - if (this.isRecording) { - // Ended unexpectedly while still recording — finalize any accumulated text - if (this._accumulatedFinal) { - this._insertText(this._accumulatedFinal); + if (!this.isRecording) return; + + const elapsed = Date.now() - this._recordingStartedAt; + // Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results). + // Auto-retry up to 2 times to avoid the "needs two clicks" problem. + if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) { + this._retryCount++; + try { + this.recognition.start(); + } catch (_e) { + // If restart fails, fall through to stop + if (this._accumulatedFinal) this._insertText(this._accumulatedFinal); + this.stop(); } - this.stop(); + return; } + + // Genuine end — finalize any accumulated text + if (this._accumulatedFinal) { + this._insertText(this._accumulatedFinal); + } + this.stop(); }, _insertText(text) { @@ -1049,6 +1106,7 @@ const VoiceInput = {
+
@@ -1057,10 +1115,16 @@ const VoiceInput = { const send = () => { const val = textarea.value.trim(); overlay.remove(); - if (val) app.sendInput(val).catch(() => {}); + if (val) app.sendInput(val + '\r').catch(() => {}); }; const cancel = () => overlay.remove(); + const newInput = () => { + textarea.value = ''; + textarea.focus(); + this.start(); + }; overlay.querySelector('.paste-cancel').addEventListener('click', cancel); + overlay.querySelector('.paste-new').addEventListener('click', newInput); overlay.querySelector('.paste-send').addEventListener('click', send); overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); }); document.body.appendChild(overlay); @@ -1096,6 +1160,77 @@ const VoiceInput = { } }, + _startDurationTimer() { + this._stopDurationTimer(); + this._durationInterval = setInterval(() => { + if (!this.isRecording || !this.previewEl) return; + const elapsed = Math.floor((Date.now() - this._recordingStartedAt) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const timeStr = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`; + const timerEl = this.previewEl.querySelector('.voice-timer'); + if (timerEl) timerEl.textContent = timeStr; + }, 1000); + }, + + _stopDurationTimer() { + if (this._durationInterval) { + clearInterval(this._durationInterval); + this._durationInterval = null; + } + }, + + /** Start audio level meter using AnalyserNode — attaches to the active mic stream */ + _startLevelMeter(stream) { + this._stopLevelMeter(); + try { + this._audioContext = new (window.AudioContext || window.webkitAudioContext)(); + this._analyserSource = this._audioContext.createMediaStreamSource(stream); + this._analyser = this._audioContext.createAnalyser(); + this._analyser.fftSize = 256; + this._analyserSource.connect(this._analyser); + this._drawLevelMeter(); + } catch (_e) { + // AudioContext not available — level meter just won't show + } + }, + + _stopLevelMeter() { + if (this._levelAnimFrame) { + cancelAnimationFrame(this._levelAnimFrame); + this._levelAnimFrame = null; + } + if (this._analyserSource) { + try { this._analyserSource.disconnect(); } catch (_e) { /* */ } + this._analyserSource = null; + } + if (this._audioContext) { + try { this._audioContext.close(); } catch (_e) { /* */ } + this._audioContext = null; + } + this._analyser = null; + }, + + _drawLevelMeter() { + if (!this._analyser || !this.isRecording) return; + const dataArray = new Uint8Array(this._analyser.frequencyBinCount); + this._analyser.getByteFrequencyData(dataArray); + // Compute RMS level 0-1 + let sum = 0; + for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]; + const rms = Math.sqrt(sum / dataArray.length) / 255; + // Update the level bars in the preview + const barsEl = this.previewEl?.querySelector('.voice-level-bars'); + if (barsEl) { + const bars = barsEl.children; + for (let i = 0; i < bars.length; i++) { + const threshold = (i + 1) / bars.length; + bars[i].classList.toggle('active', rms >= threshold * 0.7); + } + } + this._levelAnimFrame = requestAnimationFrame(() => this._drawLevelMeter()); + }, + _showPreview(text, provider) { if (!this.previewEl) { this.previewEl = document.createElement('div'); @@ -1103,16 +1238,44 @@ const VoiceInput = { this.previewEl.setAttribute('aria-live', 'polite'); document.body.appendChild(this.previewEl); } - this.previewEl.textContent = ''; - // Add provider badge for Deepgram - if (provider === 'deepgram') { - const badge = document.createElement('span'); - badge.className = 'voice-preview-badge'; - badge.textContent = 'DG'; - this.previewEl.appendChild(badge); - this.previewEl.appendChild(document.createTextNode(' ')); + + // Build the indicator structure once, then just update the text node + if (!this.previewEl.querySelector('.voice-recording-indicator')) { + this.previewEl.textContent = ''; + // Recording indicator: red dot + level bars + timer + const indicator = document.createElement('span'); + indicator.className = 'voice-recording-indicator'; + indicator.innerHTML = ''; + const barsEl = document.createElement('span'); + barsEl.className = 'voice-level-bars'; + for (let i = 0; i < 5; i++) { + const bar = document.createElement('span'); + bar.className = 'voice-level-bar'; + barsEl.appendChild(bar); + } + indicator.appendChild(barsEl); + const timerEl = document.createElement('span'); + timerEl.className = 'voice-timer'; + timerEl.textContent = '0:00'; + indicator.appendChild(timerEl); + this.previewEl.appendChild(indicator); + // Provider badge for Deepgram + if (provider === 'deepgram') { + const badge = document.createElement('span'); + badge.className = 'voice-preview-badge'; + badge.textContent = 'DG'; + this.previewEl.appendChild(badge); + this.previewEl.appendChild(document.createTextNode(' ')); + } + // Text node for transcript + this._previewTextNode = document.createTextNode(text || 'Listening...'); + this.previewEl.appendChild(this._previewTextNode); + } else { + // Just update the text content + if (this._previewTextNode) { + this._previewTextNode.textContent = text || 'Listening...'; + } } - this.previewEl.appendChild(document.createTextNode(text || 'Listening...')); this.previewEl.style.display = ''; }, @@ -1155,6 +1318,12 @@ const VoiceInput = { DeepgramProvider._cleanup(); this.recognition = null; this._activeProvider = null; + this._stopDurationTimer(); + this._stopLevelMeter(); + if (this._webSpeechStream) { + this._webSpeechStream.getTracks().forEach(t => t.stop()); + this._webSpeechStream = null; + } if (this.previewEl) { this.previewEl.remove(); this.previewEl = null; @@ -1165,6 +1334,8 @@ const VoiceInput = { this._stabilityTimer = null; this._accumulatedFinal = ''; this._lastTranscript = ''; + this._retryCount = 0; + this._hasReceivedResult = false; } }; @@ -11015,7 +11186,7 @@ class ClaudemanApp { const voiceCfg = VoiceInput._getDeepgramConfig(); document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || ''; document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US'; - document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || 'claudeman, tmux, respawn, subagent, ralph, fastify, xterm, pty'; + document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com'; document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct'; // Reset key visibility to hidden const keyInput = document.getElementById('voiceDeepgramKey'); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 7e0d6271..0fcbdfda 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -341,10 +341,10 @@ html.mobile-init .file-browser-panel { } .btn-voice-mobile.recording { - background: rgba(239, 68, 68, 0.2); - border-color: rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.25); + border-color: rgba(239, 68, 68, 0.6); color: #ef4444; - animation: voice-pulse 1.5s ease-in-out infinite; + animation: voice-pulse 1.2s ease-in-out infinite; } /* Mobile app settings gear in toolbar - far right */ @@ -850,7 +850,7 @@ html.mobile-init .file-browser-panel { margin-top: 10px; } - .paste-cancel, .paste-send { + .paste-cancel, .paste-new, .paste-send { padding: 8px 18px; border: none; border-radius: 8px; @@ -863,6 +863,12 @@ html.mobile-init .file-browser-panel { color: var(--text-secondary, #aaa); } + .paste-new { + background: var(--bg-tertiary, #333); + color: var(--accent-color, #7aa2f7); + border: 1px solid var(--accent-color, #7aa2f7); + } + .paste-send { background: var(--accent-color, #7aa2f7); color: #fff; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 74ae72a0..11341000 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1993,21 +1993,27 @@ body { } .btn-toolbar.btn-voice.recording { - background: rgba(239, 68, 68, 0.15); - border-color: rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.6); color: #ef4444; - animation: voice-pulse 1.5s ease-in-out infinite; + animation: voice-pulse 1.2s ease-in-out infinite; } .btn-toolbar.btn-voice.recording:hover { - background: rgba(239, 68, 68, 0.25); - border-color: rgba(239, 68, 68, 0.7); + background: rgba(239, 68, 68, 0.3); + border-color: rgba(239, 68, 68, 0.8); color: #f87171; } @keyframes voice-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } - 50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); } + 0%, 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.2); + } + 50% { + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); + background: rgba(239, 68, 68, 0.35); + } } @media (prefers-reduced-motion: reduce) { @@ -2067,6 +2073,75 @@ body { background: rgba(74, 222, 128, 0.1); } +/* Voice recording indicator (red dot + level bars + timer) */ +.voice-recording-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 8px; + flex-shrink: 0; +} + +.voice-rec-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #ef4444; + animation: voice-rec-blink 1s ease-in-out infinite; +} + +@keyframes voice-rec-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.voice-level-bars { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 14px; +} + +.voice-level-bar { + display: inline-block; + width: 3px; + background: rgba(239, 68, 68, 0.25); + border-radius: 1px; + transition: background 0.1s ease, height 0.1s ease; +} + +.voice-level-bar:nth-child(1) { height: 4px; } +.voice-level-bar:nth-child(2) { height: 7px; } +.voice-level-bar:nth-child(3) { height: 10px; } +.voice-level-bar:nth-child(4) { height: 13px; } +.voice-level-bar:nth-child(5) { height: 14px; } + +.voice-level-bar.active { + background: #ef4444; +} + +.voice-timer { + font-size: 0.7rem; + font-weight: 600; + color: #ef4444; + font-variant-numeric: tabular-nums; + min-width: 2.5em; +} + +/* Enhanced recording state on voice preview */ +.voice-preview:has(.voice-recording-indicator) { + border-color: rgba(239, 68, 68, 0.3); + background: rgba(20, 0, 0, 0.92); +} + +@media (prefers-reduced-motion: reduce) { + .voice-rec-dot { + animation: none; + opacity: 1; + } +} + .form-row input[type="password"] { width: 100%; padding: 0.5rem 0.75rem;