From 74074093dcd3957b7e1ca17cc23973246b56da5d Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 31 Dec 2025 09:59:24 +0100 Subject: [PATCH] Fixed STT --- src/snek/static/chat-input.js | 9 +- src/snek/static/stt.js | 424 ++++++++++++++++++++++------------ 2 files changed, 273 insertions(+), 160 deletions(-) diff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js index 6d8de3d..e158dcc 100644 --- a/src/snek/static/chat-input.js +++ b/src/snek/static/chat-input.js @@ -379,16 +379,13 @@ textToLeetAdvanced(text) { this.textarea.setAttribute("rows", "2"); this.appendChild(this.textarea); - this.ttsButton = document.createElement("stt-button"); - + this.snekSpeaker = document.createElement("snek-speaker"); this.appendChild(this.snekSpeaker); - this.ttsButton.addEventListener("click", (e) => { - this.snekSpeaker.enable() - }); + this.sttButton = document.createElement("stt-button"); + this.appendChild(this.sttButton); - this.appendChild(this.ttsButton); this.uploadButton = document.createElement("upload-button"); this.uploadButton.setAttribute("channel", this.channelUid); this.uploadButton.addEventListener("upload", (e) => { diff --git a/src/snek/static/stt.js b/src/snek/static/stt.js index 8ad2372..90920a4 100644 --- a/src/snek/static/stt.js +++ b/src/snek/static/stt.js @@ -1,183 +1,299 @@ // retoor class STTButton extends HTMLElement { - static get observedAttributes() { return ['target']; } - - simulateTypingWithEvents(element, text, delay = 100) { - let resolver = null; - const promise = new Promise((resolve) => resolver = resolve); - let index = 0; - - const triggerEvent = (type, key) => { - const event = new KeyboardEvent(type, { - key: key, - bubbles: true, - cancelable: true, - }); - element.dispatchEvent(event); - }; - - const interval = setInterval(() => { - if (index < text.length) { - const char = text.charAt(index); - - triggerEvent('keydown', char); - - if (element.isContentEditable) { - document.execCommand('insertText', false, char); - } else { - element.value += char; - } - - triggerEvent('keypress', char); - triggerEvent('keyup', char); - index++; - } else { - clearInterval(interval); - resolver(); - } - }, delay); - - return promise; - } - constructor() { super(); this.attachShadow({ mode: 'open' }); - - const btn = document.createElement('button'); - btn.setAttribute('part', 'button'); - btn.setAttribute('aria-label', 'Start voice dictation'); - btn.innerHTML = '🎤'; + this._isListening = false; + this._recognition = null; + this._finalTranscript = ''; + this._interimTranscript = ''; const style = document.createElement('style'); style.textContent = ` - :host { display:inline-block; } - button { all:unset; cursor:pointer; } - button:hover { background:#e8e8e8; } - :host([listening]) button { - background:#d32f2f; color:#fff; - animation:pulse 1.2s ease-in-out infinite; } + :host { + display: inline-block; + } + .stt-container { + position: relative; + } + .stt-button { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + margin-left: 10px; + background-color: #000000; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + position: relative; + overflow: hidden; + transition: background-color 0.2s ease, transform 0.1s ease; + } + .stt-button:hover { + background-color: #222222; + } + .stt-button:active { + transform: scale(0.95); + } + :host([listening]) .stt-button { + background-color: #d32f2f; + animation: pulse 1.5s ease-in-out infinite; + } @keyframes pulse { - 0%,100% { transform:scale(1); box-shadow:0 0 0 0 rgba(211,47,47,.6);} - 50% { transform:scale(1.12);box-shadow:0 0 0 12px rgba(211,47,47,0);} + 0%, 100% { + box-shadow: 0 0 0 0 rgba(211, 47, 47, 0.6); + } + 50% { + box-shadow: 0 0 0 8px rgba(211, 47, 47, 0); + } + } + .stt-button:disabled { + opacity: 0.5; + cursor: not-allowed; } `; - this.shadowRoot.append(style, btn); - const SR = window.SpeechRecognition || window.webkitSpeechRecognition; - if (!SR) { - console.warn('Web Speech API not supported in this browser.'); + const container = document.createElement('div'); + container.className = 'stt-container'; + + this._button = document.createElement('button'); + this._button.className = 'stt-button'; + this._button.setAttribute('aria-label', 'Voice input'); + this._button.innerHTML = '🎤'; + this._button.addEventListener('click', () => this._toggle()); + + container.appendChild(this._button); + this.shadowRoot.append(style, container); + + this._initRecognition(); + } + + _initRecognition() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + + if (!SpeechRecognition) { + this._button.disabled = true; + this._button.title = 'Speech recognition not supported'; return; } - this.recog = new SR(); - this.recog.lang = 'en-US'; - this.recog.continuous = true; - this.recog.interimResults = true; + this._recognition = new SpeechRecognition(); + this._recognition.continuous = true; + this._recognition.interimResults = true; + this._recognition.lang = navigator.language || 'en-US'; - let interim = ''; - let committed = ''; - let previousInterim = ''; - - this.recog.onresult = (e) => { - interim = ''; - for (let i = e.resultIndex; i < e.results.length; i++) { - const res = e.results[i]; - const alt = res[0]; - const txt = alt.transcript.trim(); - - if (res.isFinal) { - const sentence = txt.charAt(0).toUpperCase() + txt.slice(1); - let punctuated = /[.!?]$/.test(sentence) ? sentence : sentence + '.'; - - committed += punctuated + ' '; - if (this.targetEl) { - this.targetEl.focus(); - punctuated = punctuated.replace(/\./g, ". -") - .replace(/\?/g, "? -") - .replace(/\!/g, "! -"); - - this.targetEl.value = punctuated; // punctuated; - this.simulateTypingWithEvents(this.targetEl, ' ', 0).then(() => { - const chatInput = document.querySelector('chat-input'); - chatInput.finalizeMessage(); - }); - } - - previousInterim = ''; - } else { - if (alt.confidence >= 0.85) { - interim += txt + ' '; - } - } - } - - if (interim && this.targetEl) { - const el = this.targetEl; - el.focus(); - - if (el.isContentEditable) { - el.innerText = el.innerText.replace(new RegExp(previousInterim + '$'), ''); - } else { - el.value = interim - // el.value = interim - //el.value = el.value.replace(new RegExp(previousInterim + '$'), interim); - } - document.querySelector('chat-input').sendMessage(interim); - this.simulateTypingWithEvents(el, ' ', 0).then(() => { - - }) - // this.simulateTypingWithEvents(el, interim, 0).then(() => { - // previousInterim = interim; - // }); - } + this._recognition.onstart = () => { + this._isListening = true; + this.setAttribute('listening', ''); + this._finalTranscript = ''; + this._interimTranscript = ''; }; - this.recog.onend = () => { - if (this.listening) { + this._recognition.onend = () => { + if (this._isListening) { try { - this.recog.start(); + this._recognition.start(); } catch (e) { - console.warn('Failed to restart speech recognition:', e); + this._stop(); } } }; - btn.addEventListener('click', () => this.toggle()); - } - - attributeChangedCallback(name, _, newVal) {} - - get targetEl() { - return document.querySelector("textarea") || null; - } - - connectedCallback() {} - - get listening() { - return this.hasAttribute('listening'); - } - set listening(val) { - if (val) this.setAttribute('listening', ''); - else this.removeAttribute('listening'); - } - - toggle() { - if (!this.recog) return; - if (this.listening) { - this.recog.stop(); - this.listening = false; - } else { - try { - this.recog.start(); - this.listening = true; - } catch (e) { - console.warn('Error starting recognition:', e); + this._recognition.onerror = (event) => { + if (event.error === 'no-speech') { + return; } + if (event.error === 'aborted') { + return; + } + console.warn('Speech recognition error:', event.error); + if (event.error === 'not-allowed') { + this._button.disabled = true; + this._button.title = 'Microphone access denied'; + } + this._stop(); + }; + + this._recognition.onresult = (event) => { + let interim = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const transcript = result[0].transcript; + + if (result.isFinal) { + let text = transcript.trim(); + text = text.charAt(0).toUpperCase() + text.slice(1); + if (!/[.!?]$/.test(text)) { + text += '.'; + } + this._finalTranscript += text + ' '; + this._updateTextarea(this._finalTranscript.trim()); + this._finalize(); + this._finalTranscript = ''; + this._interimTranscript = ''; + } else { + interim += transcript; + } + } + + this._interimTranscript = interim; + if (interim) { + this._updateTextarea(this._finalTranscript + interim); + } + }; + } + + _getTextarea() { + const chatInput = document.querySelector('chat-input'); + return chatInput ? chatInput.textarea : document.querySelector('textarea'); + } + + _getChatInput() { + return document.querySelector('chat-input'); + } + + _clearTextarea() { + const textarea = this._getTextarea(); + if (textarea) { + textarea.value = ''; + } + const chatInput = this._getChatInput(); + if (chatInput) { + chatInput.value = ''; + } + } + + _updateTextarea(text) { + const textarea = this._getTextarea(); + if (!textarea) return; + + textarea.focus(); + textarea.value = text; + + const chatInput = this._getChatInput(); + if (chatInput) { + chatInput.value = text; + chatInput.updateFromInput(text); + } + } + + _finalize() { + const chatInput = this._getChatInput(); + if (chatInput) { + chatInput.finalizeMessage(); + } + } + + _simulateEnterKey() { + const textarea = this._getTextarea(); + if (!textarea) return; + + textarea.focus(); + + const keydownEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + }); + textarea.dispatchEvent(keydownEvent); + + const keyupEvent = new KeyboardEvent('keyup', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + }); + textarea.dispatchEvent(keyupEvent); + } + + _sendMessage() { + const chatInput = this._getChatInput(); + if (chatInput && this._finalTranscript.trim()) { + chatInput.value = this._finalTranscript.trim(); + chatInput.textarea.value = this._finalTranscript.trim(); + + this._simulateEnterKey(); + + this._finalTranscript = ''; + this._interimTranscript = ''; + } + } + + _toggle() { + if (this._isListening) { + this._stopAndSend(); + } else { + this._start(); + } + } + + _start() { + if (!this._recognition) return; + + try { + this._recognition.start(); + } catch (e) { + if (e.name === 'InvalidStateError') { + this._recognition.stop(); + setTimeout(() => { + try { + this._recognition.start(); + } catch (err) { + console.warn('Failed to start recognition:', err); + } + }, 100); + } + } + } + + _stop() { + this._isListening = false; + this.removeAttribute('listening'); + if (this._recognition) { + try { + this._recognition.stop(); + } catch (e) { + } + } + } + + _stopAndSend() { + if (this._finalTranscript.trim() || this._interimTranscript.trim()) { + if (this._interimTranscript.trim()) { + let text = this._interimTranscript.trim(); + text = text.charAt(0).toUpperCase() + text.slice(1); + if (!/[.!?]$/.test(text)) { + text += '.'; + } + this._finalTranscript += text + ' '; + } + this._updateTextarea(this._finalTranscript.trim()); + this._finalize(); + } + + this._stop(); + this._finalTranscript = ''; + this._interimTranscript = ''; + } + + connectedCallback() { + const textarea = this._getTextarea(); + if (textarea) { + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && this._isListening) { + this._stopAndSend(); + } + }); } } }