main
js 740 lines 20.1 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { toastFrontendError } from "/components/notifications/notification-store.js";
3 import { callJsonApi } from "/js/api.js";
4 import { sttService } from "/js/stt-service.js";
5 import { ttsService } from "/js/tts-service.js";
6 import { sendMessage, updateChatInput } from "/index.js";
7
8 const PLUGIN_NAME = "_whisper_stt";
9
10 const Status = {
11 INACTIVE: "inactive",
12 ACTIVATING: "activating",
13 LISTENING: "listening",
14 RECORDING: "recording",
15 WAITING: "waiting",
16 PROCESSING: "processing",
17 };
18
19 const MicButtonClasses = [
20 "mic-disabled",
21 "mic-inactive",
22 "mic-activating",
23 "mic-listening",
24 "mic-recording",
25 "mic-waiting",
26 "mic-processing",
27 ];
28
29 const MicStatusLabels = {
30 disabled: "Whisper STT disabled",
31 inactive: "Microphone standby",
32 activating: "Microphone activating",
33 listening: "Listening for speech",
34 recording: "Recording voice",
35 waiting: "Waiting for final silence",
36 processing: "Transcribing voice",
37 };
38
39 function clearMicrophoneTooltip(element) {
40 const tooltip = globalThis.bootstrap?.Tooltip?.getInstance?.(element);
41 tooltip?.dispose?.();
42 element.removeAttribute("title");
43 element.removeAttribute("data-bs-original-title");
44 element.removeAttribute("data-bs-toggle");
45 element.removeAttribute("data-bs-trigger");
46 element.removeAttribute("data-bs-tooltip-initialized");
47 }
48
49 const model = {
50 runtimeInitialized: false,
51 statusLoaded: false,
52 loading: false,
53 error: "",
54 enabled: false,
55 config: {
56 model_size: "base",
57 language: "en",
58 message_mode: "send",
59 silence_threshold: 0.3,
60 silence_duration: 1000,
61 waiting_timeout: 2000,
62 },
63 modelReady: false,
64 modelLoading: false,
65 loadedModel: "",
66 packageVersion: "",
67 providerCleanup: null,
68 microphoneInput: null,
69 finalTranscriptHandler: null,
70 isProcessingClick: false,
71 devices: [],
72 selectedDevice: "",
73 requestingPermission: false,
74 _ttsListener: null,
75 _deviceChangeListenerBound: false,
76
77 async initRuntime() {
78 if (this.runtimeInitialized) return;
79
80 this.runtimeInitialized = true;
81 await this.loadDevices();
82 await this.refreshStatus({ suppressError: true });
83
84 if (!this._deviceChangeListenerBound) {
85 navigator.mediaDevices?.addEventListener?.("devicechange", () => {
86 void this.loadDevices();
87 });
88 this._deviceChangeListenerBound = true;
89 }
90
91 if (!this._ttsListener) {
92 this._ttsListener = (event) => {
93 if (event?.detail?.isSpeaking && this.micStatus !== Status.INACTIVE) {
94 this.stop();
95 }
96 };
97 ttsService.addEventListener("statechange", this._ttsListener);
98 }
99 },
100
101 async ensureStatusLoaded({ force = false, suppressError = true } = {}) {
102 if ((!this.statusLoaded || force) && !this.loading) {
103 await this.refreshStatus({ suppressError });
104 }
105 },
106
107 async refreshStatus({ suppressError = false } = {}) {
108 this.loading = true;
109 this.error = "";
110
111 try {
112 const status = await callJsonApi(`/plugins/${PLUGIN_NAME}/status`, {});
113 this.statusLoaded = true;
114 this.enabled = !!status?.enabled;
115 this.config = {
116 model_size: status?.config?.model_size || "base",
117 language: status?.config?.language || "en",
118 message_mode:
119 status?.config?.message_mode === "draft" ? "draft" : "send",
120 silence_threshold: Number(status?.config?.silence_threshold ?? 0.3),
121 silence_duration: Number(status?.config?.silence_duration ?? 1000),
122 waiting_timeout: Number(status?.config?.waiting_timeout ?? 2000),
123 };
124 this.modelReady = !!status?.model?.ready;
125 this.modelLoading = !!status?.model?.loading;
126 this.loadedModel = status?.model?.loaded_model || "";
127 this.packageVersion = status?.package?.version || "";
128
129 if (this.enabled) {
130 this.registerProvider();
131 } else {
132 this.unregisterProvider();
133 }
134 } catch (error) {
135 this.error = error instanceof Error ? error.message : String(error);
136 this.unregisterProvider();
137 if (!suppressError) {
138 void toastFrontendError(this.error, "Whisper STT");
139 }
140 } finally {
141 this.loading = false;
142 this.updateMicrophoneButtonUI();
143 }
144 },
145
146 registerProvider() {
147 if (this.providerCleanup || !this.enabled) return;
148
149 this.providerCleanup = sttService.registerProvider(PLUGIN_NAME, {
150 handleMicrophoneClick: async () => await this.handleMicrophoneClick(),
151 requestMicrophonePermission: async () =>
152 await this.requestMicrophonePermission(),
153 updateMicrophoneButtonUI: () => this.updateMicrophoneButtonUI(),
154 stop: () => this.stop(),
155 getStatus: () => this.micStatus,
156 });
157
158 sttService.emitStatusChange(this.micStatus);
159 this.updateMicrophoneButtonUI();
160 },
161
162 unregisterProvider() {
163 if (!this.providerCleanup) return;
164
165 this.stop();
166 this.providerCleanup();
167 this.providerCleanup = null;
168 },
169
170 async openConfig() {
171 const { store } = await import("/components/plugins/plugin-settings-store.js");
172 await store.openConfig(PLUGIN_NAME);
173 },
174
175 openPanel() {
176 window.openModal?.(`/plugins/${PLUGIN_NAME}/webui/main.html`);
177 },
178
179 updateMicrophoneButtonUI() {
180 const status = this.enabled ? this.micStatus : "disabled";
181 const label = MicStatusLabels[status] || "Microphone";
182 const microphoneButtons = document.querySelectorAll(
183 "[data-whisper-microphone], #microphone-button",
184 );
185 for (const microphoneButton of microphoneButtons) {
186 clearMicrophoneTooltip(microphoneButton);
187 microphoneButton.classList.remove(...MicButtonClasses);
188 microphoneButton.classList.add(`mic-${status}`);
189 microphoneButton.setAttribute("data-status", status);
190 microphoneButton.setAttribute("aria-label", label);
191 microphoneButton.setAttribute(
192 "aria-pressed",
193 String(
194 status !== "disabled" &&
195 status !== Status.INACTIVE &&
196 status !== Status.ACTIVATING,
197 ),
198 );
199 }
200 },
201
202 async loadDevices() {
203 try {
204 const devices = await navigator.mediaDevices.enumerateDevices();
205 this.devices = devices.filter(
206 (device) => device.kind === "audioinput" && device.deviceId,
207 );
208
209 const saved = localStorage.getItem("whisperSttSelectedDevice") || "";
210 const savedStillExists = this.devices.some(
211 (device) => device.deviceId === saved,
212 );
213
214 if (savedStillExists) {
215 this.selectedDevice = saved;
216 return;
217 }
218
219 const defaultDevice =
220 this.devices.find((device) => device.deviceId === "default") ||
221 this.devices[0];
222 this.selectedDevice = defaultDevice?.deviceId || "";
223 } catch (error) {
224 console.error("[Whisper STT] Failed to enumerate audio devices", error);
225 this.devices = [];
226 this.selectedDevice = "";
227 }
228 },
229
230 async selectDevice(deviceId) {
231 this.selectedDevice = deviceId || "";
232 localStorage.setItem("whisperSttSelectedDevice", this.selectedDevice);
233
234 if (this.microphoneInput?.selectedDeviceId !== this.selectedDevice) {
235 this.stop();
236 this.microphoneInput = null;
237 }
238 },
239
240 getSelectedDevice() {
241 let device = this.devices.find(
242 (candidate) => candidate.deviceId === this.selectedDevice,
243 );
244
245 if (!device && this.devices.length > 0) {
246 device =
247 this.devices.find((candidate) => candidate.deviceId === "default") ||
248 this.devices[0];
249 }
250
251 return device || null;
252 },
253
254 async requestMicrophonePermission() {
255 this.requestingPermission = true;
256
257 try {
258 const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
259 stream.getTracks().forEach((track) => track.stop());
260 await this.loadDevices();
261 return true;
262 } catch (error) {
263 console.error("[Whisper STT] Microphone permission denied", error);
264 globalThis.toast?.(
265 "Microphone access denied. Please enable microphone access in your browser settings.",
266 "error",
267 );
268 return false;
269 } finally {
270 this.requestingPermission = false;
271 }
272 },
273
274 async handleMicrophoneClick(finalTranscriptHandler = null) {
275 if (this.isProcessingClick) return;
276
277 this.finalTranscriptHandler =
278 typeof finalTranscriptHandler === "function" ? finalTranscriptHandler : null;
279 this.isProcessingClick = true;
280 try {
281 await this.ensureStatusLoaded({ force: true, suppressError: false });
282 if (!this.enabled) {
283 this.finalTranscriptHandler = null;
284 globalThis.justToast?.("Whisper STT is disabled.", "info");
285 return;
286 }
287
288 ttsService.stop();
289
290 const selectedDevice = this.getSelectedDevice();
291 if (
292 this.microphoneInput &&
293 this.microphoneInput.selectedDeviceId !== (selectedDevice?.deviceId || "")
294 ) {
295 this.stop();
296 this.microphoneInput = null;
297 }
298
299 if (!this.microphoneInput) {
300 await this.initMicrophone();
301 }
302
303 if (this.microphoneInput) {
304 await this.microphoneInput.toggle();
305 }
306 } finally {
307 setTimeout(() => {
308 this.isProcessingClick = false;
309 }, 300);
310 }
311 },
312
313 async initMicrophone() {
314 if (this.microphoneInput) return this.microphoneInput;
315
316 const input = new MicrophoneInput(this, async (text, isFinal) => {
317 if (isFinal) {
318 await this.deliverVoiceMessage(text);
319 }
320 });
321
322 const initialized = await input.initialize();
323 this.microphoneInput = initialized ? input : null;
324 return this.microphoneInput;
325 },
326
327 async deliverVoiceMessage(text) {
328 if (this.finalTranscriptHandler) {
329 await this.finalTranscriptHandler(text, {
330 messageMode: this.config.message_mode,
331 sendImmediately: this.sendsImmediately,
332 });
333 return;
334 }
335 await this.sendVoiceMessage(text);
336 },
337
338 async sendVoiceMessage(text) {
339 const message = String(text || "").trim();
340 if (!message) return;
341
342 updateChatInput(message);
343
344 if (!this.sendsImmediately) {
345 this.stop();
346 return;
347 }
348
349 if (!this.microphoneInput?.messageSent) {
350 this.microphoneInput.messageSent = true;
351 await sendMessage();
352 }
353 },
354
355 notifyStatusChange() {
356 this.updateMicrophoneButtonUI();
357 sttService.emitStatusChange(this.micStatus);
358 },
359
360 stop() {
361 if (this.microphoneInput) {
362 this.microphoneInput.status = Status.INACTIVE;
363 this.microphoneInput.dispose();
364 this.microphoneInput = null;
365 }
366
367 this.finalTranscriptHandler = null;
368
369 this.notifyStatusChange();
370 },
371
372 get micStatus() {
373 return this.microphoneInput?.status || Status.INACTIVE;
374 },
375
376 get sendsImmediately() {
377 return this.config.message_mode !== "draft";
378 },
379
380 get messageModeLabel() {
381 return this.sendsImmediately ? "Send immediately" : "Draft in composer";
382 },
383
384 get statusText() {
385 if (!this.enabled) return "Disabled";
386 if (this.modelLoading) return "Loading";
387 if (this.modelReady) return "Ready";
388 return "Idle";
389 },
390
391 get statusClass() {
392 if (!this.enabled) return "warn";
393 if (this.modelLoading) return "warn";
394 if (this.modelReady) return "ok";
395 return "warn";
396 },
397
398 get selectedDeviceLabel() {
399 const device = this.getSelectedDevice();
400 if (!device) return "System default";
401 return device.label || "System default";
402 },
403 };
404
405 class MicrophoneInput {
406 constructor(owner, updateCallback) {
407 this.owner = owner;
408 this.updateCallback = updateCallback;
409 this.mediaStream = null;
410 this.mediaRecorder = null;
411 this.audioContext = null;
412 this.mediaStreamSource = null;
413 this.analyserNode = null;
414 this.audioChunks = [];
415 this.lastChunk = null;
416 this.messageSent = false;
417 this.lastAudioTime = null;
418 this.waitingTimer = null;
419 this.silenceStartTime = null;
420 this.hasStartedRecording = false;
421 this.analysisFrame = null;
422 this.selectedDeviceId = "";
423 this._status = Status.INACTIVE;
424 }
425
426 get status() {
427 return this._status;
428 }
429
430 set status(nextStatus) {
431 if (this._status === nextStatus) return;
432
433 const previousStatus = this._status;
434 this._status = nextStatus;
435 this.handleStatusChange(previousStatus, nextStatus);
436 this.owner.notifyStatusChange();
437 }
438
439 async initialize() {
440 this.status = Status.ACTIVATING;
441
442 try {
443 const selectedDevice = this.owner.getSelectedDevice();
444 const stream = await navigator.mediaDevices.getUserMedia({
445 audio: {
446 deviceId:
447 selectedDevice?.deviceId
448 ? { exact: selectedDevice.deviceId }
449 : undefined,
450 echoCancellation: true,
451 noiseSuppression: true,
452 channelCount: 1,
453 },
454 });
455
456 this.selectedDeviceId = selectedDevice?.deviceId || "";
457 this.mediaStream = stream;
458 this.mediaRecorder = new MediaRecorder(stream);
459 this.mediaRecorder.ondataavailable = (event) => {
460 if (
461 event.data.size > 0 &&
462 (this.status === Status.RECORDING || this.status === Status.WAITING)
463 ) {
464 if (this.lastChunk) {
465 this.audioChunks.push(this.lastChunk);
466 this.lastChunk = null;
467 }
468 this.audioChunks.push(event.data);
469 } else if (this.status === Status.LISTENING) {
470 this.lastChunk = event.data;
471 }
472 };
473
474 this.setupAudioAnalysis(stream);
475 return true;
476 } catch (error) {
477 console.error("[Whisper STT] Microphone initialization failed", error);
478 globalThis.toast?.(
479 "Failed to access the microphone. Please check browser permissions.",
480 "error",
481 );
482 this.status = Status.INACTIVE;
483 this.dispose();
484 return false;
485 }
486 }
487
488 handleStatusChange(previousStatus, nextStatus) {
489 if (nextStatus !== Status.RECORDING) {
490 this.lastChunk = null;
491 }
492
493 switch (nextStatus) {
494 case Status.INACTIVE:
495 this.handleInactiveState();
496 break;
497 case Status.LISTENING:
498 this.handleListeningState();
499 break;
500 case Status.RECORDING:
501 this.handleRecordingState();
502 break;
503 case Status.WAITING:
504 this.handleWaitingState();
505 break;
506 case Status.PROCESSING:
507 this.handleProcessingState();
508 break;
509 }
510 }
511
512 handleInactiveState() {
513 this.stopRecording();
514 this.stopAudioAnalysis();
515 clearTimeout(this.waitingTimer);
516 this.waitingTimer = null;
517 }
518
519 handleListeningState() {
520 this.stopRecording();
521 this.audioChunks = [];
522 this.hasStartedRecording = false;
523 this.silenceStartTime = null;
524 this.lastAudioTime = null;
525 this.messageSent = false;
526 this.startAudioAnalysis();
527 }
528
529 handleRecordingState() {
530 if (!this.mediaRecorder) return;
531
532 if (!this.hasStartedRecording && this.mediaRecorder.state !== "recording") {
533 this.hasStartedRecording = true;
534 this.mediaRecorder.start(1000);
535 }
536
537 clearTimeout(this.waitingTimer);
538 this.waitingTimer = null;
539 }
540
541 handleWaitingState() {
542 clearTimeout(this.waitingTimer);
543 this.waitingTimer = setTimeout(() => {
544 if (this.status === Status.WAITING) {
545 this.status = Status.PROCESSING;
546 }
547 }, this.owner.config.waiting_timeout);
548 }
549
550 handleProcessingState() {
551 this.stopRecording();
552 void this.process();
553 }
554
555 setupAudioAnalysis(stream) {
556 this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
557 this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
558 this.analyserNode = this.audioContext.createAnalyser();
559 this.analyserNode.fftSize = 2048;
560 this.analyserNode.minDecibels = -90;
561 this.analyserNode.maxDecibels = -10;
562 this.analyserNode.smoothingTimeConstant = 0.85;
563 this.mediaStreamSource.connect(this.analyserNode);
564 }
565
566 startAudioAnalysis() {
567 const analyzeFrame = () => {
568 if (this.status === Status.INACTIVE || !this.analyserNode) return;
569
570 const dataArray = new Uint8Array(this.analyserNode.fftSize);
571 this.analyserNode.getByteTimeDomainData(dataArray);
572
573 let sum = 0;
574 for (let index = 0; index < dataArray.length; index += 1) {
575 const amplitude = (dataArray[index] - 128) / 128;
576 sum += amplitude * amplitude;
577 }
578
579 const rms = Math.sqrt(sum / dataArray.length);
580 const now = Date.now();
581 const silenceThreshold = this.densify(this.owner.config.silence_threshold);
582
583 if (rms > silenceThreshold) {
584 this.lastAudioTime = now;
585 this.silenceStartTime = null;
586
587 if (
588 (this.status === Status.LISTENING || this.status === Status.WAITING) &&
589 !ttsService.isSpeaking()
590 ) {
591 this.status = Status.RECORDING;
592 }
593 } else if (this.status === Status.RECORDING) {
594 if (!this.silenceStartTime) {
595 this.silenceStartTime = now;
596 }
597
598 const silenceDuration = now - this.silenceStartTime;
599 if (silenceDuration >= this.owner.config.silence_duration) {
600 this.status = Status.WAITING;
601 }
602 }
603
604 this.analysisFrame = requestAnimationFrame(analyzeFrame);
605 };
606
607 this.stopAudioAnalysis();
608 this.analysisFrame = requestAnimationFrame(analyzeFrame);
609 }
610
611 stopAudioAnalysis() {
612 if (this.analysisFrame) {
613 cancelAnimationFrame(this.analysisFrame);
614 this.analysisFrame = null;
615 }
616 }
617
618 stopRecording() {
619 if (this.mediaRecorder?.state === "recording") {
620 this.mediaRecorder.stop();
621 this.hasStartedRecording = false;
622 }
623 }
624
625 densify(value) {
626 return Math.exp(-5 * (1 - value));
627 }
628
629 async process() {
630 if (this.audioChunks.length === 0) {
631 if (this.status === Status.PROCESSING) {
632 this.status = Status.LISTENING;
633 }
634 return;
635 }
636
637 const audioBlob = new Blob(this.audioChunks, { type: "audio/wav" });
638 const audio = await this.convertBlobToBase64(audioBlob);
639
640 try {
641 const result = await callJsonApi(`/plugins/${PLUGIN_NAME}/transcribe`, {
642 audio,
643 });
644 const text = this.filterResult(result?.text || "");
645 if (text) {
646 await this.updateCallback(text, true);
647 }
648 } catch (error) {
649 console.error("[Whisper STT] Transcription failed", error);
650 window.toastFetchError?.("Transcription error", error);
651 } finally {
652 this.audioChunks = [];
653 if (this.status === Status.PROCESSING) {
654 this.status = Status.LISTENING;
655 }
656 }
657 }
658
659 convertBlobToBase64(audioBlob) {
660 return new Promise((resolve, reject) => {
661 const reader = new FileReader();
662 reader.onloadend = () => {
663 const result = String(reader.result || "");
664 resolve(result.split(",")[1] || "");
665 };
666 reader.onerror = (error) => reject(error);
667 reader.readAsDataURL(audioBlob);
668 });
669 }
670
671 filterResult(text) {
672 const normalized = String(text || "").trim();
673 if (!normalized) return "";
674
675 const wrapped =
676 (normalized.startsWith("{") && normalized.endsWith("}")) ||
677 (normalized.startsWith("(") && normalized.endsWith(")")) ||
678 (normalized.startsWith("[") && normalized.endsWith("]"));
679
680 if (wrapped) {
681 console.log(`[Whisper STT] Discarding transcription: ${normalized}`);
682 return "";
683 }
684
685 return normalized;
686 }
687
688 async toggle() {
689 const hasPermission = await this.requestPermission();
690 if (!hasPermission) return;
691
692 if (
693 this.status === Status.INACTIVE ||
694 this.status === Status.ACTIVATING
695 ) {
696 this.status = Status.LISTENING;
697 } else {
698 this.owner.stop();
699 }
700 }
701
702 async requestPermission() {
703 return await this.owner.requestMicrophonePermission();
704 }
705
706 dispose() {
707 clearTimeout(this.waitingTimer);
708 this.waitingTimer = null;
709 this.stopAudioAnalysis();
710
711 try {
712 this.mediaRecorder?.stream?.getTracks?.().forEach((track) => track.stop());
713 } catch (_error) {
714 // Ignore media cleanup failures.
715 }
716
717 try {
718 this.mediaStream?.getTracks?.().forEach((track) => track.stop());
719 } catch (_error) {
720 // Ignore media cleanup failures.
721 }
722
723 try {
724 this.audioContext?.close?.();
725 } catch (_error) {
726 // Ignore audio context cleanup failures.
727 }
728
729 this.mediaStream = null;
730 this.mediaRecorder = null;
731 this.mediaStreamSource = null;
732 this.analyserNode = null;
733 this.audioContext = null;
734 this.audioChunks = [];
735 this.lastChunk = null;
736 this.hasStartedRecording = false;
737 }
738 }
739
740 export const store = createStore("whisperStt", model);