Fixed STT

This commit is contained in:
retoor 2025-12-31 09:59:24 +01:00
parent 56d0ef01fa
commit 74074093dc
2 changed files with 273 additions and 160 deletions

View File

@ -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) => {

View File

@ -1,183 +1,299 @@
// retoor <retoor@molodetz.nl>
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';
this._recognition.onstart = () => {
this._isListening = true;
this.setAttribute('listening', '');
this._finalTranscript = '';
this._interimTranscript = '';
};
this._recognition.onend = () => {
if (this._isListening) {
try {
this._recognition.start();
} catch (e) {
this._stop();
}
}
};
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 = '';
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();
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const transcript = result[0].transcript;
if (res.isFinal) {
const sentence = txt.charAt(0).toUpperCase() + txt.slice(1);
let punctuated = /[.!?]$/.test(sentence) ? sentence : sentence + '.';
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;
}
}
committed += punctuated + ' ';
if (this.targetEl) {
this.targetEl.focus();
punctuated = punctuated.replace(/\./g, ".
")
.replace(/\?/g, "?
")
.replace(/\!/g, "!
");
this._interimTranscript = interim;
if (interim) {
this._updateTextarea(this._finalTranscript + interim);
}
};
}
this.targetEl.value = punctuated; // punctuated;
this.simulateTypingWithEvents(this.targetEl, ' ', 0).then(() => {
_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);
}
previousInterim = '';
_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 {
if (alt.confidence >= 0.85) {
interim += txt + ' ';
}
this._start();
}
}
if (interim && this.targetEl) {
const el = this.targetEl;
el.focus();
_start() {
if (!this._recognition) return;
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.recog.onend = () => {
if (this.listening) {
try {
this.recog.start();
this._recognition.start();
} catch (e) {
console.warn('Failed to restart speech recognition:', e);
}
}
};
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 {
if (e.name === 'InvalidStateError') {
this._recognition.stop();
setTimeout(() => {
try {
this.recog.start();
this.listening = true;
} catch (e) {
console.warn('Error starting recognition:', e);
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();
}
});
}
}
}