Refactor STT architecture
This commit is contained in:
@@ -57,6 +57,10 @@
|
||||
<line x1="12" x2="12" y1="19" y2="22"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="dictate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 21h8m.174-14.188a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
|
||||
<path d="m21.854 2.147-10.94 10.939"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -279,6 +279,19 @@ body {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Transcribe button: recording state (same as PTT but for transcribe) */
|
||||
.compose__action-btn--recording {
|
||||
color: white;
|
||||
background: var(--color-recording);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Transcribe button: transcribing state (spinning) */
|
||||
.compose__action-btn--transcribing {
|
||||
color: var(--color-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.compose__action-btn--send .compose__icon--stop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,21 @@ const STORAGE_KEY = 'odidere_history';
|
||||
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
|
||||
const VOICE_KEY = 'odidere_voice';
|
||||
|
||||
/**
|
||||
* VOICE_MAP maps whisper language names to default Kokoro voices.
|
||||
* Used for auto-selecting a voice when the selector is set to "Auto".
|
||||
*/
|
||||
const VOICE_MAP = {
|
||||
chinese: 'zf_xiaobei',
|
||||
english: 'af_heart',
|
||||
french: 'ff_siwis',
|
||||
hindi: 'hf_alpha',
|
||||
italian: 'if_sara',
|
||||
japanese: 'jf_alpha',
|
||||
korean: 'kf_sarah',
|
||||
portuguese: 'pf_dora',
|
||||
spanish: 'ef_dora',
|
||||
};
|
||||
/**
|
||||
* Odidere is the main application class for the voice assistant UI.
|
||||
* It manages audio recording, chat history, and communication with the API.
|
||||
@@ -25,6 +40,7 @@ class Odidere {
|
||||
this.currentController = null;
|
||||
this.isProcessing = false;
|
||||
this.isRecording = false;
|
||||
this.isTranscribing = false;
|
||||
this.isMuted = false;
|
||||
this.isResetPending = false;
|
||||
this.isScrollPending = false;
|
||||
@@ -45,6 +61,9 @@ class Odidere {
|
||||
this.isPlaying = false;
|
||||
this.currentMessageId = null;
|
||||
|
||||
// STT state
|
||||
this.sttUrl = document.querySelector('meta[name="stt-url"]')?.content || '';
|
||||
|
||||
// Auto-scroll: enabled by default, disabled when user scrolls up,
|
||||
// re-enabled when they scroll back to bottom or send a new message.
|
||||
this.isAutoScrollEnabled = true;
|
||||
@@ -64,6 +83,7 @@ class Odidere {
|
||||
this.$textInput = document.getElementById('text-input');
|
||||
this.$voice = document.getElementById('voice');
|
||||
this.$mute = document.getElementById('mute');
|
||||
this.$transcribe = document.getElementById('transcribe');
|
||||
|
||||
// Settings modal
|
||||
this.$settings = document.getElementById('settings');
|
||||
@@ -192,6 +212,35 @@ class Odidere {
|
||||
this.#handleAttachments(e.target.files),
|
||||
);
|
||||
|
||||
// Transcribe button: hold to record, release to transcribe and populate textarea.
|
||||
this.$transcribe.addEventListener(
|
||||
'touchstart',
|
||||
this.#handleTranscribeTouchStart,
|
||||
{
|
||||
passive: false,
|
||||
},
|
||||
);
|
||||
this.$transcribe.addEventListener(
|
||||
'touchend',
|
||||
this.#handleTranscribeTouchEnd,
|
||||
);
|
||||
this.$transcribe.addEventListener(
|
||||
'touchcancel',
|
||||
this.#handleTranscribeTouchEnd,
|
||||
);
|
||||
// Prevent context menu on long press.
|
||||
this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
this.$transcribe.addEventListener(
|
||||
'mousedown',
|
||||
this.#handleTranscribeMouseDown,
|
||||
);
|
||||
this.$transcribe.addEventListener('mouseup', this.#handleTranscribeMouseUp);
|
||||
this.$transcribe.addEventListener(
|
||||
'mouseleave',
|
||||
this.#handleTranscribeMouseUp,
|
||||
);
|
||||
|
||||
// Save selections on change.
|
||||
this.$model.addEventListener('change', () => {
|
||||
localStorage.setItem(MODEL_KEY, this.$model.value);
|
||||
@@ -830,6 +879,122 @@ class Odidere {
|
||||
this.#setLoadingState(false);
|
||||
}
|
||||
|
||||
// ====================
|
||||
// STT: FRONTEND TRANSCRIPTION
|
||||
// ====================
|
||||
/**
|
||||
* #transcribeAudio sends an audio blob to whisper-server directly from
|
||||
* the browser and returns the transcription result.
|
||||
* @param {Blob} audioBlob
|
||||
* @returns {Promise<{text: string, detectedLanguage: string}>}
|
||||
*/
|
||||
async #transcribeAudio(audioBlob) {
|
||||
if (!this.sttUrl) {
|
||||
throw new Error('STT URL not configured');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob, 'audio.webm');
|
||||
formData.append('response_format', 'verbose_json');
|
||||
|
||||
const res = await fetch(`${this.sttUrl}/inference`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
throw new Error(`STT error ${res.status}: ${errText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = (data.text || '').trim();
|
||||
const detectedLanguage = data.detected_language || data.language || '';
|
||||
return { text, detectedLanguage };
|
||||
}
|
||||
|
||||
/**
|
||||
* #handleTranscribeTouchStart handles touch start on the transcribe button.
|
||||
* Begins recording for transcription (hold-to-record, like PTT).
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#handleTranscribeTouchStart = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.#startTranscribeRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* #handleTranscribeTouchEnd handles touch end on the transcribe button.
|
||||
* Stops recording; the shared stop handler will populate the textarea.
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#handleTranscribeTouchEnd = (event) => {
|
||||
event.stopPropagation();
|
||||
this.#stopTranscribeRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* #handleTranscribeMouseDown handles mouse down on the transcribe button.
|
||||
* Ignores events that originate from touch to avoid double-firing.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleTranscribeMouseDown = (event) => {
|
||||
if (event.sourceCapabilities?.firesTouchEvents) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.#startTranscribeRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* #handleTranscribeMouseUp handles mouse up on the transcribe button.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleTranscribeMouseUp = (event) => {
|
||||
if (event.sourceCapabilities?.firesTouchEvents) return;
|
||||
event.stopPropagation();
|
||||
this.#stopTranscribeRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* #startTranscribeRecording begins recording for transcription mode.
|
||||
* Unlike PTT, this populates the textarea on stop rather than sending.
|
||||
*/
|
||||
async #startTranscribeRecording() {
|
||||
if (this.isTranscribing) return;
|
||||
if (this.isRecording) return;
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.#stopCurrentAudio();
|
||||
this.#stopTTS();
|
||||
|
||||
// Initialize MediaRecorder on first use (shared with PTT).
|
||||
if (!this.mediaRecorder) {
|
||||
const success = await this.#initMediaRecorder();
|
||||
if (!success) return;
|
||||
}
|
||||
|
||||
this.audioChunks = [];
|
||||
this.mediaRecorder.start();
|
||||
this.isTranscribing = true;
|
||||
this.$transcribe.classList.add('compose__action-btn--recording');
|
||||
}
|
||||
|
||||
/**
|
||||
* #stopTranscribeRecording stops the media recorder for transcription mode.
|
||||
* The shared 'stop' event handler (set up in #initMediaRecorder) will
|
||||
* fire and clear UI state since this.isTranscribing is still true.
|
||||
*/
|
||||
#stopTranscribeRecording() {
|
||||
if (!this.isTranscribing) return;
|
||||
if (!this.mediaRecorder) return;
|
||||
|
||||
// Keep isTranscribing=true so the stop handler knows to populate textarea.
|
||||
// The stop handler is responsible for clearing the recording class,
|
||||
// so we do not remove it here.
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
|
||||
// ====================
|
||||
// AUDIO RECORDING
|
||||
// ====================
|
||||
@@ -840,6 +1005,7 @@ class Odidere {
|
||||
*/
|
||||
async #startRecording() {
|
||||
if (this.isRecording) return;
|
||||
if (this.isTranscribing) return;
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.#stopCurrentAudio();
|
||||
@@ -854,7 +1020,6 @@ class Odidere {
|
||||
this.audioChunks = [];
|
||||
this.mediaRecorder.start();
|
||||
this.#setRecordingState(true);
|
||||
this.#acquireWakeLock();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -864,13 +1029,17 @@ class Odidere {
|
||||
if (!this.isRecording) return;
|
||||
if (!this.mediaRecorder) return;
|
||||
|
||||
// Do not clear recording state here. The async 'stop' event handler
|
||||
// is responsible for clearing it (#setRecordingState(false)) after
|
||||
// the audio blob has been assembled.
|
||||
this.mediaRecorder.stop();
|
||||
this.#setRecordingState(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* #initMediaRecorder initializes the MediaRecorder with microphone access.
|
||||
* Prefers opus codec for better compression if available.
|
||||
* The stop handler branches on this.isTranscribing: when true it populates
|
||||
* the textarea; when false (PTT flow) it sends the request.
|
||||
* @returns {Promise<boolean>} true if successful
|
||||
*/
|
||||
async #initMediaRecorder() {
|
||||
@@ -891,22 +1060,87 @@ class Odidere {
|
||||
this.mediaRecorder.addEventListener('stop', async () => {
|
||||
if (this.audioChunks.length === 0) return;
|
||||
|
||||
// Capture the mode synchronously before any async work.
|
||||
// The 'stop' event fires asynchronously, so this.isTranscribing
|
||||
// may have changed by the time we await below.
|
||||
const wasTranscribing = this.isTranscribing;
|
||||
|
||||
const audioBlob = new Blob(this.audioChunks, {
|
||||
type: this.mediaRecorder.mimeType,
|
||||
});
|
||||
this.audioChunks = [];
|
||||
|
||||
// Capture and clear text input to send with audio.
|
||||
const text = this.$textInput.value.trim();
|
||||
this.$textInput.value = '';
|
||||
this.$textInput.style.height = 'auto';
|
||||
// Clear the flag now that the stop event has fired.
|
||||
if (wasTranscribing) {
|
||||
this.isTranscribing = false;
|
||||
this.$transcribe.classList.remove('compose__action-btn--recording');
|
||||
} else {
|
||||
this.#setRecordingState(false);
|
||||
}
|
||||
|
||||
await this.#sendRequest({ audio: audioBlob, text });
|
||||
// Transcribe the audio.
|
||||
try {
|
||||
const { text: transcription, detectedLanguage } =
|
||||
await this.#transcribeAudio(audioBlob);
|
||||
|
||||
// Resolve the effective voice for this message.
|
||||
// If the selector is "Auto", pick a default for the detected language.
|
||||
// This does NOT change the dropdown - the voice is per-message only.
|
||||
let voice = this.$voice.value;
|
||||
if (!voice && detectedLanguage) {
|
||||
voice = VOICE_MAP[detectedLanguage] || '';
|
||||
}
|
||||
|
||||
// Branch: transcribe-only (populate textarea) vs PTT (send).
|
||||
if (wasTranscribing) {
|
||||
// Transcribe mode: populate textarea, do not send.
|
||||
this.$transcribe.classList.add('compose__action-btn--transcribing');
|
||||
try {
|
||||
if (transcription) {
|
||||
const existing = this.$textInput.value;
|
||||
if (existing.trim()) {
|
||||
this.$textInput.value = `${existing}\n${transcription}`;
|
||||
} else {
|
||||
this.$textInput.value = transcription;
|
||||
}
|
||||
this.#handleTextareaInput();
|
||||
}
|
||||
} finally {
|
||||
this.$transcribe.classList.remove(
|
||||
'compose__action-btn--transcribing',
|
||||
);
|
||||
this.$textInput.focus();
|
||||
}
|
||||
} else {
|
||||
// PTT mode: capture text, clear input, send request.
|
||||
const text = this.$textInput.value.trim();
|
||||
this.$textInput.value = '';
|
||||
this.$textInput.style.height = 'auto';
|
||||
|
||||
const combined = text ? `${text}\n${transcription}` : transcription;
|
||||
await this.#sendRequest({
|
||||
text: combined,
|
||||
detectedLanguage,
|
||||
voice,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('transcription failed:', e);
|
||||
this.#renderError(`Transcription failed: ${e.message}`);
|
||||
if (!wasTranscribing) {
|
||||
this.#setLoadingState(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.mediaRecorder.addEventListener('error', (event) => {
|
||||
console.error('MediaRecorder error:', event.error);
|
||||
this.#setRecordingState(false);
|
||||
if (this.isTranscribing) {
|
||||
this.isTranscribing = false;
|
||||
this.$transcribe.classList.remove('compose__action-btn--recording');
|
||||
} else {
|
||||
this.#setRecordingState(false);
|
||||
}
|
||||
this.audioChunks = [];
|
||||
this.#renderError(
|
||||
`Recording error: ${event.error?.message || 'Unknown error'}`,
|
||||
@@ -1181,25 +1415,24 @@ class Odidere {
|
||||
*
|
||||
* For text-only input, renders the user message immediately for
|
||||
* responsiveness.
|
||||
* For audio input, waits for the server's transcription event before
|
||||
* rendering the user message.
|
||||
*
|
||||
* Messages are rendered incrementally as SSE events arrive:
|
||||
* - user (transcription), assistant (tool calls), tool (results),
|
||||
* - user, assistant (tool calls), tool (results),
|
||||
* and a final assistant message with content.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Blob} [options.audio] - Recorded audio blob
|
||||
* @param {string} [options.text] - Text input
|
||||
* @param {string} [options.detectedLanguage] - Detected language from STT
|
||||
* @param {string} [options.voice] - Resolved voice for this message (from auto-selection)
|
||||
*/
|
||||
async #sendRequest({ audio = null, text = '' }) {
|
||||
async #sendRequest({ text = '', detectedLanguage = '', voice }) {
|
||||
this.#setLoadingState(true);
|
||||
// Re-enable auto-scroll when the user sends a new message.
|
||||
this.isAutoScrollEnabled = true;
|
||||
this.currentController = new AbortController();
|
||||
|
||||
try {
|
||||
const hasNewInput = text || this.attachments.length > 0 || audio;
|
||||
const hasNewInput = text || this.attachments.length > 0;
|
||||
|
||||
// Build messages array from active path + current user message.
|
||||
const activePath = this.getActivePath();
|
||||
@@ -1221,25 +1454,24 @@ class Odidere {
|
||||
|
||||
const payload = {
|
||||
messages,
|
||||
voice: this.$voice.value,
|
||||
voice: voice ?? this.$voice.value,
|
||||
model: this.$model.value,
|
||||
};
|
||||
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
|
||||
if (systemMessage) {
|
||||
payload.system_message = systemMessage;
|
||||
}
|
||||
if (audio) {
|
||||
payload.audio = await this.#toBase64(audio);
|
||||
}
|
||||
|
||||
// Clear attachments after building payload (before async operations).
|
||||
this.#clearAttachments();
|
||||
|
||||
// For text-only requests with new input, add to history and render
|
||||
// immediately. Skip when continuing conversation (no new user message).
|
||||
if (!audio && hasNewInput) {
|
||||
const appended = this.#appendHistory([userMessage]);
|
||||
this.#renderMessages(appended);
|
||||
if (hasNewInput) {
|
||||
const meta = {};
|
||||
if (detectedLanguage) meta.language = detectedLanguage;
|
||||
const appended = this.#appendHistory([userMessage], meta);
|
||||
this.#renderMessages(appended, meta);
|
||||
}
|
||||
|
||||
const res = await fetch(STREAM_ENDPOINT, {
|
||||
@@ -1296,37 +1528,6 @@ class Odidere {
|
||||
}
|
||||
|
||||
const message = event.message;
|
||||
// Handle user message from server (audio transcription).
|
||||
if (message.role === 'user' && event.transcription) {
|
||||
const displayParts = [];
|
||||
if (text) displayParts.push(text);
|
||||
if (event.transcription) displayParts.push(event.transcription);
|
||||
const displayContent = displayParts.join('\n\n');
|
||||
|
||||
let historyContent;
|
||||
if (typeof userMessage.content === 'string') {
|
||||
historyContent = displayContent;
|
||||
} else {
|
||||
historyContent = [...userMessage.content];
|
||||
if (event.transcription) {
|
||||
historyContent.push({
|
||||
type: 'text',
|
||||
text: event.transcription,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const audioUserMessage = {
|
||||
id: userMessage.id,
|
||||
role: 'user',
|
||||
content: historyContent,
|
||||
meta: { language: event.detected_language },
|
||||
};
|
||||
|
||||
this.#appendHistory([audioUserMessage]);
|
||||
this.#renderMessages([audioUserMessage]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stash assistant messages with tool calls; render once all
|
||||
// tool results have arrived so the output is visible in the UI.
|
||||
@@ -1488,6 +1689,7 @@ class Odidere {
|
||||
this.$chatLoading.hidden = !loading;
|
||||
// Keep the send button enabled during loading so it can act as a stop button.
|
||||
this.$ptt.disabled = loading;
|
||||
this.$transcribe.disabled = loading;
|
||||
// Clear any pending stop state when loading ends.
|
||||
if (!loading && this.isStopPending) {
|
||||
this.isStopPending = false;
|
||||
|
||||
Reference in New Issue
Block a user