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
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
jinx_name: yap
|
|
2
|
+
description: Voice chat TUI - speech-to-text input, text-to-speech output
|
|
3
|
+
interactive: true
|
|
4
|
+
inputs:
|
|
5
|
+
- model: null
|
|
6
|
+
- provider: null
|
|
7
|
+
- tts_model: kokoro
|
|
8
|
+
- voice: af_heart
|
|
9
|
+
- files: null
|
|
10
|
+
|
|
11
|
+
steps:
|
|
12
|
+
- name: yap_tui
|
|
13
|
+
engine: python
|
|
14
|
+
code: |
|
|
15
|
+
import os, sys, tty, termios, time, tempfile, threading, queue
|
|
16
|
+
import select as _sel
|
|
17
|
+
from termcolor import colored
|
|
18
|
+
|
|
19
|
+
# Audio imports
|
|
20
|
+
try:
|
|
21
|
+
import torch
|
|
22
|
+
import pyaudio
|
|
23
|
+
import wave
|
|
24
|
+
import numpy as np
|
|
25
|
+
from faster_whisper import WhisperModel
|
|
26
|
+
from gtts import gTTS
|
|
27
|
+
from npcpy.data.audio import (
|
|
28
|
+
FORMAT, CHANNELS, RATE, CHUNK,
|
|
29
|
+
transcribe_recording, convert_mp3_to_wav
|
|
30
|
+
)
|
|
31
|
+
AUDIO_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
AUDIO_AVAILABLE = False
|
|
34
|
+
|
|
35
|
+
from npcpy.llm_funcs import get_llm_response
|
|
36
|
+
from npcpy.npc_sysenv import get_system_message, render_markdown
|
|
37
|
+
from npcpy.data.load import load_file_contents
|
|
38
|
+
from npcpy.data.text import rag_search
|
|
39
|
+
|
|
40
|
+
npc = context.get('npc')
|
|
41
|
+
team = context.get('team')
|
|
42
|
+
messages = context.get('messages', [])
|
|
43
|
+
files = context.get('files')
|
|
44
|
+
tts_model_name = context.get('tts_model', 'kokoro')
|
|
45
|
+
voice_name = context.get('voice', 'af_heart')
|
|
46
|
+
|
|
47
|
+
if isinstance(npc, str) and team:
|
|
48
|
+
npc = team.get(npc) if hasattr(team, 'get') else None
|
|
49
|
+
elif isinstance(npc, str):
|
|
50
|
+
npc = None
|
|
51
|
+
|
|
52
|
+
model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
|
|
53
|
+
provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
|
|
54
|
+
npc_name = npc.name if npc else "yap"
|
|
55
|
+
|
|
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
|
+
# ================================================================
|
|
67
|
+
vad_model = None
|
|
68
|
+
whisper_model = None
|
|
69
|
+
|
|
70
|
+
if AUDIO_AVAILABLE:
|
|
71
|
+
try:
|
|
72
|
+
vad_model, _ = torch.hub.load(
|
|
73
|
+
repo_or_dir="snakers4/silero-vad",
|
|
74
|
+
model="silero_vad",
|
|
75
|
+
force_reload=False,
|
|
76
|
+
onnx=False,
|
|
77
|
+
verbose=False
|
|
78
|
+
)
|
|
79
|
+
vad_model.to('cpu')
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
try:
|
|
83
|
+
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
|
|
84
|
+
except Exception:
|
|
85
|
+
AUDIO_AVAILABLE = False
|
|
86
|
+
|
|
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})
|
|
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():
|
|
147
|
+
try:
|
|
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 ""
|
|
188
|
+
|
|
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)
|
|
201
|
+
import subprocess
|
|
202
|
+
if sys.platform == 'darwin':
|
|
203
|
+
subprocess.run(['afplay', wav_path], check=True, timeout=30)
|
|
204
|
+
elif sys.platform == 'linux':
|
|
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
|
|
210
|
+
except Exception as e:
|
|
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."""
|
|
233
|
+
try:
|
|
234
|
+
p = pyaudio.PyAudio()
|
|
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
|
|
242
|
+
|
|
243
|
+
chunk_dur = CHUNK / RATE # duration of one chunk in seconds
|
|
244
|
+
|
|
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
|
|
253
|
+
|
|
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
|
|
260
|
+
|
|
261
|
+
audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
|
262
|
+
if len(audio_np) != CHUNK:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
tensor = torch.from_numpy(audio_np)
|
|
267
|
+
prob = vad_model(tensor, RATE).item()
|
|
268
|
+
except Exception:
|
|
269
|
+
continue
|
|
270
|
+
|
|
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
|
|
301
|
+
else:
|
|
302
|
+
silence_dur = 0.0
|
|
303
|
+
|
|
304
|
+
if silence_dur >= ui.silence_timeout:
|
|
305
|
+
break
|
|
306
|
+
|
|
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
|
|
316
|
+
|
|
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
|
|
322
|
+
|
|
323
|
+
if text and text.strip():
|
|
324
|
+
ui.chat_log.append(('info', f'Heard: "{text}"'))
|
|
325
|
+
ui.chat_scroll = -1
|
|
326
|
+
send_message(text)
|
|
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
|
|
603
|
+
if AUDIO_AVAILABLE:
|
|
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()
|
|
714
|
+
|
|
715
|
+
context['output'] = "Exited yap mode."
|
|
716
|
+
context['messages'] = messages
|