microphone setting, ui polishing

frdel committed Jul 14, 2025 at 22:49 UTC 0b9e2aa186c4c726cb30be898aec2c940047456a
12 files changed +227 -24
python/api/synthesize.py
+2 -2
@@ -13,7 +13,7 @@ class Synthesize(ApiHandler):
13
14 context = self.get_context(ctxid)
15 if not await kokoro_tts.is_downloaded():
16 - context.log.log(type="info", content="Kokoro TTS model is currently being downloaded, please wait...")
16 + context.log.log(type="info", content="Kokoro TTS model is currently being initialized, please wait...")
17
18 try:
19 # # Clean and chunk text for long responses
@@ -32,7 +32,7 @@ class Synthesize(ApiHandler):
32 # audio_parts.append(chunk_audio)
33 # return {"audio_parts": audio_parts, "success": True}
34
35 -
35 + # audio is chunked on the frontend for better flow
36 audio = await kokoro_tts.synthesize_sentences([text])
37 return {"audio": audio, "success": True}
38 except Exception as e:
python/api/transcribe.py
+1 -1
@@ -10,7 +10,7 @@ class Transcribe(ApiHandler):
10
11 context = self.get_context(ctxid)
12 if not await whisper.is_downloaded():
13 - context.log.log(type="info", content="Whisper STT model is currently being downloaded, please wait...")
13 + context.log.log(type="info", content="Whisper STT model is currently being initialized, please wait...")
14
15 set = settings.get_settings()
16 result = await whisper.transcribe(set["stt_model_size"], audio) # type: ignore
python/helpers/settings.py
+25 -15
@@ -71,7 +71,7 @@ class Settings(TypedDict):
71 stt_silence_duration: int
72 stt_waiting_timeout: int
73
74 - tts_enabled: bool
74 + tts_kokoro: bool
75
76 mcp_servers: str
77 mcp_client_init_timeout: int
@@ -94,7 +94,7 @@ class SettingsField(TypedDict, total=False):
94 title: str
95 description: str
96 type: Literal[
97 - "text", "number", "select", "range", "textarea", "password", "switch", "button"
97 + "text", "number", "select", "range", "textarea", "password", "switch", "button", "html"
98 ]
99 value: Any
100 min: float
@@ -632,11 +632,21 @@ def convert_out(settings: Settings) -> SettingsOutput:
632 # Speech to text section
633 stt_fields: list[SettingsField] = []
634
635 + stt_fields.append(
636 + {
637 + "id": "stt_microphone_section",
638 + "title": "Microphone device",
639 + "description": "Select the microphone device to use for speech-to-text.",
640 + "value": "<x-component path='/settings/speech/microphone.html' />",
641 + "type": "html",
642 + }
643 + )
644 +
645 stt_fields.append(
646 {
647 "id": "stt_model_size",
638 - "title": "Model Size",
639 - "description": "Select the speech recognition model size",
648 + "title": "Speech-to-text model size",
649 + "description": "Select the speech-to-text model size",
650 "type": "select",
651 "value": settings["stt_model_size"],
652 "options": [
@@ -653,7 +663,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
663 stt_fields.append(
664 {
665 "id": "stt_language",
656 - "title": "Language Code",
666 + "title": "Speech-to-text language code",
667 "description": "Language code (e.g. en, fr, it)",
668 "type": "text",
669 "value": settings["stt_language"],
@@ -663,8 +673,8 @@ def convert_out(settings: Settings) -> SettingsOutput:
673 stt_fields.append(
674 {
675 "id": "stt_silence_threshold",
666 - "title": "Silence threshold",
667 - "description": "Silence detection threshold. Lower values are more sensitive.",
676 + "title": "Microphone silence threshold",
677 + "description": "Silence detection threshold. Lower values are more sensitive to noise.",
678 "type": "range",
679 "min": 0,
680 "max": 1,
@@ -676,8 +686,8 @@ def convert_out(settings: Settings) -> SettingsOutput:
686 stt_fields.append(
687 {
688 "id": "stt_silence_duration",
679 - "title": "Silence duration (ms)",
680 - "description": "Duration of silence before the server considers speaking to have ended.",
689 + "title": "Microphone silence duration (ms)",
690 + "description": "Duration of silence before the system considers speaking to have ended.",
691 "type": "text",
692 "value": settings["stt_silence_duration"],
693 }
@@ -686,8 +696,8 @@ def convert_out(settings: Settings) -> SettingsOutput:
696 stt_fields.append(
697 {
698 "id": "stt_waiting_timeout",
689 - "title": "Waiting timeout (ms)",
690 - "description": "Duration before the server closes the microphone.",
699 + "title": "Microphone waiting timeout (ms)",
700 + "description": "Duration of silence before the system closes the microphone.",
701 "type": "text",
702 "value": settings["stt_waiting_timeout"],
703 }
@@ -698,11 +708,11 @@ def convert_out(settings: Settings) -> SettingsOutput:
708
709 tts_fields.append(
710 {
701 - "id": "tts_enabled",
711 + "id": "tts_kokoro",
712 "title": "Enable Kokoro TTS",
703 - "description": "Enable server-side AI text-to-speech (Kokoro)",
713 + "description": "Enable higher quality server-side AI (Kokoro) instead of browser-based text-to-speech.",
714 "type": "switch",
705 - "value": settings["tts_enabled"],
715 + "value": settings["tts_kokoro"],
716 }
717 )
718
@@ -1031,7 +1041,7 @@ def get_default_settings() -> Settings:
1041 stt_silence_threshold=0.3,
1042 stt_silence_duration=1000,
1043 stt_waiting_timeout=2000,
1034 - tts_enabled=False,
1044 + tts_kokoro=True,
1045 mcp_servers='{\n "mcpServers": {}\n}',
1046 mcp_client_init_timeout=10,
1047 mcp_client_tool_timeout=120,
webui/components/_examples/_example-component.html new
+36
@@ -0,0 +1,36 @@
1 +<html>
2 +
3 +<head>
4 + <title>Example component or modal</title>
5 +
6 + <!-- Import the alpine store -->
7 + <script type="module">
8 + import { store } from "/components/_examples/_example-store.js";
9 + </script>
10 +</head>
11 +
12 +<body>
13 +
14 + <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
15 + <div x-data>
16 + <template x-if="$store.exampleStore">
17 +
18 + <!-- Keep in mind that <template> can have only one root element inside -->
19 + <div>
20 + <p x-text="$store.exampleStore.example1"></p>
21 + <p x-text="$store.exampleStore.example2"></p>
22 + </div>
23 +
24 + </template>
25 + </div>
26 +
27 + <!-- Optional style for the component -->
28 + <style>
29 + #example-component {
30 + width: 100%;
31 + }
32 + </style>
33 +
34 +</body>
35 +
36 +</html>
\ No newline at end of file
webui/components/_examples/_example-store.js new
+19
@@ -0,0 +1,19 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +// define the model object holding data and functions
4 +const model = {
5 + example1:"Example 1",
6 + example2:"Example 2",
7 +
8 + // gets called when the store is created
9 + init(){
10 + console.log("Example store initialized");
11 + }
12 +
13 +};
14 +
15 +// convert it to alpine store
16 +const store = createStore("_exampleStore", model);
17 +
18 +// export for use in other files
19 +export { store };
webui/components/chat/attachments/attachmentsStore.js
+1 -1
@@ -220,7 +220,7 @@ const model = {
220 Array.from(files).forEach((file) => {
221 console.log("Processing file:", file.name, file.type);
222 const ext = file.name.split(".").pop().toLowerCase();
223 - const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp"].includes(
223 + const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes(
224 ext
225 );
226
webui/components/chat/speech/speech-store.js
+18 -2
@@ -1,6 +1,7 @@
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
6 const Status = {
7 INACTIVE: "inactive",
@@ -21,7 +22,7 @@ const model = {
22 stt_waiting_timeout: 2000,
23
24 // TTS Settings
24 - tts_enabled: false,
25 + tts_kokoro: false,
26
27 // TTS State
28 isSpeaking: false,
@@ -36,6 +37,7 @@ const model = {
37 // STT State
38 microphoneInput: null,
39 isProcessingClick: false,
40 + selectedDevice: null,
41
42 // Getter for micStatus - delegates to microphoneInput
43 get micStatus() {
@@ -62,6 +64,15 @@ const model = {
64 if (this.isProcessingClick) return;
65 this.isProcessingClick = true;
66 try {
67 +
68 + // reset mic input if device has changed in settings
69 + const device = microphoneSettingStore.getSelectedDevice();
70 + if(device!=this.selectedDevice){
71 + this.selectedDevice = device;
72 + this.microphoneInput = null;
73 + console.log("Device changed, microphoneInput reset");
74 + }
75 +
76 if (!this.microphoneInput) {
77 await this.initMicrophone();
78 }
@@ -220,7 +231,7 @@ const model = {
231 // speak wrapper
232 async _speak(text, waitForPrevious, terminator) {
233 // default browser speech
223 - if (!this.tts_enabled)
234 + if (!this.tts_kokoro)
235 return await this.speakWithBrowser(text, waitForPrevious, terminator);
236
237 // kokoro tts
@@ -347,6 +358,7 @@ const model = {
358 this.browserUtterance.onend = () => {
359 this.isSpeaking = false;
360 };
361 +
362 this.synth.speak(this.browserUtterance);
363 },
364
@@ -587,8 +599,12 @@ class MicrophoneInput {
599 // Set status to activating at the start of initialization
600 this.status = Status.ACTIVATING;
601 try {
602 + // get selected device from microphone settings
603 + const selectedDevice = microphoneSettingStore.getSelectedDevice();
604 +
605 const stream = await navigator.mediaDevices.getUserMedia({
606 audio: {
607 + deviceId: selectedDevice && selectedDevice.deviceId ? { exact: selectedDevice.deviceId } : undefined,
608 echoCancellation: true,
609 noiseSuppression: true,
610 channelCount: 1,
webui/components/settings/speech/microphone-setting-store.js new
+53
@@ -0,0 +1,53 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +
3 +const model = {
4 +
5 +
6 + devices: [],
7 + selectedDevice: "",
8 +
9 + async init() {
10 + // Load selected device from localStorage if present
11 + const saved = localStorage.getItem('microphoneSelectedDevice');
12 + await this.loadDevices();
13 + if (saved && this.devices.some(d => d.deviceId === saved)) {
14 + this.selectedDevice = saved;
15 + }
16 + },
17 +
18 + async loadDevices() {
19 + // Get media devices
20 + const devices = await navigator.mediaDevices.enumerateDevices();
21 + // Filter for audio input (microphones)
22 + this.devices = devices.filter(d => d.kind === "audioinput" && d.deviceId);
23 + // Set selected device to first available, if any
24 + this.selectedDevice = this.devices.length > 0 ? this.devices[0].deviceId : "";
25 + },
26 +
27 + requestPermission() {
28 + navigator.mediaDevices.getUserMedia({ audio: true });
29 + this.loadDevices();
30 + },
31 +
32 + async selectDevice(deviceId) {
33 + this.selectedDevice = deviceId;
34 + this.onSelectDevice();
35 + },
36 +
37 + async onSelectDevice() {
38 + localStorage.setItem('microphoneSelectedDevice', this.selectedDevice);
39 + },
40 +
41 + getSelectedDevice() {
42 + let device = this.devices.find(d => d.deviceId === this.selectedDevice);
43 + if (!device && this.devices.length > 0) {
44 + device = this.devices.find(d => d.deviceId === "default") || this.devices[0];
45 + }
46 + return device;
47 + }
48 +
49 +};
50 +
51 +const store = createStore("microphoneSetting", model);
52 +
53 +export { store };
webui/components/settings/speech/microphone.html new
+41
@@ -0,0 +1,41 @@
1 +<html>
2 +
3 +<head>
4 + <title>Microhone settings</title>
5 +
6 + <!-- Import the alpine store -->
7 + <script type="module">
8 + import { store } from "/components/settings/speech/microphone-setting-store.js";
9 + console.log("microphone-setting-store.js loaded");
10 + </script>
11 +</head>
12 +
13 +<body>
14 +
15 + <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
16 + <div x-data>
17 + <template x-if="$store.microphoneSetting">
18 +
19 + <div>
20 + <select x-model="$store.microphoneSetting.selectedDevice"
21 + @change="$store.microphoneSetting.onSelectDevice()"
22 + x-show="$store.microphoneSetting.devices.length > 0">
23 + <template x-for="option in $store.microphoneSetting.devices" :key="option.deviceId">
24 + <option :value="option.deviceId" x-text="option.label"
25 + :selected="option.deviceId === $store.microphoneSetting.selectedDevice"></option>
26 + </template>
27 + </select>
28 + <button class="btn btn-field" x-show="$store.microphoneSetting.devices.length == 0"
29 + @click="$store.microphoneSetting.requestPermission()">Request permission</button>
30 + </div>
31 +
32 + </template>
33 + </div>
34 +
35 + <!-- Optional style for the component -->
36 + <style>
37 + </style>
38 +
39 +</body>
40 +
41 +</html>
\ No newline at end of file
webui/index.css
+5
@@ -938,6 +938,7 @@ pre {
938 border-radius: 8px;
939 max-width: 600px; /* Limits to ~5 columns at 120px each */
940 overflow: visible;
941 + justify-items: end;
942 }
943
944 .attachment-item {
@@ -981,6 +982,8 @@ pre {
982 height: 100%;
983 object-fit: cover;
984 border-radius: 10px;
985 + background: repeating-linear-gradient(45deg, #fff 0 10px, #e0e0e0 10px 20px);
986 +
987 }
988
989 /* File attachment styling */
@@ -1017,6 +1020,8 @@ pre {
1020 height: 100%;
1021 object-fit: cover;
1022 border-radius: 10px;
1023 + background: repeating-linear-gradient(45deg, #fff 0 10px, #e0e0e0 10px 20px);
1024 +
1025 }
1026
1027 .attachment-image .attachment-preview {
webui/index.html
+8 -3
@@ -586,9 +586,9 @@
586
587 <template x-for="(field, fieldIndex) in section.fields.filter(f => !f.hidden)" :key="fieldIndex">
588 <div :class="{'field': true, 'field-full': field.type === 'textarea'}">
589 - <div class="field-label">
590 - <div class="field-title" x-text="field.title"></div>
591 - <div class="field-description" x-html="field.description || ''"></div>
589 + <div class="field-label" x-show="field.title || field.description">
590 + <div class="field-title" x-text="field.title" x-show="field.title"></div>
591 + <div class="field-description" x-html="field.description || ''" x-show="field.description"></div>
592 </div>
593
594 <div class="field-control">
@@ -661,6 +661,11 @@
661 </template>
662 </select>
663 </template>
664 +
665 + <!-- HTML field -->
666 + <template x-if="field.type === 'html'">
667 + <div :class="field.classes" x-html="field.value"></div>
668 + </template>
669 </div>
670 </div>
671 </template>
webui/js/components.js
+18
@@ -5,7 +5,22 @@
5 // cache object to store loaded components
6 const componentCache = {};
7
8 +// Lock map to prevent multiple simultaneous imports of the same component
9 +const importLocks = new Map();
10 +
11 export async function importComponent(path, targetElement) {
12 + // Create a unique key for this import based on the target element
13 + const lockKey = targetElement.id || targetElement.getAttribute('data-component-id') || targetElement;
14 +
15 + // If this component is already being loaded, return early
16 + if (importLocks.get(lockKey)) {
17 + console.log(`Component ${path} is already being loaded for target`, targetElement);
18 + return;
19 + }
20 +
21 + // Set the lock
22 + importLocks.set(lockKey, true);
23 +
24 try {
25 if (!targetElement) {
26 throw new Error("Target element is required");
@@ -163,6 +178,9 @@ export async function importComponent(path, targetElement) {
178 } catch (error) {
179 console.error("Error importing component:", error);
180 throw error;
181 + } finally {
182 + // Release the lock when done, regardless of success or failure
183 + importLocks.delete(lockKey);
184 }
185 }
186