1
-import { createStore } from "/js/AlpineStore.js";
2
-import { updateChatInput, sendMessage } from "/index.js";
3
-import { sleep } from "/js/sleep.js";
4
-import { store as microphoneSettingStore } from "/components/settings/speech/microphone-setting-store.js";
5
-import * as shortcuts from "/js/shortcuts.js";
6
-
7
-const Status = {
8
- INACTIVE: "inactive",
9
- ACTIVATING: "activating",
10
- LISTENING: "listening",
11
- RECORDING: "recording",
12
- WAITING: "waiting",
13
- PROCESSING: "processing",
14
-};
15
-
16
-// Create the speech store
17
-const model = {
18
- // Initialization guard
19
- _initialized: false,
20
-
21
- // STT Settings
22
- stt_model_size: "tiny",
23
- stt_language: "en",
24
- stt_silence_threshold: 0.05,
25
- stt_silence_duration: 1000,
26
- stt_waiting_timeout: 2000,
27
-
28
- // TTS Settings
29
- tts_kokoro: false,
30
-
31
- // TTS State
32
- isSpeaking: false,
33
- speakingId: "",
34
- speakingText: "",
35
- currentAudio: null,
36
- audioEl: null,
37
- audioContext: null,
38
- userHasInteracted: false,
39
- stopSpeechChain: false,
40
- ttsStream: null,
41
-
42
- // STT State
43
- microphoneInput: null,
44
- isProcessingClick: false,
45
- selectedDevice: null,
46
-
47
- // Getter for micStatus - delegates to microphoneInput
48
- get micStatus() {
49
- return this.microphoneInput?.status || Status.INACTIVE;
50
- },
51
-
52
- updateMicrophoneButtonUI() {
53
- const microphoneButton = document.getElementById("microphone-button");
54
- if (!microphoneButton) return;
55
- const status = this.micStatus;
56
- microphoneButton.classList.remove(
57
- "mic-inactive",
58
- "mic-activating",
59
- "mic-listening",
60
- "mic-recording",
61
- "mic-waiting",
62
- "mic-processing"
63
- );
64
- microphoneButton.classList.add(`mic-${status.toLowerCase()}`);
65
- microphoneButton.setAttribute("data-status", status);
66
- },
67
-
68
- async handleMicrophoneClick() {
69
- if (this.isProcessingClick) return;
70
- this.isProcessingClick = true;
71
- try {
72
- // reset mic input if device has changed in settings
73
- const device = microphoneSettingStore.getSelectedDevice();
74
- if (device != this.selectedDevice) {
75
- this.selectedDevice = device;
76
- this.microphoneInput = null;
77
- console.log("Device changed, microphoneInput reset");
78
- }
79
-
80
- if (!this.microphoneInput) {
81
- await this.initMicrophone();
82
- }
83
-
84
- if (this.microphoneInput) {
85
- await this.microphoneInput.toggle();
86
- }
87
- } finally {
88
- setTimeout(() => {
89
- this.isProcessingClick = false;
90
- }, 300);
91
- }
92
- },
93
-
94
- // Initialize speech functionality
95
- async init() {
96
- // Guard against multiple initializations
97
- if (this._initialized) {
98
- console.log(
99
- "[Speech Store] Already initialized, skipping duplicate init()"
100
- );
101
- return;
102
- }
103
-
104
- this._initialized = true;
105
- await this.loadSettings();
106
- this.setupBrowserTTS();
107
- this.setupUserInteractionHandling();
108
- },
109
-
110
- // Load settings from server
111
- async loadSettings() {
112
- try {
113
- const response = await fetchApi("/settings_get", { method: "POST" });
114
- const data = await response.json();
115
- const settings = data?.settings || {};
116
-
117
- if (settings) {
118
- this.stt_model_size = settings.stt_model_size ?? this.stt_model_size;
119
- this.stt_language = settings.stt_language ?? this.stt_language;
120
- this.stt_silence_threshold =
121
- settings.stt_silence_threshold ?? this.stt_silence_threshold;
122
- this.stt_silence_duration =
123
- settings.stt_silence_duration ?? this.stt_silence_duration;
124
- this.stt_waiting_timeout =
125
- settings.stt_waiting_timeout ?? this.stt_waiting_timeout;
126
- this.tts_kokoro = settings.tts_kokoro ?? this.tts_kokoro;
127
- }
128
- } catch (error) {
129
- window.toastFetchError("Failed to load speech settings", error);
130
- console.error("Failed to load speech settings:", error);
131
- }
132
- },
133
-
134
- // Setup browser TTS
135
- setupBrowserTTS() {
136
- this.synth = window.speechSynthesis;
137
- this.browserUtterance = null;
138
- },
139
-
140
- // Setup user interaction handling for autoplay policy
141
- setupUserInteractionHandling() {
142
- const enableAudio = () => {
143
- if (!this.userHasInteracted) {
144
- this.userHasInteracted = true;
145
- console.log("User interaction detected - audio playback enabled");
146
-
147
- // Create a dummy audio context to "unlock" audio
148
- try {
149
- this.audioContext = new (window.AudioContext ||
150
- window.webkitAudioContext)();
151
- this.audioContext.resume();
152
- } catch (e) {
153
- console.log("AudioContext not available");
154
- }
155
- }
156
- };
157
-
158
- // Listen for any user interaction
159
- const events = ["click", "touchstart", "keydown", "mousedown"];
160
- events.forEach((event) => {
161
- document.addEventListener(event, enableAudio, {
162
- once: true,
163
- passive: true,
164
- });
165
- });
166
- },
167
-
168
- // main speak function, allows to speak a stream of text that is generated piece by piece
169
- async speakStream(id, text, finished = false) {
170
- // if already running the same stream, do nothing
171
- if (
172
- this.ttsStream &&
173
- this.ttsStream.id === id &&
174
- this.ttsStream.text === text &&
175
- this.ttsStream.finished === finished
176
- )
177
- return;
178
-
179
- // if user has not interacted (after reload), do not play audio
180
- if (!this.userHasInteracted) return this.showAudioPermissionPrompt();
181
-
182
- // new stream
183
- if (!this.ttsStream || this.ttsStream.id !== id) {
184
- // this.stop(); // stop potential previous stream
185
- // create new stream data
186
- this.ttsStream = {
187
- id,
188
- text,
189
- finished,
190
- running: false,
191
- lastChunkIndex: -1,
192
- stopped: false,
193
- chunks: [],
194
- };
195
- } else {
196
- // update existing stream data
197
- this.ttsStream.finished = finished;
198
- this.ttsStream.text = text;
199
- }
200
-
201
- // cleanup text
202
- const cleanText = this.cleanText(text);
203
- if (!cleanText.trim()) return;
204
-
205
- // chunk it for faster processing
206
- this.ttsStream.chunks = this.chunkText(cleanText);
207
- if (this.ttsStream.chunks.length == 0) return;
208
-
209
- // if stream was already running, just updating chunks is enough
210
- // The running loop will pick up the new chunks automatically
211
- if (this.ttsStream.running) return;
212
- else this.ttsStream.running = true; // proceed to running phase
213
-
214
- // terminator function to kill the stream if new stream has started
215
- const terminator = () =>
216
- this.ttsStream?.id !== id || this.ttsStream?.stopped;
217
-
218
- const spoken = [];
219
-
220
- // continuously loop until all chunks are spoken and stream is finished
221
- while (true) {
222
- // check if we should stop
223
- if (terminator()) break;
224
-
225
- // get the next chunk index to speak
226
- const nextIndex = this.ttsStream.lastChunkIndex + 1;
227
-
228
- // if no more chunks available, check if we should wait or exit
229
- if (nextIndex >= this.ttsStream.chunks.length) {
230
- // if stream is finished, we're done
231
- if (this.ttsStream.finished) break;
232
- // otherwise wait a bit for more chunks to arrive
233
- await new Promise((resolve) => setTimeout(resolve, 50));
234
- continue;
235
- }
236
-
237
- // do not speak the last chunk until finished (it is being generated)
238
- if (
239
- nextIndex == this.ttsStream.chunks.length - 1 &&
240
- !this.ttsStream.finished
241
- ) {
242
- // wait a bit for more content or finish signal
243
- await new Promise((resolve) => setTimeout(resolve, 50));
244
- continue;
245
- }
246
-
247
- // set the index of last spoken chunk
248
- this.ttsStream.lastChunkIndex = nextIndex;
249
-
250
- // speak the chunk
251
- const chunk = this.ttsStream.chunks[nextIndex];
252
- spoken.push(chunk);
253
- await this._speak(chunk, nextIndex > 0, () => terminator());
254
- }
255
-
256
- // at the end, finish stream data
257
- this.ttsStream.running = false;
258
- },
259
-
260
- // simplified speak function, speak a single finished piece of text
261
- async speak(text) {
262
- const id = Math.random();
263
- return await this.speakStream(id, text, true);
264
- },
265
-
266
- // speak wrapper
267
- async _speak(text, waitForPrevious, terminator) {
268
- // default browser speech
269
- if (!this.tts_kokoro)
270
- return await this.speakWithBrowser(text, waitForPrevious, terminator);
271
-
272
- // kokoro tts
273
- try {
274
- await await this.speakWithKokoro(text, waitForPrevious, terminator);
275
- } catch (error) {
276
- console.error(error);
277
- return await this.speakWithBrowser(text, waitForPrevious, terminator);
278
- }
279
- },
280
-
281
- chunkText(text, { maxChunkLength = 135, lineSeparator = "..." } = {}) {
282
- const INC_LIMIT = maxChunkLength * 2;
283
- const MIN_CHUNK_LENGTH = 20; // minimum length for a chunk before merging
284
-
285
- // Only split by ,/word if needed (unchanged)
286
- const splitDeep = (seg) => {
287
- if (seg.length <= INC_LIMIT) return [seg];
288
- const byComma = seg.match(/[^,]+(?:,|$)/g);
289
- if (byComma.length > 1)
290
- return byComma.flatMap((p, i) =>
291
- splitDeep(i < byComma.length - 1 ? p : p.replace(/,$/, ""))
292
- );
293
- const out = [];
294
- let part = "";
295
- for (const word of seg.split(/\s+/)) {
296
- const need = part ? part.length + 1 + word.length : word.length;
297
- if (need <= maxChunkLength) {
298
- part += (part ? " " : "") + word;
299
- } else {
300
- if (part) out.push(part);
301
- if (word.length > maxChunkLength) {
302
- for (let i = 0; i < word.length; i += maxChunkLength)
303
- out.push(word.slice(i, i + maxChunkLength));
304
- part = "";
305
- } else {
306
- part = word;
307
- }
308
- }
309
- }
310
- if (part) out.push(part);
311
- return out;
312
- };
313
-
314
- // Only split on [.!?] followed by space
315
- const sentenceTokens = (line) => {
316
- const toks = [];
317
- let start = 0;
318
- for (let i = 0; i < line.length; i++) {
319
- const c = line[i];
320
- if (
321
- (c === "." || c === "!" || c === "?") &&
322
- /\s/.test(line[i + 1] || "")
323
- ) {
324
- toks.push(line.slice(start, i + 1));
325
- i += 1;
326
- start = i + 1;
327
- }
328
- }
329
- if (start < line.length) toks.push(line.slice(start));
330
- return toks;
331
- };
332
-
333
- // Step 1: Split all newlines into individual chunks first
334
- let initialChunks = [];
335
- const lines = text.split(/\n+/).filter((l) => l.trim());
336
-
337
- for (const line of lines) {
338
- if (!line.trim()) continue;
339
- // Process each line into sentence tokens and add to chunks
340
- const sentences = sentenceTokens(line.trim());
341
- initialChunks.push(...sentences);
342
- }
343
-
344
- // Step 2: Merge short chunks until they meet minimum length criteria
345
- const finalChunks = [];
346
- let currentChunk = "";
347
-
348
- for (let i = 0; i < initialChunks.length; i++) {
349
- const chunk = initialChunks[i];
350
-
351
- // If current chunk is empty, start with this chunk
352
- if (!currentChunk) {
353
- currentChunk = chunk;
354
- // If this is the last chunk or it's already long enough, add it
355
- if (
356
- i === initialChunks.length - 1 ||
357
- currentChunk.length >= MIN_CHUNK_LENGTH
358
- ) {
359
- finalChunks.push(currentChunk);
360
- currentChunk = "";
361
- }
362
- continue;
363
- }
364
-
365
- // Current chunk exists, check if we should merge
366
- if (currentChunk.length < MIN_CHUNK_LENGTH) {
367
- // Try to merge with separator
368
- const merged = currentChunk + " " + lineSeparator + " " + chunk;
369
-
370
- // Check if merged chunk fits within max length
371
- if (merged.length <= maxChunkLength) {
372
- currentChunk = merged;
373
- } else {
374
- // Doesn't fit, add current chunk and start new one
375
- finalChunks.push(currentChunk);
376
- currentChunk = chunk;
377
- }
378
- } else {
379
- // Current chunk is already long enough, add it and start new one
380
- finalChunks.push(currentChunk);
381
- currentChunk = chunk;
382
- }
383
-
384
- // If this is the last chunk, add whatever is in the buffer
385
- if (i === initialChunks.length - 1 && currentChunk) {
386
- finalChunks.push(currentChunk);
387
- }
388
- }
389
-
390
- return finalChunks.map((chunk) => chunk.trimEnd());
391
- },
392
-
393
- // Show a prompt to user to enable audio
394
- showAudioPermissionPrompt() {
395
- shortcuts.frontendNotification({
396
- type: "info",
397
- message: "Click anywhere to enable audio playback",
398
- displayTime: 5000,
399
- frontendOnly: true,
400
- });
401
- console.log("Please click anywhere on the page to enable audio playback");
402
- },
403
-
404
- // Browser TTS
405
- async speakWithBrowser(text, waitForPrevious = false, terminator = null) {
406
- // wait for previous to finish if requested
407
- while (waitForPrevious && this.isSpeaking) await sleep(25);
408
- if (terminator && terminator()) return;
409
-
410
- // stop previous only if not waiting for it
411
- if (!waitForPrevious) this.stopAudio();
412
-
413
- this.browserUtterance = new SpeechSynthesisUtterance(text);
414
- this.browserUtterance.onstart = () => {
415
- this.isSpeaking = true;
416
- };
417
- this.browserUtterance.onend = () => {
418
- this.isSpeaking = false;
419
- };
420
-
421
- this.synth.speak(this.browserUtterance);
422
- },
423
-
424
- // Kokoro TTS
425
- async speakWithKokoro(text, waitForPrevious = false, terminator = null) {
426
- try {
427
- // synthesize on the backend
428
- const response = await sendJsonData("/synthesize", { text });
429
-
430
- // wait for previous to finish if requested
431
- while (waitForPrevious && this.isSpeaking) await sleep(25);
432
- if (terminator && terminator()) return;
433
-
434
- // stop previous only if not waiting for it
435
- if (!waitForPrevious) this.stopAudio();
436
-
437
- if (response.success) {
438
- if (response.audio_parts) {
439
- // Multiple chunks - play sequentially
440
- for (const audioPart of response.audio_parts) {
441
- if (terminator && terminator()) return;
442
- await this.playAudio(audioPart);
443
- await sleep(100); // Brief pause
444
- }
445
- } else if (response.audio) {
446
- // Single audio
447
- this.playAudio(response.audio);
448
- }
449
- } else {
450
- throw new Error("Kokoro TTS error:", response.error);
451
- }
452
- } catch (error) {
453
- throw new Error("Kokoro TTS error:", error);
454
- }
455
- },
456
-
457
- // Play base64 audio
458
- async playAudio(base64Audio) {
459
- return new Promise((resolve, reject) => {
460
- const audio = this.audioEl ? this.audioEl : (this.audioEl = new Audio());
461
-
462
- // Reset any previous playback state
463
- audio.pause();
464
- audio.currentTime = 0;
465
-
466
- audio.onplay = () => {
467
- this.isSpeaking = true;
468
- };
469
- audio.onended = () => {
470
- this.isSpeaking = false;
471
- this.currentAudio = null;
472
- resolve();
473
- };
474
- audio.onerror = (error) => {
475
- this.isSpeaking = false;
476
- this.currentAudio = null;
477
- reject(error);
478
- };
479
-
480
- audio.src = `data:audio/wav;base64,${base64Audio}`;
481
- this.currentAudio = audio;
482
-
483
- audio.play().catch((error) => {
484
- this.isSpeaking = false;
485
- this.currentAudio = null;
486
-
487
- if (error.name === "NotAllowedError") {
488
- this.showAudioPermissionPrompt();
489
- this.userHasInteracted = false;
490
- }
491
- reject(error);
492
- });
493
- });
494
- },
495
-
496
- // Stop current speech chain
497
- stop() {
498
- this.stopAudio(); // stop current audio immediately
499
- if (this.ttsStream) this.ttsStream.stopped = true; // set stop on current stream
500
- },
501
-
502
- // Stop current speech audio
503
- stopAudio() {
504
- if (this.synth?.speaking) {
505
- this.synth.cancel();
506
- }
507
-
508
- if (this.audioEl) {
509
- this.audioEl.pause();
510
- this.audioEl.currentTime = 0;
511
- }
512
- this.currentAudio = null;
513
- this.isSpeaking = false;
514
- },
515
-
516
- // Clean text for TTS
517
- cleanText(text) {
518
- // Use SUB character (ASCII 26, 0x1A) for placeholders to avoid conflicts with actual text
519
- const SUB = "\x1A"; // non-printable substitute character
520
- const codePlaceholder = SUB + "code" + SUB;
521
- const tablePlaceholder = SUB + "table" + SUB;
522
-
523
- // Handle code blocks BEFORE HTML parsing (markdown code blocks)
524
- text = text.replace(/```(?:[a-zA-Z0-9]*\n)?[\s\S]*?```/g, codePlaceholder); // closed code blocks
525
- text = text.replace(/```(?:[a-zA-Z0-9]*\n)?[\s\S]*$/g, codePlaceholder); // unclosed code blocks
526
-
527
- // Replace inline code ticks with content preserved
528
- text = text.replace(/`([^`]*)`/g, "$1"); // remove backticks but keep content
529
-
530
- // Parse HTML using browser's DOMParser to properly extract text content
531
- try {
532
- const parser = new DOMParser();
533
- // Wrap in a div to handle fragments
534
- const doc = parser.parseFromString(`<div>${text}</div>`, 'text/html');
535
-
536
- // Replace <pre> and <code> tags with placeholder before extracting text
537
- doc.querySelectorAll('pre, code').forEach(el => {
538
- el.textContent = codePlaceholder;
539
- });
540
-
541
- // Extract text content (this strips all HTML tags properly)
542
- text = doc.body.textContent || "";
543
- } catch (e) {
544
- // Fallback: simple tag stripping if DOMParser fails
545
- console.warn("[Speech Store] DOMParser failed, using fallback:", e);
546
- text = text.replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, codePlaceholder);
547
- text = text.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, codePlaceholder);
548
- text = text.replace(/<[^>]+>/g, ''); // strip remaining tags
549
- }
550
-
551
- // Remove markdown links: [label](url) → label
552
- text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1");
553
-
554
- // Remove markdown formatting: *, _, #
555
- text = text.replace(/[*_#]+/g, "");
556
-
557
- // Handle tables - both complete and partial
558
- // Check if text contains a table-like pattern
559
- if (text.includes("|")) {
560
- // Find consecutive lines with | characters (table rows)
561
- const tableLines = text
562
- .split("\n")
563
- .filter((line) => line.includes("|") && line.trim().startsWith("|"));
564
- if (tableLines.length > 0) {
565
- // Replace each table line with a placeholder
566
- for (const line of tableLines) {
567
- text = text.replace(line, tablePlaceholder);
568
- }
569
- } else {
570
- // Just handle individual table rows
571
- text = text.replace(/\|[^\n]*\|/g, tablePlaceholder);
572
- }
573
- }
574
-
575
- // Remove emojis and private unicode blocks
576
- text = text.replace(
577
- /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
578
- ""
579
- );
580
-
581
- // Replace URLs with just the domain name
582
- text = text.replace(/https?:\/\/[^\s]+/g, (match) => {
583
- try {
584
- return new URL(match).hostname;
585
- } catch {
586
- return "";
587
- }
588
- });
589
-
590
- // Remove email addresses
591
- // text = text.replace(/\S+@\S+/g, "");
592
-
593
- // Replace UUIDs with 'UUID'
594
- text = text.replace(
595
- /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
596
- "UUID"
597
- );
598
-
599
- // Collapse multiple spaces/tabs to a single space, but preserve newlines
600
- text = text.replace(/[ \t]+/g, " ");
601
-
602
- // Function to merge consecutive placeholders of any type
603
- function mergePlaceholders(txt, placeholder, replacement) {
604
- // Create regex for consecutive placeholders (with possible whitespace between)
605
- const regex = new RegExp(placeholder + "\\s*" + placeholder, "g");
606
- // Merge consecutive placeholders until no more found
607
- while (regex.test(txt)) {
608
- txt = txt.replace(regex, placeholder);
609
- }
610
- // Replace all remaining placeholders with human-readable text
611
- return txt.replace(new RegExp(placeholder, "g"), replacement);
612
- }
613
-
614
- // Apply placeholder merging for both types
615
- text = mergePlaceholders(text, codePlaceholder, "See code attached ...");
616
- text = mergePlaceholders(text, tablePlaceholder, "See table attached ...");
617
-
618
- // Trim leading/trailing whitespace
619
- text = text.trim();
620
-
621
- return text;
622
- },
623
-
624
- // Initialize microphone input
625
- async initMicrophone() {
626
- if (this.microphoneInput) return this.microphoneInput;
627
-
628
- this.microphoneInput = new MicrophoneInput(async (text, isFinal) => {
629
- if (isFinal) {
630
- this.sendMessage(text);
631
- }
632
- });
633
-
634
- const initialized = await this.microphoneInput.initialize();
635
- return initialized ? this.microphoneInput : null;
636
- },
637
-
638
- async sendMessage(text) {
639
- text = "(voice) " + text;
640
- updateChatInput(text);
641
- if (!this.microphoneInput.messageSent) {
642
- this.microphoneInput.messageSent = true;
643
- await sendMessage();
644
- }
645
- },
646
-
647
- // Request microphone permission - delegate to MicrophoneInput
648
- async requestMicrophonePermission() {
649
- return this.microphoneInput
650
- ? this.microphoneInput.requestPermission()
651
- : MicrophoneInput.prototype.requestPermission.call(null);
652
- },
653
-};
654
-
655
-// Microphone Input Class (simplified for store integration)
656
-class MicrophoneInput {
657
- constructor(updateCallback) {
658
- this.mediaRecorder = null;
659
- this.audioChunks = [];
660
- this.lastChunk = [];
661
- this.updateCallback = updateCallback;
662
- this.messageSent = false;
663
- this.audioContext = null;
664
- this.mediaStreamSource = null;
665
- this.analyserNode = null;
666
- this._status = Status.INACTIVE;
667
- this.lastAudioTime = null;
668
- this.waitingTimer = null;
669
- this.silenceStartTime = null;
670
- this.hasStartedRecording = false;
671
- this.analysisFrame = null;
672
- }
673
-
674
- get status() {
675
- return this._status;
676
- }
677
-
678
- set status(newStatus) {
679
- if (this._status === newStatus) return;
680
-
681
- const oldStatus = this._status;
682
- this._status = newStatus;
683
- console.log(`Mic status changed from ${oldStatus} to ${newStatus}`);
684
-
685
- this.handleStatusChange(oldStatus, newStatus);
686
- }
687
-
688
- async initialize() {
689
- // Set status to activating at the start of initialization
690
- this.status = Status.ACTIVATING;
691
- try {
692
- // get selected device from microphone settings
693
- const selectedDevice = microphoneSettingStore.getSelectedDevice();
694
-
695
- const stream = await navigator.mediaDevices.getUserMedia({
696
- audio: {
697
- deviceId:
698
- selectedDevice && selectedDevice.deviceId
699
- ? { exact: selectedDevice.deviceId }
700
- : undefined,
701
- echoCancellation: true,
702
- noiseSuppression: true,
703
- channelCount: 1,
704
- },
705
- });
706
-
707
- this.mediaRecorder = new MediaRecorder(stream);
708
- this.mediaRecorder.ondataavailable = (event) => {
709
- if (
710
- event.data.size > 0 &&
711
- (this.status === Status.RECORDING || this.status === Status.WAITING)
712
- ) {
713
- if (this.lastChunk) {
714
- this.audioChunks.push(this.lastChunk);
715
- this.lastChunk = null;
716
- }
717
- this.audioChunks.push(event.data);
718
- } else if (this.status === Status.LISTENING) {
719
- this.lastChunk = event.data;
720
- }
721
- };
722
-
723
- this.setupAudioAnalysis(stream);
724
- return true;
725
- } catch (error) {
726
- console.error("Microphone initialization error:", error);
727
- toast("Failed to access microphone. Please check permissions.", "error");
728
- return false;
729
- }
730
- }
731
-
732
- handleStatusChange(oldStatus, newStatus) {
733
- if (newStatus != Status.RECORDING) {
734
- this.lastChunk = null;
735
- }
736
-
737
- switch (newStatus) {
738
- case Status.INACTIVE:
739
- this.handleInactiveState();
740
- break;
741
- case Status.LISTENING:
742
- this.handleListeningState();
743
- break;
744
- case Status.RECORDING:
745
- this.handleRecordingState();
746
- break;
747
- case Status.WAITING:
748
- this.handleWaitingState();
749
- break;
750
- case Status.PROCESSING:
751
- this.handleProcessingState();
752
- break;
753
- }
754
- }
755
-
756
- handleInactiveState() {
757
- this.stopRecording();
758
- this.stopAudioAnalysis();
759
- if (this.waitingTimer) {
760
- clearTimeout(this.waitingTimer);
761
- this.waitingTimer = null;
762
- }
763
- }
764
-
765
- handleListeningState() {
766
- this.stopRecording();
767
- this.audioChunks = [];
768
- this.hasStartedRecording = false;
769
- this.silenceStartTime = null;
770
- this.lastAudioTime = null;
771
- this.messageSent = false;
772
- this.startAudioAnalysis();
773
- }
774
-
775
- handleRecordingState() {
776
- if (!this.hasStartedRecording && this.mediaRecorder.state !== "recording") {
777
- this.hasStartedRecording = true;
778
- this.mediaRecorder.start(1000);
779
- console.log("Speech started");
780
- }
781
- if (this.waitingTimer) {
782
- clearTimeout(this.waitingTimer);
783
- this.waitingTimer = null;
784
- }
785
- }
786
-
787
- handleWaitingState() {
788
- this.waitingTimer = setTimeout(() => {
789
- if (this.status === Status.WAITING) {
790
- this.status = Status.PROCESSING;
791
- }
792
- }, store.stt_waiting_timeout);
793
- }
794
-
795
- handleProcessingState() {
796
- this.stopRecording();
797
- this.process();
798
- }
799
-
800
- setupAudioAnalysis(stream) {
801
- this.audioContext = new (window.AudioContext ||
802
- window.webkitAudioContext)();
803
- this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
804
- this.analyserNode = this.audioContext.createAnalyser();
805
- this.analyserNode.fftSize = 2048;
806
- this.analyserNode.minDecibels = -90;
807
- this.analyserNode.maxDecibels = -10;
808
- this.analyserNode.smoothingTimeConstant = 0.85;
809
- this.mediaStreamSource.connect(this.analyserNode);
810
- }
811
-
812
- startAudioAnalysis() {
813
- const analyzeFrame = () => {
814
- if (this.status === Status.INACTIVE) return;
815
-
816
- const dataArray = new Uint8Array(this.analyserNode.fftSize);
817
- this.analyserNode.getByteTimeDomainData(dataArray);
818
-
819
- let sum = 0;
820
- for (let i = 0; i < dataArray.length; i++) {
821
- const amplitude = (dataArray[i] - 128) / 128;
822
- sum += amplitude * amplitude;
823
- }
824
- const rms = Math.sqrt(sum / dataArray.length);
825
- const now = Date.now();
826
-
827
- // Update status based on audio level (ignore if TTS is speaking)
828
- if (rms > this.densify(store.stt_silence_threshold)) {
829
- this.lastAudioTime = now;
830
- this.silenceStartTime = null;
831
-
832
- if (
833
- (this.status === Status.LISTENING ||
834
- this.status === Status.WAITING) &&
835
- !store.isSpeaking
836
- ) {
837
- this.status = Status.RECORDING;
838
- }
839
- } else if (this.status === Status.RECORDING) {
840
- if (!this.silenceStartTime) {
841
- this.silenceStartTime = now;
842
- }
843
-
844
- const silenceDuration = now - this.silenceStartTime;
845
- if (silenceDuration >= store.stt_silence_duration) {
846
- this.status = Status.WAITING;
847
- }
848
- }
849
-
850
- this.analysisFrame = requestAnimationFrame(analyzeFrame);
851
- };
852
-
853
- this.analysisFrame = requestAnimationFrame(analyzeFrame);
854
- }
855
-
856
- stopAudioAnalysis() {
857
- if (this.analysisFrame) {
858
- cancelAnimationFrame(this.analysisFrame);
859
- this.analysisFrame = null;
860
- }
861
- }
862
-
863
- stopRecording() {
864
- if (this.mediaRecorder?.state === "recording") {
865
- this.mediaRecorder.stop();
866
- this.hasStartedRecording = false;
867
- }
868
- }
869
-
870
- densify(x) {
871
- return Math.exp(-5 * (1 - x));
872
- }
873
-
874
- async process() {
875
- if (this.audioChunks.length === 0) {
876
- this.status = Status.LISTENING;
877
- return;
878
- }
879
-
880
- const audioBlob = new Blob(this.audioChunks, { type: "audio/wav" });
881
- const base64 = await this.convertBlobToBase64Wav(audioBlob);
882
-
883
- try {
884
- const result = await sendJsonData("/transcribe", { audio: base64 });
885
- const text = this.filterResult(result.text || "");
886
-
887
- if (text) {
888
- console.log("Transcription:", result.text);
889
- await this.updateCallback(result.text, true);
890
- }
891
- } catch (error) {
892
- window.toastFetchError("Transcription error", error);
893
- console.error("Transcription error:", error);
894
- } finally {
895
- this.audioChunks = [];
896
- this.status = Status.LISTENING;
897
- }
898
- }
899
-
900
- convertBlobToBase64Wav(audioBlob) {
901
- return new Promise((resolve, reject) => {
902
- const reader = new FileReader();
903
- reader.onloadend = () => {
904
- const base64Data = reader.result.split(",")[1];
905
- resolve(base64Data);
906
- };
907
- reader.onerror = (error) => reject(error);
908
- reader.readAsDataURL(audioBlob);
909
- });
910
- }
911
-
912
- filterResult(text) {
913
- text = text.trim();
914
- let ok = false;
915
- while (!ok) {
916
- if (!text) break;
917
- if (text[0] === "{" && text[text.length - 1] === "}") break;
918
- if (text[0] === "(" && text[text.length - 1] === ")") break;
919
- if (text[0] === "[" && text[text.length - 1] === "]") break;
920
- ok = true;
921
- }
922
- if (ok) return text;
923
- else console.log(`Discarding transcription: ${text}`);
924
- }
925
-
926
- // Toggle microphone between active and inactive states
927
- async toggle() {
928
- const hasPermission = await this.requestPermission();
929
- if (!hasPermission) return;
930
-
931
- // Toggle between listening and inactive
932
- if (this.status === Status.INACTIVE || this.status === Status.ACTIVATING) {
933
- this.status = Status.LISTENING;
934
- } else {
935
- this.status = Status.INACTIVE;
936
- }
937
- }
938
-
939
- // Request microphone permission
940
- async requestPermission() {
941
- try {
942
- await navigator.mediaDevices.getUserMedia({ audio: true });
943
- return true;
944
- } catch (err) {
945
- console.error("Error accessing microphone:", err);
946
- toast(
947
- "Microphone access denied. Please enable microphone access in your browser settings.",
948
- "error"
949
- );
950
- return false;
951
- }
952
- }
953
-}
954
-
955
-export const store = createStore("speech", model);
956
-
957
-// Initialize speech store
958
-// window.speechStore = speechStore;
959
-
960
-// Event listeners
961
-document.addEventListener("settings-updated", () => store.loadSettings());
962
-// document.addEventListener("DOMContentLoaded", () => speechStore.init());