native models, log trunc fix, speech skip

moved whisper and kokoro to native runtime log kvps truncation fix skip speech on context switch

frdel committed Jul 14, 2025 at 14:30 UTC dcae2b74ac8e05e8ebb2f49ef2aa3a88dcb6cf6e
7 files changed +126 -58
preload.py
+5 -1
@@ -40,7 +40,11 @@ async def preload():
40 PrintStyle().error(f"Error in preload_kokoro: {e}")
41
42 # async tasks to preload
43 - tasks = [preload_whisper(), preload_embedding(), preload_kokoro()]
43 + tasks = [
44 + preload_embedding(),
45 + # preload_whisper(),
46 + # preload_kokoro()
47 + ] # no longer preload kokoro and whisper, do it JIT
48
49 await asyncio.gather(*tasks, return_exceptions=True)
50 PrintStyle().print("Preload completed")
python/api/synthesize.py
+1 -1
@@ -12,7 +12,7 @@ class Synthesize(ApiHandler):
12 ctxid = input.get("ctxid", "")
13
14 context = self.get_context(ctxid)
15 - if await kokoro_tts.is_downloading():
15 + if not await kokoro_tts.is_downloaded():
16 context.log.log(type="info", content="Kokoro TTS model is currently being downloaded, please wait...")
17
18 try:
python/api/transcribe.py
+2 -2
@@ -9,8 +9,8 @@ class Transcribe(ApiHandler):
9 ctxid = input.get("ctxid", "")
10
11 context = self.get_context(ctxid)
12 - if await whisper.is_downloading():
13 - context.log.log(type="info", content="Whisper model is currently being downloaded, please wait...")
12 + if not await whisper.is_downloaded():
13 + context.log.log(type="info", content="Whisper STT model is currently being downloaded, please wait...")
14
15 set = settings.get_settings()
16 result = await whisper.transcribe(set["stt_model_size"], audio) # type: ignore
python/helpers/kokoro_tts.py
+44 -22
@@ -15,52 +15,74 @@ _voice = "am_puck,am_onyx"
15 _speed = 1.1
16 is_updating_model = False
17
18 +
19 async def preload():
20 try:
20 - return await runtime.call_development_function(_preload)
21 + # return await runtime.call_development_function(_preload)
22 + return await _preload()
23 except Exception as e:
22 - if not runtime.is_development():
23 - raise e
24 + # if not runtime.is_development():
25 + raise e
26 # Fallback to direct execution if RFC fails in development
25 - PrintStyle.standard("RFC failed, falling back to direct execution...")
26 - return await _preload()
27 + # PrintStyle.standard("RFC failed, falling back to direct execution...")
28 + # return await _preload()
29 +
30
31 async def _preload():
32 global _pipeline, is_updating_model
30 -
33 +
34 while is_updating_model:
35 await asyncio.sleep(0.1)
33 -
36 +
37 try:
38 is_updating_model = True
39 if not _pipeline:
40 PrintStyle.standard("Loading Kokoro TTS model...")
41 from kokoro import KPipeline
39 - _pipeline = KPipeline(lang_code='a')
42 + _pipeline = KPipeline(lang_code="a")
43 finally:
44 is_updating_model = False
45
46 +
47 async def is_downloading():
48 try:
45 - return await runtime.call_development_function(_is_downloading)
49 + # return await runtime.call_development_function(_is_downloading)
50 + return _is_downloading()
51 except Exception as e:
47 - if not runtime.is_development():
48 - raise e
52 + # if not runtime.is_development():
53 + raise e
54 # Fallback to direct execution if RFC fails in development
50 - return _is_downloading()
55 + # return _is_downloading()
56 +
57
58 def _is_downloading():
59 return is_updating_model
60
61 +async def is_downloaded():
62 + try:
63 + # return await runtime.call_development_function(_is_downloaded)
64 + return _is_downloaded()
65 + except Exception as e:
66 + # if not runtime.is_development():
67 + raise e
68 + # Fallback to direct execution if RFC fails in development
69 + # return _is_downloaded()
70 +
71 +def _is_downloaded():
72 + return _pipeline is not None
73 +
74 +
75 async def synthesize_sentences(sentences: list[str]):
76 """Generate audio for multiple sentences and return concatenated base64 audio"""
77 try:
58 - return await runtime.call_development_function(_synthesize_sentences, sentences)
78 + # return await runtime.call_development_function(_synthesize_sentences, sentences)
79 + return await _synthesize_sentences(sentences)
80 except Exception as e:
60 - if not runtime.is_development():
61 - raise e
81 + # if not runtime.is_development():
82 + raise e
83 # Fallback to direct execution if RFC fails in development
63 - return await _synthesize_sentences(sentences)
84 + # return await _synthesize_sentences(sentences)
85 +
86
87 async def _synthesize_sentences(sentences: list[str]):
88 await _preload()
@@ -70,22 +92,22 @@ async def _synthesize_sentences(sentences: list[str]):
92 try:
93 for sentence in sentences:
94 if sentence.strip():
73 - segments = _pipeline(sentence.strip(), voice=_voice, speed=_speed)
95 + segments = _pipeline(sentence.strip(), voice=_voice, speed=_speed) # type: ignore
96 segment_list = list(segments)
75 -
97 +
98 for segment in segment_list:
99 audio_tensor = segment.audio
78 - audio_numpy = audio_tensor.detach().cpu().numpy()
100 + audio_numpy = audio_tensor.detach().cpu().numpy() # type: ignore
101 combined_audio.extend(audio_numpy)
102
103 # Convert combined audio to bytes
104 buffer = io.BytesIO()
83 - sf.write(buffer, combined_audio, 24000, format='WAV')
105 + sf.write(buffer, combined_audio, 24000, format="WAV")
106 audio_bytes = buffer.getvalue()
107
108 # Return base64 encoded audio
87 - return base64.b64encode(audio_bytes).decode('utf-8')
109 + return base64.b64encode(audio_bytes).decode("utf-8")
110
111 except Exception as e:
112 PrintStyle.error(f"Error in Kokoro TTS synthesis: {e}")
91 - raise
\ No newline at end of file
113 + raise
\ No newline at end of file
python/helpers/log.py
+31 -17
@@ -4,6 +4,7 @@ from typing import Any, Literal, Optional, Dict
4 import uuid
5 from collections import OrderedDict # Import OrderedDict
6 from python.helpers.strings import truncate_text_by_ratio
7 +import copy
8
9 Type = Literal[
10 "agent",
@@ -25,9 +26,9 @@ ProgressUpdate = Literal["persistent", "temporary", "none"]
26
27
28 HEADING_MAX_LEN: int = 120
28 -CONTENT_MAX_LEN: int = 4000
29 +CONTENT_MAX_LEN: int = 10000
30 KEY_MAX_LEN: int = 60
30 -VALUE_MAX_LEN: int = 1000
31 +VALUE_MAX_LEN: int = 3000
32 PROGRESS_MAX_LEN: int = 120
33
34
@@ -44,29 +45,36 @@ def _truncate_progress(text: str | None) -> str:
45 def _truncate_key(text: str) -> str:
46 return truncate_text_by_ratio(str(text), KEY_MAX_LEN, "...", ratio=1.0)
47
47 -def _truncate_value(text: Any) -> Any:
48 +def _truncate_value(val: Any) -> Any:
49 + # If dict, recursively truncate each value
50 + if isinstance(val, dict):
51 + for k in list(val.keys()):
52 + val[k] = _truncate_value(val[k])
53 + return val
54 + # If list or tuple, recursively truncate each item
55 + if isinstance(val, list):
56 + for i in range(len(val)):
57 + val[i] = _truncate_value(val[i])
58 + return val
59 + if isinstance(val, tuple):
60 + return tuple(_truncate_value(x) for x in val)
61 +
62 # Convert non-str values to json for consistent length measurement
49 - if isinstance(text, str):
50 - raw = text
63 + if isinstance(val, str):
64 + raw = val
65 else:
66 try:
53 - raw = json.dumps(text, ensure_ascii=False)
67 + raw = json.dumps(val, ensure_ascii=False)
68 except Exception:
55 - raw = str(text)
69 + raw = str(val)
70
71 if len(raw) <= VALUE_MAX_LEN:
58 - return text # No truncation needed, preserve original type
72 + return val # No truncation needed, preserve original type
73
60 - # Determine removed characters dynamically to build replacement string
74 + # Do a single truncation calculation
75 removed = len(raw) - VALUE_MAX_LEN
62 - while True:
63 - replacement = f"\n\n<< {removed} Characters hidden >>\n\n"
64 - truncated = truncate_text_by_ratio(raw, VALUE_MAX_LEN, replacement, ratio=0.3)
65 - new_removed = len(raw) - (len(truncated) - len(replacement))
66 - if new_removed == removed:
67 - break
68 - removed = new_removed
69 -
76 + replacement = f"\n\n<< {removed} Characters hidden >>\n\n"
77 + truncated = truncate_text_by_ratio(raw, VALUE_MAX_LEN, replacement, ratio=0.3)
78 return truncated
79
80 def _truncate_content(text: str | None) -> str:
@@ -177,10 +185,13 @@ class Log:
185
186 # Truncate kvps
187 if kvps is not None:
188 + kvps = copy.deepcopy(kvps) # deep copy to avoid modifying the original kvps
189 kvps = OrderedDict({
190 _truncate_key(k): _truncate_value(v) for k, v in kvps.items()
191 })
192 # Apply truncation to kwargs merged into kvps later
193 + if kwargs is not None:
194 + kwargs = copy.deepcopy(kwargs) # deep copy to avoid modifying the original kwargs
195 kwargs = { _truncate_key(k): _truncate_value(v) for k, v in (kwargs or {}).items() }
196
197 # Ensure kvps is OrderedDict even if None
@@ -231,6 +242,7 @@ class Log:
242 item.content = _truncate_content(content)
243
244 if kvps is not None:
245 + kvps = copy.deepcopy(kvps) # deep copy to avoid modifying the original kvps
246 item.kvps = OrderedDict({
247 _truncate_key(k): _truncate_value(v) for k, v in kvps.items()
248 }) # Ensure order
@@ -239,11 +251,13 @@ class Log:
251 item.temp = temp
252
253 if kwargs:
254 + kwargs = copy.deepcopy(kwargs) # deep copy to avoid modifying the original kwargs
255 if item.kvps is None:
256 item.kvps = OrderedDict() # Ensure kvps is an OrderedDict
257 for k, v in kwargs.items():
258 item.kvps[_truncate_key(k)] = _truncate_value(v)
259
260 +
261 self.updates += [item.no]
262 self._update_progress_from_item(item)
263
python/helpers/whisper.py
+23 -7
@@ -3,7 +3,7 @@ import warnings
3 import whisper
4 import tempfile
5 import asyncio
6 -from python.helpers import runtime, rfc, settings
6 +from python.helpers import runtime, rfc, settings, files
7 from python.helpers.print_style import PrintStyle
8
9 # Suppress FutureWarning from torch.load
@@ -15,10 +15,11 @@ is_updating_model = False # Tracks whether the model is currently updating
15
16 async def preload(model_name:str):
17 try:
18 - return await runtime.call_development_function(_preload, model_name)
18 + # return await runtime.call_development_function(_preload, model_name)
19 + return await _preload(model_name)
20 except Exception as e:
20 - if not runtime.is_development():
21 - raise e
21 + # if not runtime.is_development():
22 + raise e
23
24 async def _preload(model_name:str):
25 global _model, _model_name, is_updating_model
@@ -30,19 +31,34 @@ async def _preload(model_name:str):
31 is_updating_model = True
32 if not _model or _model_name != model_name:
33 PrintStyle.standard(f"Loading Whisper model: {model_name}")
33 - _model = whisper.load_model(name=model_name) # type: ignore
34 + _model = whisper.load_model(name=model_name, download_root=files.get_abs_path("/tmp/models/whisper")) # type: ignore
35 _model_name = model_name
36 finally:
37 is_updating_model = False
38
39 async def is_downloading():
39 - return await runtime.call_development_function(_is_downloading)
40 + # return await runtime.call_development_function(_is_downloading)
41 + return _is_downloading()
42
43 def _is_downloading():
44 return is_updating_model
45
46 +async def is_downloaded():
47 + try:
48 + # return await runtime.call_development_function(_is_downloaded)
49 + return _is_downloaded()
50 + except Exception as e:
51 + # if not runtime.is_development():
52 + raise e
53 + # Fallback to direct execution if RFC fails in development
54 + # return _is_downloaded()
55 +
56 +def _is_downloaded():
57 + return _model is not None
58 +
59 async def transcribe(model_name:str, audio_bytes_b64: str):
45 - return await runtime.call_development_function(_transcribe, model_name, audio_bytes_b64)
60 + # return await runtime.call_development_function(_transcribe, model_name, audio_bytes_b64)
61 + return await _transcribe(model_name, audio_bytes_b64)
62
63
64 async def _transcribe(model_name:str, audio_bytes_b64: str):
webui/index.js
+20 -8
@@ -24,6 +24,7 @@ const timeDate = document.getElementById("time-date-container");
24 let autoScroll = true;
25 let context = "";
26 let resetCounter = 0;
27 +let skipOneSpeech = false;
28 let connectionStatus = false;
29
30 // Initialize the toggle button
@@ -503,6 +504,10 @@ function afterMessagesUpdate(logs) {
504 }
505
506 function speakMessages(logs) {
507 + if (skipOneSpeech) {
508 + skipOneSpeech = false;
509 + return;
510 + }
511 // log.no, log.type, log.heading, log.content
512 for (let i = logs.length - 1; i >= 0; i--) {
513 const log = logs[i];
@@ -512,10 +517,14 @@ function speakMessages(logs) {
517
518 // finished response
519 if (log.type == "response") {
515 - // lastSpokenNo = log.no;
516 - speechStore.speakStream(getChatBasedId(log.no), log.content, log.kvps?.finished);
517 - return;
518 -
520 + // lastSpokenNo = log.no;
521 + speechStore.speakStream(
522 + getChatBasedId(log.no),
523 + log.content,
524 + log.kvps?.finished
525 + );
526 + return;
527 +
528 // finished LLM headline, not response
529 } else if (
530 log.type == "agent" &&
@@ -524,9 +533,9 @@ function speakMessages(logs) {
533 log.kvps.tool_args &&
534 log.kvps.tool_name != "response"
535 ) {
527 - // lastSpokenNo = log.no;
528 - speechStore.speakStream(getChatBasedId(log.no), log.kvps.headline, true);
529 - return;
536 + // lastSpokenNo = log.no;
537 + speechStore.speakStream(getChatBasedId(log.no), log.kvps.headline, true);
538 + return;
539 }
540 }
541 }
@@ -736,6 +745,9 @@ export const setContext = function (id) {
745 if (tasksAD) tasksAD.selected = id;
746 }
747 }
748 +
749 + //skip one speech if enabled when switching context
750 + if (localStorage.getItem("speech") == "true") skipOneSpeech = true;
751 };
752
753 export const getContext = function () {
@@ -743,7 +755,7 @@ export const getContext = function () {
755 };
756
757 export const getChatBasedId = function (id) {
746 - return context+"-"+resetCounter+"-"+id;
758 + return context + "-" + resetCounter + "-" + id;
759 };
760
761 window.toggleAutoScroll = async function (_autoScroll) {