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