npcsh 1.1.21__py3-none-any.whl → 1.1.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- npcsh/_state.py +10 -5
- npcsh/benchmark/npcsh_agent.py +22 -14
- npcsh/benchmark/templates/install-npcsh.sh.j2 +2 -2
- npcsh/mcp_server.py +9 -1
- npcsh/npc_team/alicanto.npc +12 -6
- npcsh/npc_team/corca.npc +0 -1
- npcsh/npc_team/frederic.npc +2 -3
- npcsh/npc_team/jinxs/lib/core/edit_file.jinx +83 -61
- npcsh/npc_team/jinxs/modes/alicanto.jinx +102 -41
- npcsh/npc_team/jinxs/modes/build.jinx +378 -0
- npcsh/npc_team/jinxs/modes/convene.jinx +597 -0
- npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
- npcsh/npc_team/jinxs/modes/kg.jinx +69 -2
- npcsh/npc_team/jinxs/modes/plonk.jinx +16 -7
- npcsh/npc_team/jinxs/modes/yap.jinx +628 -187
- npcsh/npc_team/kadiefa.npc +2 -1
- npcsh/npc_team/sibiji.npc +3 -3
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.jinx +102 -41
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.npc +12 -6
- npcsh-1.1.22.data/data/npcsh/npc_team/build.jinx +378 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/corca.jinx +820 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.npc +0 -1
- npcsh-1.1.22.data/data/npcsh/npc_team/edit_file.jinx +119 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic.npc +2 -3
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.npc +2 -1
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kg.jinx +69 -2
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.jinx +16 -7
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.npc +3 -3
- npcsh-1.1.22.data/data/npcsh/npc_team/yap.jinx +716 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.22.dist-info}/METADATA +246 -281
- {npcsh-1.1.21.dist-info → npcsh-1.1.22.dist-info}/RECORD +127 -130
- npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +0 -429
- npcsh/npc_team/jinxs/lib/core/search.jinx +0 -54
- npcsh/npc_team/jinxs/lib/utils/build.jinx +0 -65
- npcsh-1.1.21.data/data/npcsh/npc_team/build.jinx +0 -65
- npcsh-1.1.21.data/data/npcsh/npc_team/corca.jinx +0 -430
- npcsh-1.1.21.data/data/npcsh/npc_team/edit_file.jinx +0 -97
- npcsh-1.1.21.data/data/npcsh/npc_team/kg_search.jinx +0 -429
- npcsh-1.1.21.data/data/npcsh/npc_team/search.jinx +0 -54
- npcsh-1.1.21.data/data/npcsh/npc_team/yap.jinx +0 -275
- /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
- /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/chat.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/click.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/cmd.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/compile.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/compress.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/config_tui.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/confirm.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/convene.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca_example.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/db_search.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/delegate.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/file_search.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic4.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/git.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.npc +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/help.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/incognide.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/init.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/jinxs.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/key_press.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/load_file.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/memories.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/models.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/navigate.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/notify.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/nql.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/ots.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/papers.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/paste.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.npc +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonkjr.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/pti.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/python.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/reattach.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/roll.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sample.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/send_message.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/serve.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/set.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/setup.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sh.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/shh.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sleep.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sql.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switches.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sync.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/team.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/trigger.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/type_text.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/usage.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/verbose.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/wait.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/wander.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/web_search.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/write_file.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/yap.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.22.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.22.dist-info}/WHEEL +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.22.dist-info}/entry_points.txt +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.22.dist-info}/licenses/LICENSE +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.22.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
jinx_name: yap
|
|
2
|
-
description: Voice chat
|
|
2
|
+
description: Voice chat TUI - speech-to-text input, text-to-speech output
|
|
3
|
+
interactive: true
|
|
3
4
|
inputs:
|
|
4
5
|
- model: null
|
|
5
6
|
- provider: null
|
|
@@ -8,18 +9,14 @@ inputs:
|
|
|
8
9
|
- files: null
|
|
9
10
|
|
|
10
11
|
steps:
|
|
11
|
-
- name:
|
|
12
|
+
- name: yap_tui
|
|
12
13
|
engine: python
|
|
13
14
|
code: |
|
|
14
|
-
import os
|
|
15
|
-
import
|
|
16
|
-
import time
|
|
17
|
-
import tempfile
|
|
18
|
-
import threading
|
|
19
|
-
import queue
|
|
15
|
+
import os, sys, tty, termios, time, tempfile, threading, queue
|
|
16
|
+
import select as _sel
|
|
20
17
|
from termcolor import colored
|
|
21
18
|
|
|
22
|
-
# Audio imports
|
|
19
|
+
# Audio imports
|
|
23
20
|
try:
|
|
24
21
|
import torch
|
|
25
22
|
import pyaudio
|
|
@@ -32,10 +29,8 @@ steps:
|
|
|
32
29
|
transcribe_recording, convert_mp3_to_wav
|
|
33
30
|
)
|
|
34
31
|
AUDIO_AVAILABLE = True
|
|
35
|
-
except ImportError
|
|
32
|
+
except ImportError:
|
|
36
33
|
AUDIO_AVAILABLE = False
|
|
37
|
-
print(colored(f"Audio dependencies not available: {e}", "yellow"))
|
|
38
|
-
print("Install with: pip install npcsh[audio]")
|
|
39
34
|
|
|
40
35
|
from npcpy.llm_funcs import get_llm_response
|
|
41
36
|
from npcpy.npc_sysenv import get_system_message, render_markdown
|
|
@@ -46,10 +41,9 @@ steps:
|
|
|
46
41
|
team = context.get('team')
|
|
47
42
|
messages = context.get('messages', [])
|
|
48
43
|
files = context.get('files')
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
tts_model_name = context.get('tts_model', 'kokoro')
|
|
45
|
+
voice_name = context.get('voice', 'af_heart')
|
|
51
46
|
|
|
52
|
-
# Resolve npc if it's a string (npc name) rather than NPC object
|
|
53
47
|
if isinstance(npc, str) and team:
|
|
54
48
|
npc = team.get(npc) if hasattr(team, 'get') else None
|
|
55
49
|
elif isinstance(npc, str):
|
|
@@ -57,53 +51,24 @@ steps:
|
|
|
57
51
|
|
|
58
52
|
model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
|
|
59
53
|
provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
|
|
60
|
-
|
|
61
|
-
print("""
|
|
62
|
-
██╗ ██╗ █████╗ ██████╗
|
|
63
|
-
╚██╗ ██╔╝██╔══██╗██╔══██╗
|
|
64
|
-
╚████╔╝ ███████║██████╔╝
|
|
65
|
-
╚██╔╝ ██╔══██║██╔═══╝
|
|
66
|
-
██║ ██║ ██║██║
|
|
67
|
-
╚═╝ ╚═╝ ╚═╝╚═╝
|
|
68
|
-
|
|
69
|
-
Voice Chat Mode
|
|
70
|
-
""")
|
|
71
|
-
|
|
72
54
|
npc_name = npc.name if npc else "yap"
|
|
73
|
-
print(f"Entering yap mode (NPC: {npc_name}). Type '/yq' to exit.")
|
|
74
|
-
|
|
75
|
-
if not AUDIO_AVAILABLE:
|
|
76
|
-
print(colored("Audio not available. Falling back to text mode.", "yellow"))
|
|
77
|
-
|
|
78
|
-
# Load files for RAG context
|
|
79
|
-
loaded_chunks = {}
|
|
80
|
-
if files:
|
|
81
|
-
if isinstance(files, str):
|
|
82
|
-
files = [f.strip() for f in files.split(',')]
|
|
83
|
-
for file_path in files:
|
|
84
|
-
file_path = os.path.expanduser(file_path)
|
|
85
|
-
if os.path.exists(file_path):
|
|
86
|
-
try:
|
|
87
|
-
chunks = load_file_contents(file_path)
|
|
88
|
-
loaded_chunks[file_path] = chunks
|
|
89
|
-
print(colored(f"Loaded: {file_path}", "green"))
|
|
90
|
-
except Exception as e:
|
|
91
|
-
print(colored(f"Error loading {file_path}: {e}", "red"))
|
|
92
|
-
|
|
93
|
-
# System message for concise voice responses
|
|
94
|
-
sys_msg = get_system_message(npc) if npc else "You are a helpful assistant."
|
|
95
|
-
sys_msg += "\n\nProvide brief responses of 1-2 sentences unless asked for more detail. Keep responses clear and conversational for voice."
|
|
96
|
-
|
|
97
|
-
if not messages or messages[0].get("role") != "system":
|
|
98
|
-
messages.insert(0, {"role": "system", "content": sys_msg})
|
|
99
55
|
|
|
100
|
-
#
|
|
56
|
+
# ================================================================
|
|
57
|
+
# Non-interactive fallback
|
|
58
|
+
# ================================================================
|
|
59
|
+
if not sys.stdin.isatty():
|
|
60
|
+
context['output'] = "Yap requires an interactive terminal."
|
|
61
|
+
context['messages'] = messages
|
|
62
|
+
exit()
|
|
63
|
+
|
|
64
|
+
# ================================================================
|
|
65
|
+
# Audio models
|
|
66
|
+
# ================================================================
|
|
101
67
|
vad_model = None
|
|
102
68
|
whisper_model = None
|
|
103
69
|
|
|
104
70
|
if AUDIO_AVAILABLE:
|
|
105
71
|
try:
|
|
106
|
-
# Load VAD model for voice activity detection
|
|
107
72
|
vad_model, _ = torch.hub.load(
|
|
108
73
|
repo_or_dir="snakers4/silero-vad",
|
|
109
74
|
model="silero_vad",
|
|
@@ -112,164 +77,640 @@ steps:
|
|
|
112
77
|
verbose=False
|
|
113
78
|
)
|
|
114
79
|
vad_model.to('cpu')
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
try:
|
|
118
83
|
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
|
|
119
|
-
|
|
120
|
-
except Exception as e:
|
|
121
|
-
print(colored(f"Error loading audio models: {e}", "red"))
|
|
84
|
+
except Exception:
|
|
122
85
|
AUDIO_AVAILABLE = False
|
|
123
86
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
87
|
+
# ================================================================
|
|
88
|
+
# File loading for RAG
|
|
89
|
+
# ================================================================
|
|
90
|
+
loaded_chunks = {}
|
|
91
|
+
if files:
|
|
92
|
+
if isinstance(files, str):
|
|
93
|
+
files = [f.strip() for f in files.split(',')]
|
|
94
|
+
for fp in files:
|
|
95
|
+
fp = os.path.expanduser(fp)
|
|
96
|
+
if os.path.exists(fp):
|
|
97
|
+
try:
|
|
98
|
+
loaded_chunks[fp] = load_file_contents(fp)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# System message
|
|
103
|
+
sys_msg = get_system_message(npc) if npc else "You are a helpful assistant."
|
|
104
|
+
sys_msg += "\n\nProvide brief responses of 1-2 sentences unless asked for more detail. Keep responses clear and conversational for voice."
|
|
105
|
+
if not messages or messages[0].get("role") != "system":
|
|
106
|
+
messages.insert(0, {"role": "system", "content": sys_msg})
|
|
128
107
|
|
|
108
|
+
# ================================================================
|
|
109
|
+
# State
|
|
110
|
+
# ================================================================
|
|
111
|
+
class UI:
|
|
112
|
+
tab = 0 # 0=chat, 1=settings
|
|
113
|
+
TAB_NAMES = ['Chat', 'Settings']
|
|
114
|
+
|
|
115
|
+
# chat
|
|
116
|
+
chat_log = [] # [(role, text)]
|
|
117
|
+
chat_scroll = -1
|
|
118
|
+
input_buf = ""
|
|
119
|
+
thinking = False
|
|
120
|
+
spinner_frame = 0
|
|
121
|
+
recording = False
|
|
122
|
+
rec_seconds = 0.0
|
|
123
|
+
transcribing = False
|
|
124
|
+
speaking = False
|
|
125
|
+
|
|
126
|
+
# VAD listening
|
|
127
|
+
listening = AUDIO_AVAILABLE # auto-listen by default
|
|
128
|
+
listen_stop = False # signal to stop listener thread
|
|
129
|
+
|
|
130
|
+
# settings
|
|
131
|
+
set_sel = 0
|
|
132
|
+
tts_enabled = AUDIO_AVAILABLE
|
|
133
|
+
auto_speak = True
|
|
134
|
+
vad_threshold = 0.4 # speech probability threshold
|
|
135
|
+
silence_timeout = 1.5 # seconds of silence before cut
|
|
136
|
+
min_speech = 0.3 # minimum speech duration to process
|
|
137
|
+
editing = False
|
|
138
|
+
edit_buf = ""
|
|
139
|
+
edit_key = ""
|
|
140
|
+
|
|
141
|
+
ui = UI()
|
|
142
|
+
|
|
143
|
+
# ================================================================
|
|
144
|
+
# Helpers
|
|
145
|
+
# ================================================================
|
|
146
|
+
def sz():
|
|
129
147
|
try:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
s = os.get_terminal_size()
|
|
149
|
+
return s.columns, s.lines
|
|
150
|
+
except:
|
|
151
|
+
return 80, 24
|
|
152
|
+
|
|
153
|
+
TURQ = '\033[38;2;64;224;208m'
|
|
154
|
+
PURPLE = '\033[38;2;180;130;255m'
|
|
155
|
+
ORANGE = '\033[38;2;255;165;0m'
|
|
156
|
+
GREEN = '\033[32m'
|
|
157
|
+
DIM = '\033[90m'
|
|
158
|
+
BOLD = '\033[1m'
|
|
159
|
+
REV = '\033[7m'
|
|
160
|
+
RST = '\033[0m'
|
|
161
|
+
RED = '\033[31m'
|
|
162
|
+
SPINNERS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
163
|
+
|
|
164
|
+
def wrap_text(text, width):
|
|
165
|
+
lines = []
|
|
166
|
+
for line in text.split('\n'):
|
|
167
|
+
while len(line) > width:
|
|
168
|
+
lines.append(line[:width])
|
|
169
|
+
line = line[width:]
|
|
170
|
+
lines.append(line)
|
|
171
|
+
return lines
|
|
172
|
+
|
|
173
|
+
# ================================================================
|
|
174
|
+
# Audio functions
|
|
175
|
+
# ================================================================
|
|
176
|
+
def transcribe_audio(audio_path):
|
|
177
|
+
if not whisper_model or not audio_path:
|
|
178
|
+
return ""
|
|
179
|
+
try:
|
|
180
|
+
segments, _ = whisper_model.transcribe(audio_path, beam_size=5)
|
|
181
|
+
text = " ".join([seg.text for seg in segments]).strip()
|
|
182
|
+
try: os.remove(audio_path)
|
|
183
|
+
except: pass
|
|
184
|
+
return text
|
|
185
|
+
except Exception as e:
|
|
186
|
+
ui.chat_log.append(('error', f'Transcribe error: {e}'))
|
|
187
|
+
return ""
|
|
135
188
|
|
|
136
|
-
|
|
189
|
+
def speak_text(text):
|
|
190
|
+
if not AUDIO_AVAILABLE or not ui.tts_enabled:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
ui.speaking = True
|
|
194
|
+
tts = gTTS(text=text, lang='en')
|
|
195
|
+
mp3_f = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False)
|
|
196
|
+
mp3_path = mp3_f.name
|
|
197
|
+
mp3_f.close()
|
|
198
|
+
tts.save(mp3_path)
|
|
199
|
+
wav_path = mp3_path.replace('.mp3', '.wav')
|
|
200
|
+
convert_mp3_to_wav(mp3_path, wav_path)
|
|
137
201
|
import subprocess
|
|
138
202
|
if sys.platform == 'darwin':
|
|
139
|
-
subprocess.run(['afplay', wav_path], check=True)
|
|
203
|
+
subprocess.run(['afplay', wav_path], check=True, timeout=30)
|
|
140
204
|
elif sys.platform == 'linux':
|
|
141
|
-
subprocess.run(['aplay', wav_path], check=True
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for _p in [f.name, wav_path]:
|
|
148
|
-
try:
|
|
149
|
-
os.remove(_p)
|
|
150
|
-
except:
|
|
151
|
-
pass
|
|
205
|
+
subprocess.run(['aplay', wav_path], check=True, timeout=30,
|
|
206
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
207
|
+
for _p in [mp3_path, wav_path]:
|
|
208
|
+
try: os.remove(_p)
|
|
209
|
+
except: pass
|
|
152
210
|
except Exception as e:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
211
|
+
ui.chat_log.append(('error', f'TTS error: {e}'))
|
|
212
|
+
finally:
|
|
213
|
+
ui.speaking = False
|
|
214
|
+
|
|
215
|
+
def save_frames_to_wav(frames, sample_width):
|
|
216
|
+
f = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
|
|
217
|
+
path = f.name
|
|
218
|
+
f.close()
|
|
219
|
+
wf = wave.open(path, 'wb')
|
|
220
|
+
wf.setnchannels(CHANNELS)
|
|
221
|
+
wf.setsampwidth(sample_width)
|
|
222
|
+
wf.setframerate(RATE)
|
|
223
|
+
wf.writeframes(b''.join(frames))
|
|
224
|
+
wf.close()
|
|
225
|
+
return path
|
|
226
|
+
|
|
227
|
+
# ================================================================
|
|
228
|
+
# VAD continuous listener
|
|
229
|
+
# ================================================================
|
|
230
|
+
def vad_listener_loop():
|
|
231
|
+
"""Background thread: continuously monitors mic, detects speech via
|
|
232
|
+
VAD, records until silence, then transcribes and sends."""
|
|
160
233
|
try:
|
|
161
234
|
p = pyaudio.PyAudio()
|
|
162
|
-
|
|
235
|
+
sw = p.get_sample_size(FORMAT)
|
|
236
|
+
stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE,
|
|
237
|
+
input=True, frames_per_buffer=CHUNK)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
ui.chat_log.append(('error', f'Mic open failed: {e}'))
|
|
240
|
+
ui.listening = False
|
|
241
|
+
return
|
|
163
242
|
|
|
164
|
-
|
|
165
|
-
frames = []
|
|
166
|
-
for _ in range(0, int(RATE / CHUNK * duration)):
|
|
167
|
-
data = stream.read(CHUNK)
|
|
168
|
-
frames.append(data)
|
|
169
|
-
print(colored(" Done.", "cyan"))
|
|
243
|
+
chunk_dur = CHUNK / RATE # duration of one chunk in seconds
|
|
170
244
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
245
|
+
while not ui.listen_stop:
|
|
246
|
+
# Skip if busy
|
|
247
|
+
if ui.thinking or ui.speaking or ui.transcribing:
|
|
248
|
+
time.sleep(0.1)
|
|
249
|
+
continue
|
|
250
|
+
if not ui.listening:
|
|
251
|
+
time.sleep(0.1)
|
|
252
|
+
continue
|
|
174
253
|
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
wf.writeframes(b''.join(frames))
|
|
182
|
-
wf.close()
|
|
183
|
-
return f.name
|
|
184
|
-
except Exception as e:
|
|
185
|
-
print(colored(f"Recording error: {e}", "red"))
|
|
186
|
-
return None
|
|
254
|
+
# Read a chunk and run VAD
|
|
255
|
+
try:
|
|
256
|
+
data = stream.read(CHUNK, exception_on_overflow=False)
|
|
257
|
+
except Exception:
|
|
258
|
+
time.sleep(0.05)
|
|
259
|
+
continue
|
|
187
260
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return ""
|
|
261
|
+
audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
|
262
|
+
if len(audio_np) != CHUNK:
|
|
263
|
+
continue
|
|
192
264
|
|
|
193
|
-
try:
|
|
194
|
-
segments, _ = whisper_model.transcribe(audio_path, beam_size=5)
|
|
195
|
-
text = " ".join([seg.text for seg in segments])
|
|
196
265
|
try:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
except Exception as e:
|
|
202
|
-
print(colored(f"Transcription error: {e}", "red"))
|
|
203
|
-
return ""
|
|
266
|
+
tensor = torch.from_numpy(audio_np)
|
|
267
|
+
prob = vad_model(tensor, RATE).item()
|
|
268
|
+
except Exception:
|
|
269
|
+
continue
|
|
204
270
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
271
|
+
if prob < ui.vad_threshold:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# Speech detected — start collecting frames
|
|
275
|
+
ui.recording = True
|
|
276
|
+
ui.rec_seconds = 0.0
|
|
277
|
+
ui.chat_scroll = -1
|
|
278
|
+
speech_frames = [data]
|
|
279
|
+
speech_dur = chunk_dur
|
|
280
|
+
silence_dur = 0.0
|
|
281
|
+
|
|
282
|
+
while not ui.listen_stop:
|
|
283
|
+
try:
|
|
284
|
+
data = stream.read(CHUNK, exception_on_overflow=False)
|
|
285
|
+
except Exception:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
speech_frames.append(data)
|
|
289
|
+
speech_dur += chunk_dur
|
|
290
|
+
ui.rec_seconds = speech_dur
|
|
291
|
+
|
|
292
|
+
audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
|
293
|
+
try:
|
|
294
|
+
tensor = torch.from_numpy(audio_np)
|
|
295
|
+
prob = vad_model(tensor, RATE).item()
|
|
296
|
+
except Exception:
|
|
297
|
+
prob = 0.0
|
|
298
|
+
|
|
299
|
+
if prob < ui.vad_threshold:
|
|
300
|
+
silence_dur += chunk_dur
|
|
230
301
|
else:
|
|
231
|
-
|
|
302
|
+
silence_dur = 0.0
|
|
232
303
|
|
|
233
|
-
|
|
234
|
-
|
|
304
|
+
if silence_dur >= ui.silence_timeout:
|
|
305
|
+
break
|
|
235
306
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if context_content:
|
|
246
|
-
current_prompt += f"\n\nContext:{context_content}"
|
|
247
|
-
|
|
248
|
-
# Get response
|
|
249
|
-
resp = get_llm_response(
|
|
250
|
-
current_prompt,
|
|
251
|
-
model=model,
|
|
252
|
-
provider=provider,
|
|
253
|
-
messages=messages,
|
|
254
|
-
stream=False, # Don't stream for voice
|
|
255
|
-
npc=npc
|
|
256
|
-
)
|
|
307
|
+
# Safety: max 60 seconds
|
|
308
|
+
if speech_dur > 60.0:
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
ui.recording = False
|
|
312
|
+
|
|
313
|
+
# Only process if enough speech
|
|
314
|
+
if speech_dur - silence_dur < ui.min_speech:
|
|
315
|
+
continue
|
|
257
316
|
|
|
258
|
-
|
|
259
|
-
|
|
317
|
+
# Transcribe
|
|
318
|
+
ui.transcribing = True
|
|
319
|
+
audio_path = save_frames_to_wav(speech_frames, sw)
|
|
320
|
+
text = transcribe_audio(audio_path)
|
|
321
|
+
ui.transcribing = False
|
|
260
322
|
|
|
261
|
-
|
|
262
|
-
|
|
323
|
+
if text and text.strip():
|
|
324
|
+
ui.chat_log.append(('info', f'Heard: "{text}"'))
|
|
325
|
+
ui.chat_scroll = -1
|
|
326
|
+
send_message(text)
|
|
263
327
|
|
|
328
|
+
# Cleanup
|
|
329
|
+
try:
|
|
330
|
+
stream.stop_stream()
|
|
331
|
+
stream.close()
|
|
332
|
+
p.terminate()
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
# ================================================================
|
|
337
|
+
# Chat send
|
|
338
|
+
# ================================================================
|
|
339
|
+
def send_message(text):
|
|
340
|
+
ui.chat_log.append(('user', text))
|
|
341
|
+
ui.thinking = True
|
|
342
|
+
ui.chat_scroll = -1
|
|
343
|
+
|
|
344
|
+
def worker():
|
|
345
|
+
try:
|
|
346
|
+
current_prompt = text
|
|
347
|
+
if loaded_chunks:
|
|
348
|
+
ctx_content = ""
|
|
349
|
+
for fn, chunks in loaded_chunks.items():
|
|
350
|
+
full = "\n".join(chunks)
|
|
351
|
+
ret = rag_search(text, full, similarity_threshold=0.3)
|
|
352
|
+
if ret:
|
|
353
|
+
ctx_content += f"\n{ret}\n"
|
|
354
|
+
if ctx_content:
|
|
355
|
+
current_prompt += f"\n\nContext:{ctx_content}"
|
|
356
|
+
|
|
357
|
+
resp = get_llm_response(
|
|
358
|
+
current_prompt, model=model, provider=provider,
|
|
359
|
+
messages=messages, stream=False, npc=npc
|
|
360
|
+
)
|
|
361
|
+
messages[:] = resp.get('messages', messages)
|
|
362
|
+
response_text = str(resp.get('response', ''))
|
|
363
|
+
if response_text:
|
|
364
|
+
ui.chat_log.append(('assistant', response_text))
|
|
365
|
+
if ui.auto_speak and ui.tts_enabled:
|
|
366
|
+
speak_text(response_text)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
ui.chat_log.append(('error', str(e)))
|
|
369
|
+
ui.thinking = False
|
|
370
|
+
|
|
371
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
372
|
+
|
|
373
|
+
# ================================================================
|
|
374
|
+
# Rendering
|
|
375
|
+
# ================================================================
|
|
376
|
+
def render():
|
|
377
|
+
w, h = sz()
|
|
378
|
+
buf = ['\033[H']
|
|
379
|
+
|
|
380
|
+
# Tab bar
|
|
381
|
+
tabs = ''
|
|
382
|
+
for i, name in enumerate(ui.TAB_NAMES):
|
|
383
|
+
if i == ui.tab:
|
|
384
|
+
tabs += f' {REV}{BOLD} {name} {RST} '
|
|
385
|
+
else:
|
|
386
|
+
tabs += f' {DIM} {name} {RST} '
|
|
387
|
+
|
|
388
|
+
mic = ''
|
|
389
|
+
if ui.recording:
|
|
390
|
+
mic = f'{RED}● REC {ui.rec_seconds:.1f}s{RST}'
|
|
391
|
+
elif ui.transcribing:
|
|
392
|
+
mic = f'{ORANGE}● transcribing...{RST}'
|
|
393
|
+
elif ui.speaking:
|
|
394
|
+
mic = f'{GREEN}● speaking...{RST}'
|
|
395
|
+
elif ui.thinking:
|
|
396
|
+
sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
|
|
397
|
+
mic = f'{ORANGE}{sp} thinking...{RST}'
|
|
398
|
+
elif ui.listening:
|
|
399
|
+
mic = f'{TURQ}● listening{RST}'
|
|
400
|
+
|
|
401
|
+
audio_st = '🎤' if ui.listening else ('🔇' if not AUDIO_AVAILABLE else '⏸')
|
|
402
|
+
right = f'{npc_name} | {audio_st} | {model or "?"}@{provider or "?"}'
|
|
403
|
+
pad = w - 12 - len(right) - 20
|
|
404
|
+
header = f'{PURPLE}YAP{RST} {tabs}{" " * max(0, pad)}{mic} {DIM}{right}{RST}'
|
|
405
|
+
buf.append(f'\033[1;1H{REV} {header[:w-2].ljust(w-2)} {RST}')
|
|
406
|
+
|
|
407
|
+
if ui.tab == 0:
|
|
408
|
+
render_chat(buf, w, h)
|
|
409
|
+
elif ui.tab == 1:
|
|
410
|
+
render_settings(buf, w, h)
|
|
411
|
+
|
|
412
|
+
sys.stdout.write(''.join(buf))
|
|
413
|
+
sys.stdout.flush()
|
|
414
|
+
|
|
415
|
+
def render_chat(buf, w, h):
|
|
416
|
+
input_h = 3
|
|
417
|
+
chat_h = h - 2 - input_h
|
|
418
|
+
|
|
419
|
+
all_lines = []
|
|
420
|
+
_asst_pw = len(npc_name) + 2 # "name: "
|
|
421
|
+
_cont_pw = _asst_pw # continuation indent matches
|
|
422
|
+
for role, text in ui.chat_log:
|
|
423
|
+
if role == 'user':
|
|
424
|
+
tw = w - 6
|
|
425
|
+
wrapped = wrap_text(text, tw)
|
|
426
|
+
for i, l in enumerate(wrapped):
|
|
427
|
+
prefix = f'{BOLD}you:{RST} ' if i == 0 else ' '
|
|
428
|
+
all_lines.append(f'{prefix}{l}')
|
|
429
|
+
elif role == 'assistant':
|
|
430
|
+
tw = w - _asst_pw - 1
|
|
431
|
+
wrapped = wrap_text(text, tw)
|
|
432
|
+
pad = ' ' * _asst_pw
|
|
433
|
+
for i, l in enumerate(wrapped):
|
|
434
|
+
prefix = f'{PURPLE}{BOLD}{npc_name}:{RST} ' if i == 0 else pad
|
|
435
|
+
all_lines.append(f'{prefix}{l}')
|
|
436
|
+
elif role == 'info':
|
|
437
|
+
tw = w - 5
|
|
438
|
+
wrapped = wrap_text(text, tw)
|
|
439
|
+
for i, l in enumerate(wrapped):
|
|
440
|
+
prefix = f' {TURQ}ℹ ' if i == 0 else ' '
|
|
441
|
+
all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
|
|
442
|
+
elif role == 'error':
|
|
443
|
+
tw = w - 5
|
|
444
|
+
wrapped = wrap_text(text, tw)
|
|
445
|
+
for i, l in enumerate(wrapped):
|
|
446
|
+
prefix = f' {RED}✗ ' if i == 0 else ' '
|
|
447
|
+
all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
|
|
448
|
+
|
|
449
|
+
if ui.recording:
|
|
450
|
+
secs = ui.rec_seconds
|
|
451
|
+
all_lines.append(f' {RED}🎙 Recording... {secs:.1f}s{RST}')
|
|
452
|
+
elif ui.transcribing:
|
|
453
|
+
sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
|
|
454
|
+
all_lines.append(f' {ORANGE}{sp} Transcribing...{RST}')
|
|
455
|
+
elif ui.thinking:
|
|
456
|
+
sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
|
|
457
|
+
all_lines.append(f' {ORANGE}{sp} thinking...{RST}')
|
|
458
|
+
elif ui.speaking:
|
|
459
|
+
all_lines.append(f' {GREEN}🔊 Speaking...{RST}')
|
|
460
|
+
|
|
461
|
+
# Scrolling
|
|
462
|
+
if ui.chat_scroll == -1:
|
|
463
|
+
scroll = max(0, len(all_lines) - chat_h)
|
|
464
|
+
else:
|
|
465
|
+
scroll = ui.chat_scroll
|
|
466
|
+
|
|
467
|
+
for i in range(chat_h):
|
|
468
|
+
y = 2 + i
|
|
469
|
+
li = scroll + i
|
|
470
|
+
buf.append(f'\033[{y};1H\033[K')
|
|
471
|
+
if li < len(all_lines):
|
|
472
|
+
buf.append(all_lines[li])
|
|
473
|
+
|
|
474
|
+
# Input area
|
|
475
|
+
div_y = 2 + chat_h
|
|
476
|
+
buf.append(f'\033[{div_y};1H\033[K{DIM}{"─" * w}{RST}')
|
|
477
|
+
input_y = div_y + 1
|
|
478
|
+
visible = ui.input_buf[-(w-4):] if len(ui.input_buf) > w - 4 else ui.input_buf
|
|
479
|
+
buf.append(f'\033[{input_y};1H\033[K {BOLD}>{RST} {visible}\033[?25h')
|
|
480
|
+
|
|
481
|
+
# Status bar
|
|
482
|
+
if AUDIO_AVAILABLE:
|
|
483
|
+
ltog = 'Ctrl+L:Pause' if ui.listening else 'Ctrl+L:Listen'
|
|
484
|
+
hints = f'Enter:Send {ltog} PgUp/PgDn:Scroll Tab:Settings Ctrl+Q:Quit'
|
|
485
|
+
else:
|
|
486
|
+
hints = 'Enter:Send PgUp/PgDn:Scroll Tab:Settings Ctrl+Q:Quit'
|
|
487
|
+
buf.append(f'\033[{h};1H\033[K{REV} {hints[:w-2].ljust(w-2)} {RST}')
|
|
488
|
+
|
|
489
|
+
def render_settings(buf, w, h):
|
|
490
|
+
settings = [
|
|
491
|
+
('tts_enabled', 'TTS Enabled', 'On' if ui.tts_enabled else 'Off'),
|
|
492
|
+
('auto_speak', 'Auto-Speak', 'On' if ui.auto_speak else 'Off'),
|
|
493
|
+
('listening', 'Auto-Listen', 'On' if ui.listening else 'Off'),
|
|
494
|
+
('silence_timeout', 'Silence Timeout', f'{ui.silence_timeout}s'),
|
|
495
|
+
('vad_threshold', 'VAD Sensitivity', f'{ui.vad_threshold:.1f}'),
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
buf.append(f'\033[3;3H{BOLD}Voice Settings{RST}')
|
|
499
|
+
buf.append(f'\033[4;3H{DIM}{"─" * (w - 6)}{RST}')
|
|
500
|
+
|
|
501
|
+
y = 6
|
|
502
|
+
for i, (key, label, val) in enumerate(settings):
|
|
503
|
+
if ui.editing and ui.edit_key == key:
|
|
504
|
+
buf.append(f'\033[{y};3H{ORANGE}{label}:{RST} {REV} {ui.edit_buf}_ {RST}')
|
|
505
|
+
elif i == ui.set_sel:
|
|
506
|
+
buf.append(f'\033[{y};3H{REV} {label}: {val} {RST}')
|
|
507
|
+
else:
|
|
508
|
+
buf.append(f'\033[{y};3H {BOLD}{label}:{RST} {val}')
|
|
509
|
+
y += 2
|
|
510
|
+
|
|
511
|
+
y += 1
|
|
512
|
+
buf.append(f'\033[{y};3H{DIM}Audio: {"Available" if AUDIO_AVAILABLE else "Not available"}{RST}')
|
|
513
|
+
y += 1
|
|
514
|
+
if loaded_chunks:
|
|
515
|
+
buf.append(f'\033[{y};3H{DIM}Files loaded: {len(loaded_chunks)}{RST}')
|
|
516
|
+
y += 1
|
|
517
|
+
buf.append(f'\033[{y};3H{DIM}Whisper: {"Loaded" if whisper_model else "Not loaded"}{RST}')
|
|
518
|
+
|
|
519
|
+
for cy in range(y + 1, h - 1):
|
|
520
|
+
buf.append(f'\033[{cy};1H\033[K')
|
|
521
|
+
|
|
522
|
+
if ui.editing:
|
|
523
|
+
buf.append(f'\033[{h};1H\033[K{REV} Enter:Save Esc:Cancel {RST}')
|
|
524
|
+
else:
|
|
525
|
+
buf.append(f'\033[{h};1H\033[K{REV} j/k:Navigate Space:Toggle e:Edit Tab:Chat Ctrl+Q:Quit {RST}')
|
|
526
|
+
|
|
527
|
+
# ================================================================
|
|
528
|
+
# Input handling
|
|
529
|
+
# ================================================================
|
|
530
|
+
def handle_key(c, fd):
|
|
531
|
+
if c == '\t':
|
|
532
|
+
if not ui.editing:
|
|
533
|
+
ui.tab = (ui.tab + 1) % 2
|
|
534
|
+
return True
|
|
535
|
+
if c == '\x11': # Ctrl+Q
|
|
536
|
+
return False
|
|
537
|
+
if c == '\x03': # Ctrl+C
|
|
538
|
+
return True
|
|
539
|
+
|
|
540
|
+
# Escape sequences
|
|
541
|
+
if c == '\x1b':
|
|
542
|
+
if _sel.select([fd], [], [], 0.05)[0]:
|
|
543
|
+
c2 = os.read(fd, 1).decode('latin-1')
|
|
544
|
+
if c2 == '[':
|
|
545
|
+
c3 = os.read(fd, 1).decode('latin-1')
|
|
546
|
+
if c3 == 'A': # Up
|
|
547
|
+
if ui.tab == 0: _chat_scroll_up()
|
|
548
|
+
elif ui.tab == 1 and not ui.editing and ui.set_sel > 0: ui.set_sel -= 1
|
|
549
|
+
elif c3 == 'B': # Down
|
|
550
|
+
if ui.tab == 0: _chat_scroll_down()
|
|
551
|
+
elif ui.tab == 1 and not ui.editing and ui.set_sel < 4: ui.set_sel += 1
|
|
552
|
+
elif c3 == '5': # PgUp
|
|
553
|
+
os.read(fd, 1)
|
|
554
|
+
if ui.tab == 0: _chat_page_up()
|
|
555
|
+
elif c3 == '6': # PgDn
|
|
556
|
+
os.read(fd, 1)
|
|
557
|
+
if ui.tab == 0: _chat_page_down()
|
|
558
|
+
elif c2 == 'O':
|
|
559
|
+
c3 = os.read(fd, 1).decode('latin-1')
|
|
560
|
+
if c3 == 'P': ui.tab = 0 # F1
|
|
561
|
+
elif c3 == 'Q': ui.tab = 1 # F2
|
|
562
|
+
else:
|
|
563
|
+
# bare Esc
|
|
564
|
+
if ui.tab == 1 and ui.editing:
|
|
565
|
+
ui.editing = False
|
|
566
|
+
ui.edit_buf = ""
|
|
567
|
+
else:
|
|
568
|
+
if ui.tab == 1 and ui.editing:
|
|
569
|
+
ui.editing = False
|
|
570
|
+
ui.edit_buf = ""
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
if ui.tab == 0:
|
|
574
|
+
return handle_chat(c, fd)
|
|
575
|
+
elif ui.tab == 1:
|
|
576
|
+
return handle_settings(c, fd)
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
def _chat_scroll_up():
|
|
580
|
+
_, h = sz()
|
|
581
|
+
chat_h = h - 5
|
|
582
|
+
if ui.chat_scroll == -1:
|
|
583
|
+
ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - 1)
|
|
584
|
+
ui.chat_scroll = max(0, ui.chat_scroll - 1)
|
|
585
|
+
|
|
586
|
+
def _chat_scroll_down():
|
|
587
|
+
ui.chat_scroll = -1 if ui.chat_scroll == -1 else ui.chat_scroll + 1
|
|
588
|
+
|
|
589
|
+
def _chat_page_up():
|
|
590
|
+
_, h = sz()
|
|
591
|
+
chat_h = h - 5
|
|
592
|
+
if ui.chat_scroll == -1:
|
|
593
|
+
ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - chat_h)
|
|
594
|
+
else:
|
|
595
|
+
ui.chat_scroll = max(0, ui.chat_scroll - chat_h)
|
|
596
|
+
|
|
597
|
+
def _chat_page_down():
|
|
598
|
+
ui.chat_scroll = -1
|
|
599
|
+
|
|
600
|
+
def handle_chat(c, fd):
|
|
601
|
+
# Ctrl+L = toggle listening
|
|
602
|
+
if c == '\x0c': # Ctrl+L
|
|
264
603
|
if AUDIO_AVAILABLE:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
604
|
+
ui.listening = not ui.listening
|
|
605
|
+
st = 'on' if ui.listening else 'off'
|
|
606
|
+
ui.chat_log.append(('info', f'Listening {st}.'))
|
|
607
|
+
return True
|
|
608
|
+
|
|
609
|
+
if ui.recording or ui.transcribing:
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
if ui.thinking:
|
|
613
|
+
return True
|
|
614
|
+
|
|
615
|
+
if c in ('\r', '\n'):
|
|
616
|
+
text = ui.input_buf.strip()
|
|
617
|
+
ui.input_buf = ""
|
|
618
|
+
if text:
|
|
619
|
+
send_message(text)
|
|
620
|
+
return True
|
|
621
|
+
|
|
622
|
+
if c == '\x7f' or c == '\x08':
|
|
623
|
+
ui.input_buf = ui.input_buf[:-1]
|
|
624
|
+
return True
|
|
625
|
+
|
|
626
|
+
if c >= ' ' and c <= '~':
|
|
627
|
+
ui.input_buf += c
|
|
628
|
+
ui.chat_scroll = -1
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
return True
|
|
632
|
+
|
|
633
|
+
def handle_settings(c, fd):
|
|
634
|
+
SETTINGS_KEYS = ['tts_enabled', 'auto_speak', 'listening', 'silence_timeout', 'vad_threshold']
|
|
635
|
+
|
|
636
|
+
if ui.editing:
|
|
637
|
+
if c in ('\r', '\n'):
|
|
638
|
+
val = ui.edit_buf.strip()
|
|
639
|
+
if ui.edit_key == 'silence_timeout':
|
|
640
|
+
try: ui.silence_timeout = max(0.3, min(10.0, float(val)))
|
|
641
|
+
except: pass
|
|
642
|
+
elif ui.edit_key == 'vad_threshold':
|
|
643
|
+
try: ui.vad_threshold = max(0.1, min(0.9, float(val)))
|
|
644
|
+
except: pass
|
|
645
|
+
ui.editing = False
|
|
646
|
+
ui.edit_buf = ""
|
|
647
|
+
elif c == '\x7f' or c == '\x08':
|
|
648
|
+
ui.edit_buf = ui.edit_buf[:-1]
|
|
649
|
+
elif c >= ' ' and c <= '~':
|
|
650
|
+
ui.edit_buf += c
|
|
651
|
+
return True
|
|
652
|
+
|
|
653
|
+
if c == 'j' and ui.set_sel < len(SETTINGS_KEYS) - 1:
|
|
654
|
+
ui.set_sel += 1
|
|
655
|
+
elif c == 'k' and ui.set_sel > 0:
|
|
656
|
+
ui.set_sel -= 1
|
|
657
|
+
elif c == ' ':
|
|
658
|
+
key = SETTINGS_KEYS[ui.set_sel]
|
|
659
|
+
if key == 'tts_enabled':
|
|
660
|
+
ui.tts_enabled = not ui.tts_enabled
|
|
661
|
+
elif key == 'auto_speak':
|
|
662
|
+
ui.auto_speak = not ui.auto_speak
|
|
663
|
+
elif key == 'listening':
|
|
664
|
+
ui.listening = not ui.listening
|
|
665
|
+
st = 'on' if ui.listening else 'off'
|
|
666
|
+
ui.chat_log.append(('info', f'Listening {st}.'))
|
|
667
|
+
elif c == 'e':
|
|
668
|
+
key = SETTINGS_KEYS[ui.set_sel]
|
|
669
|
+
if key in ('silence_timeout', 'vad_threshold'):
|
|
670
|
+
ui.editing = True
|
|
671
|
+
ui.edit_key = key
|
|
672
|
+
ui.edit_buf = str(ui.silence_timeout if key == 'silence_timeout' else ui.vad_threshold)
|
|
673
|
+
return True
|
|
674
|
+
|
|
675
|
+
# ================================================================
|
|
676
|
+
# Welcome
|
|
677
|
+
# ================================================================
|
|
678
|
+
ui.chat_log.append(('info', f'YAP voice chat. NPC: {npc_name}.'))
|
|
679
|
+
if AUDIO_AVAILABLE:
|
|
680
|
+
ui.chat_log.append(('info', 'Listening for speech. Just start talking, or type text.'))
|
|
681
|
+
ui.chat_log.append(('info', 'Ctrl+L to pause/resume listening.'))
|
|
682
|
+
else:
|
|
683
|
+
ui.chat_log.append(('info', 'Audio not available. Text mode only.'))
|
|
684
|
+
if loaded_chunks:
|
|
685
|
+
ui.chat_log.append(('info', f'{len(loaded_chunks)} files loaded for context.'))
|
|
686
|
+
|
|
687
|
+
# Start VAD listener thread
|
|
688
|
+
_listener_thread = None
|
|
689
|
+
if AUDIO_AVAILABLE and vad_model is not None:
|
|
690
|
+
_listener_thread = threading.Thread(target=vad_listener_loop, daemon=True)
|
|
691
|
+
_listener_thread.start()
|
|
692
|
+
|
|
693
|
+
# ================================================================
|
|
694
|
+
# Main loop
|
|
695
|
+
# ================================================================
|
|
696
|
+
fd = sys.stdin.fileno()
|
|
697
|
+
old_settings = termios.tcgetattr(fd)
|
|
698
|
+
try:
|
|
699
|
+
tty.setcbreak(fd)
|
|
700
|
+
sys.stdout.write('\033[?25l\033[2J')
|
|
701
|
+
running = True
|
|
702
|
+
while running:
|
|
703
|
+
render()
|
|
704
|
+
if ui.thinking or ui.recording or ui.transcribing or ui.speaking or ui.listening:
|
|
705
|
+
ui.spinner_frame += 1
|
|
706
|
+
if _sel.select([fd], [], [], 0.15)[0]:
|
|
707
|
+
c = os.read(fd, 1).decode('latin-1')
|
|
708
|
+
running = handle_key(c, fd)
|
|
709
|
+
finally:
|
|
710
|
+
ui.listen_stop = True
|
|
711
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
712
|
+
sys.stdout.write('\033[?25h\033[2J\033[H')
|
|
713
|
+
sys.stdout.flush()
|
|
273
714
|
|
|
274
715
|
context['output'] = "Exited yap mode."
|
|
275
716
|
context['messages'] = messages
|