npcsh 1.1.21__py3-none-any.whl → 1.1.23__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 +282 -125
- npcsh/benchmark/npcsh_agent.py +77 -232
- npcsh/benchmark/templates/install-npcsh.sh.j2 +12 -4
- npcsh/config.py +5 -2
- npcsh/mcp_server.py +9 -1
- npcsh/npc_team/alicanto.npc +8 -6
- npcsh/npc_team/corca.npc +5 -12
- npcsh/npc_team/frederic.npc +6 -9
- npcsh/npc_team/guac.npc +4 -4
- npcsh/npc_team/jinxs/lib/core/delegate.jinx +1 -1
- npcsh/npc_team/jinxs/lib/core/edit_file.jinx +84 -62
- npcsh/npc_team/jinxs/lib/core/sh.jinx +1 -1
- npcsh/npc_team/jinxs/lib/core/skill.jinx +59 -0
- npcsh/npc_team/jinxs/lib/utils/help.jinx +194 -10
- npcsh/npc_team/jinxs/lib/utils/init.jinx +528 -37
- npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +0 -1
- npcsh/npc_team/jinxs/lib/utils/serve.jinx +938 -21
- npcsh/npc_team/jinxs/modes/alicanto.jinx +102 -41
- npcsh/npc_team/jinxs/modes/build.jinx +378 -0
- npcsh-1.1.21.data/data/npcsh/npc_team/config_tui.jinx → npcsh/npc_team/jinxs/modes/config.jinx +1 -1
- npcsh/npc_team/jinxs/modes/convene.jinx +670 -0
- npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
- npcsh/npc_team/jinxs/modes/crond.jinx +818 -0
- npcsh/npc_team/jinxs/modes/kg.jinx +69 -2
- npcsh/npc_team/jinxs/modes/plonk.jinx +86 -15
- npcsh/npc_team/jinxs/modes/roll.jinx +368 -55
- npcsh/npc_team/jinxs/modes/skills.jinx +621 -0
- npcsh/npc_team/jinxs/modes/yap.jinx +1092 -177
- npcsh/npc_team/jinxs/skills/code-review/SKILL.md +45 -0
- npcsh/npc_team/jinxs/skills/debugging/SKILL.md +44 -0
- npcsh/npc_team/jinxs/skills/git-workflow.jinx +44 -0
- npcsh/npc_team/kadiefa.npc +6 -6
- npcsh/npc_team/npcsh.ctx +16 -0
- npcsh/npc_team/plonk.npc +5 -9
- npcsh/npc_team/sibiji.npc +15 -7
- npcsh/npcsh.py +1 -0
- npcsh/routes.py +0 -4
- npcsh/yap.py +22 -4
- npcsh-1.1.23.data/data/npcsh/npc_team/SKILL.md +44 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.jinx +102 -41
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.npc +8 -6
- npcsh-1.1.23.data/data/npcsh/npc_team/build.jinx +378 -0
- npcsh/npc_team/jinxs/modes/config_tui.jinx → npcsh-1.1.23.data/data/npcsh/npc_team/config.jinx +1 -1
- npcsh-1.1.23.data/data/npcsh/npc_team/convene.jinx +670 -0
- npcsh-1.1.23.data/data/npcsh/npc_team/corca.jinx +820 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.npc +5 -12
- npcsh-1.1.23.data/data/npcsh/npc_team/crond.jinx +818 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/delegate.jinx +1 -1
- npcsh-1.1.23.data/data/npcsh/npc_team/edit_file.jinx +119 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic.npc +6 -9
- npcsh-1.1.23.data/data/npcsh/npc_team/git-workflow.jinx +44 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.npc +4 -4
- npcsh-1.1.23.data/data/npcsh/npc_team/help.jinx +236 -0
- npcsh-1.1.23.data/data/npcsh/npc_team/init.jinx +532 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/jinxs.jinx +0 -1
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.npc +6 -6
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kg.jinx +69 -2
- npcsh-1.1.23.data/data/npcsh/npc_team/npcsh.ctx +34 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.jinx +86 -15
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.npc +5 -9
- npcsh-1.1.23.data/data/npcsh/npc_team/roll.jinx +378 -0
- npcsh-1.1.23.data/data/npcsh/npc_team/serve.jinx +943 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sh.jinx +1 -1
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.npc +15 -7
- npcsh-1.1.23.data/data/npcsh/npc_team/skill.jinx +59 -0
- npcsh-1.1.23.data/data/npcsh/npc_team/skills.jinx +621 -0
- npcsh-1.1.23.data/data/npcsh/npc_team/yap.jinx +1190 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/METADATA +404 -278
- npcsh-1.1.23.dist-info/RECORD +216 -0
- npcsh/npc_team/jinxs/incognide/add_tab.jinx +0 -11
- npcsh/npc_team/jinxs/incognide/close_pane.jinx +0 -9
- npcsh/npc_team/jinxs/incognide/close_tab.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/confirm.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/focus_pane.jinx +0 -9
- npcsh/npc_team/jinxs/incognide/list_panes.jinx +0 -8
- npcsh/npc_team/jinxs/incognide/navigate.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/notify.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/open_pane.jinx +0 -13
- npcsh/npc_team/jinxs/incognide/read_pane.jinx +0 -9
- npcsh/npc_team/jinxs/incognide/run_terminal.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/send_message.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/split_pane.jinx +0 -12
- npcsh/npc_team/jinxs/incognide/switch_npc.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/switch_tab.jinx +0 -10
- npcsh/npc_team/jinxs/incognide/write_file.jinx +0 -11
- npcsh/npc_team/jinxs/incognide/zen_mode.jinx +0 -9
- npcsh/npc_team/jinxs/lib/core/convene.jinx +0 -232
- 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/add_tab.jinx +0 -11
- npcsh-1.1.21.data/data/npcsh/npc_team/build.jinx +0 -65
- npcsh-1.1.21.data/data/npcsh/npc_team/close_pane.jinx +0 -9
- npcsh-1.1.21.data/data/npcsh/npc_team/close_tab.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/confirm.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/convene.jinx +0 -232
- 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/focus_pane.jinx +0 -9
- npcsh-1.1.21.data/data/npcsh/npc_team/help.jinx +0 -52
- npcsh-1.1.21.data/data/npcsh/npc_team/init.jinx +0 -41
- npcsh-1.1.21.data/data/npcsh/npc_team/kg_search.jinx +0 -429
- npcsh-1.1.21.data/data/npcsh/npc_team/list_panes.jinx +0 -8
- npcsh-1.1.21.data/data/npcsh/npc_team/navigate.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/notify.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/npcsh.ctx +0 -18
- npcsh-1.1.21.data/data/npcsh/npc_team/open_pane.jinx +0 -13
- npcsh-1.1.21.data/data/npcsh/npc_team/read_pane.jinx +0 -9
- npcsh-1.1.21.data/data/npcsh/npc_team/roll.jinx +0 -65
- npcsh-1.1.21.data/data/npcsh/npc_team/run_terminal.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/search.jinx +0 -54
- npcsh-1.1.21.data/data/npcsh/npc_team/send_message.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/serve.jinx +0 -26
- npcsh-1.1.21.data/data/npcsh/npc_team/split_pane.jinx +0 -12
- npcsh-1.1.21.data/data/npcsh/npc_team/switch_npc.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/switch_tab.jinx +0 -10
- npcsh-1.1.21.data/data/npcsh/npc_team/write_file.jinx +0 -11
- npcsh-1.1.21.data/data/npcsh/npc_team/yap.jinx +0 -275
- npcsh-1.1.21.data/data/npcsh/npc_team/zen_mode.jinx +0 -9
- npcsh-1.1.21.dist-info/RECORD +0 -243
- /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
- /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
- /npcsh/npc_team/jinxs/{incognide → lib/utils}/incognide.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/chat.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/click.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/cmd.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compile.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compress.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca_example.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/db_search.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/file_search.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic4.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/git.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/incognide.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/key_press.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/load_file.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/memories.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/models.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/nql.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/ots.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/papers.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/paste.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonkjr.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/pti.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/python.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/reattach.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sample.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/set.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/setup.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/shh.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sleep.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.png +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sql.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switch.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switches.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sync.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/team.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/trigger.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/type_text.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/usage.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/verbose.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wait.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wander.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/web_search.jinx +0 -0
- {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.png +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/WHEEL +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/entry_points.txt +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/licenses/LICENSE +0 -0
- {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1190 @@
|
|
|
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
|
+
- show_setup: false
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- name: yap_tui
|
|
14
|
+
engine: python
|
|
15
|
+
code: |
|
|
16
|
+
import os, sys, tty, termios, time, tempfile, threading, queue
|
|
17
|
+
import select as _sel
|
|
18
|
+
from termcolor import colored
|
|
19
|
+
|
|
20
|
+
# Audio imports
|
|
21
|
+
try:
|
|
22
|
+
import torch
|
|
23
|
+
import pyaudio
|
|
24
|
+
import wave
|
|
25
|
+
import numpy as np
|
|
26
|
+
from faster_whisper import WhisperModel
|
|
27
|
+
from npcpy.data.audio import (
|
|
28
|
+
FORMAT, CHANNELS, RATE, CHUNK,
|
|
29
|
+
transcribe_recording, convert_mp3_to_wav
|
|
30
|
+
)
|
|
31
|
+
from npcpy.gen.audio_gen import text_to_speech, get_available_engines, get_available_voices
|
|
32
|
+
AUDIO_AVAILABLE = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
AUDIO_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
from npcpy.llm_funcs import get_llm_response
|
|
37
|
+
from npcpy.npc_sysenv import get_system_message, render_markdown, get_locally_available_models
|
|
38
|
+
from npcpy.data.load import load_file_contents
|
|
39
|
+
from npcpy.data.text import rag_search
|
|
40
|
+
|
|
41
|
+
npc = context.get('npc')
|
|
42
|
+
team = context.get('team')
|
|
43
|
+
messages = context.get('messages', [])
|
|
44
|
+
files = context.get('files')
|
|
45
|
+
tts_model_name = context.get('tts_model', 'kokoro')
|
|
46
|
+
voice_name = context.get('voice') or None
|
|
47
|
+
show_setup = context.get('show_setup', False)
|
|
48
|
+
|
|
49
|
+
if isinstance(npc, str) and team:
|
|
50
|
+
npc = team.get(npc) if hasattr(team, 'get') else None
|
|
51
|
+
elif isinstance(npc, str):
|
|
52
|
+
npc = None
|
|
53
|
+
|
|
54
|
+
model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
|
|
55
|
+
provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
|
|
56
|
+
npc_name = npc.name if npc else "yap"
|
|
57
|
+
|
|
58
|
+
# ================================================================
|
|
59
|
+
# Non-interactive fallback
|
|
60
|
+
# ================================================================
|
|
61
|
+
if not sys.stdin.isatty():
|
|
62
|
+
context['output'] = "Yap requires an interactive terminal."
|
|
63
|
+
context['messages'] = messages
|
|
64
|
+
exit()
|
|
65
|
+
|
|
66
|
+
# ================================================================
|
|
67
|
+
# Gather available options for setup/modal
|
|
68
|
+
# ================================================================
|
|
69
|
+
_all_engines = []
|
|
70
|
+
_engine_voices = {}
|
|
71
|
+
try:
|
|
72
|
+
_engines_info = get_available_engines()
|
|
73
|
+
for ename, einfo in _engines_info.items():
|
|
74
|
+
_all_engines.append(ename)
|
|
75
|
+
try:
|
|
76
|
+
_engine_voices[ename] = [v['id'] if isinstance(v, dict) else str(v) for v in get_available_voices(ename)]
|
|
77
|
+
except Exception:
|
|
78
|
+
_engine_voices[ename] = []
|
|
79
|
+
except Exception:
|
|
80
|
+
_all_engines = ['kokoro', 'qwen3', 'elevenlabs', 'openai', 'gemini', 'gtts']
|
|
81
|
+
_engine_voices = {e: [] for e in _all_engines}
|
|
82
|
+
|
|
83
|
+
if not _all_engines:
|
|
84
|
+
_all_engines = ['kokoro']
|
|
85
|
+
|
|
86
|
+
_all_models = []
|
|
87
|
+
_all_providers = []
|
|
88
|
+
try:
|
|
89
|
+
_local = get_locally_available_models(os.getcwd())
|
|
90
|
+
_seen_models = set()
|
|
91
|
+
_seen_providers = set()
|
|
92
|
+
for entry in _local:
|
|
93
|
+
m = entry.get('model', '') if isinstance(entry, dict) else str(entry)
|
|
94
|
+
p = entry.get('provider', '') if isinstance(entry, dict) else ''
|
|
95
|
+
if m and m not in _seen_models:
|
|
96
|
+
_all_models.append(m)
|
|
97
|
+
_seen_models.add(m)
|
|
98
|
+
if p and p not in _seen_providers:
|
|
99
|
+
_all_providers.append(p)
|
|
100
|
+
_seen_providers.add(p)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if not _all_models:
|
|
105
|
+
_all_models = [model or 'gemma3:4b']
|
|
106
|
+
if not _all_providers:
|
|
107
|
+
_all_providers = [provider or 'ollama']
|
|
108
|
+
|
|
109
|
+
# Ensure current selections are in the lists
|
|
110
|
+
if tts_model_name not in _all_engines:
|
|
111
|
+
_all_engines.insert(0, tts_model_name)
|
|
112
|
+
if model and model not in _all_models:
|
|
113
|
+
_all_models.insert(0, model)
|
|
114
|
+
if provider and provider not in _all_providers:
|
|
115
|
+
_all_providers.insert(0, provider)
|
|
116
|
+
|
|
117
|
+
def _voices_for_engine(eng):
|
|
118
|
+
v = _engine_voices.get(eng, [])
|
|
119
|
+
if not v:
|
|
120
|
+
defaults = {'kokoro': ['af_heart'], 'qwen3': ['ryan'], 'elevenlabs': ['rachel'],
|
|
121
|
+
'openai': ['alloy'], 'gemini': ['en-US-Standard-A'], 'gtts': ['en']}
|
|
122
|
+
v = defaults.get(eng, ['default'])
|
|
123
|
+
return v
|
|
124
|
+
|
|
125
|
+
# ================================================================
|
|
126
|
+
# Setup screen
|
|
127
|
+
# ================================================================
|
|
128
|
+
def run_setup():
|
|
129
|
+
nonlocal tts_model_name, voice_name, model, provider
|
|
130
|
+
|
|
131
|
+
TURQ = '\033[38;2;64;224;208m'
|
|
132
|
+
PURPLE = '\033[38;2;180;130;255m'
|
|
133
|
+
ORANGE = '\033[38;2;255;165;0m'
|
|
134
|
+
GREEN = '\033[32m'
|
|
135
|
+
DIM = '\033[90m'
|
|
136
|
+
BOLD = '\033[1m'
|
|
137
|
+
REV = '\033[7m'
|
|
138
|
+
RST = '\033[0m'
|
|
139
|
+
|
|
140
|
+
def _sz():
|
|
141
|
+
try:
|
|
142
|
+
s = os.get_terminal_size()
|
|
143
|
+
return s.columns, s.lines
|
|
144
|
+
except:
|
|
145
|
+
return 80, 24
|
|
146
|
+
|
|
147
|
+
# Setup state
|
|
148
|
+
fields = ['model', 'provider', 'engine', 'voice']
|
|
149
|
+
field_labels = {'model': 'LLM Model', 'provider': 'LLM Provider',
|
|
150
|
+
'engine': 'TTS Engine', 'voice': 'Voice'}
|
|
151
|
+
|
|
152
|
+
model_idx = _all_models.index(model) if model in _all_models else 0
|
|
153
|
+
provider_idx = _all_providers.index(provider) if provider in _all_providers else 0
|
|
154
|
+
engine_idx = _all_engines.index(tts_model_name) if tts_model_name in _all_engines else 0
|
|
155
|
+
cur_voices = _voices_for_engine(_all_engines[engine_idx])
|
|
156
|
+
voice_idx = 0
|
|
157
|
+
if voice_name and voice_name in cur_voices:
|
|
158
|
+
voice_idx = cur_voices.index(voice_name)
|
|
159
|
+
|
|
160
|
+
field_options = {
|
|
161
|
+
'model': (_all_models, model_idx),
|
|
162
|
+
'provider': (_all_providers, provider_idx),
|
|
163
|
+
'engine': (_all_engines, engine_idx),
|
|
164
|
+
'voice': (cur_voices, voice_idx),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
sel = 0 # 0-3 = fields, 4 = save default, 5 = dont show again
|
|
168
|
+
save_default = True
|
|
169
|
+
dont_show = False
|
|
170
|
+
total_rows = 6 # 4 fields + 2 checkboxes
|
|
171
|
+
|
|
172
|
+
def _get_val(f):
|
|
173
|
+
opts, idx = field_options[f]
|
|
174
|
+
return opts[idx] if opts else '?'
|
|
175
|
+
|
|
176
|
+
def _render_setup():
|
|
177
|
+
w, h = _sz()
|
|
178
|
+
box_w = min(50, w - 4)
|
|
179
|
+
box_h = 18
|
|
180
|
+
sx = max(1, (w - box_w) // 2)
|
|
181
|
+
sy = max(1, (h - box_h) // 2)
|
|
182
|
+
|
|
183
|
+
buf = ['\033[2J\033[H']
|
|
184
|
+
|
|
185
|
+
# Box border
|
|
186
|
+
buf.append(f'\033[{sy};{sx}H{PURPLE}{"─" * box_w}{RST}')
|
|
187
|
+
title = " YAP Voice Chat Setup "
|
|
188
|
+
tp = sx + (box_w - len(title)) // 2
|
|
189
|
+
buf.append(f'\033[{sy};{tp}H{BOLD}{PURPLE}{title}{RST}')
|
|
190
|
+
|
|
191
|
+
y = sy + 2
|
|
192
|
+
for i, f in enumerate(fields):
|
|
193
|
+
opts, idx = field_options[f]
|
|
194
|
+
val = opts[idx] if opts else '?'
|
|
195
|
+
label = field_labels[f]
|
|
196
|
+
lpad = sx + 2
|
|
197
|
+
|
|
198
|
+
if i == sel:
|
|
199
|
+
arrow_l = f'{TURQ}\u25c4{RST}'
|
|
200
|
+
arrow_r = f'{TURQ}\u25ba{RST}'
|
|
201
|
+
buf.append(f'\033[{y};{lpad}H{REV} {label}: {RST} {arrow_l} {BOLD}{val}{RST} {arrow_r}')
|
|
202
|
+
else:
|
|
203
|
+
buf.append(f'\033[{y};{lpad}H {DIM}{label}:{RST} {val}')
|
|
204
|
+
y += 2
|
|
205
|
+
|
|
206
|
+
# Separator
|
|
207
|
+
buf.append(f'\033[{y};{sx + 2}H{DIM}{"─" * (box_w - 4)}{RST}')
|
|
208
|
+
y += 1
|
|
209
|
+
|
|
210
|
+
# Checkboxes
|
|
211
|
+
ck_save = f'{GREEN}[x]{RST}' if save_default else '[ ]'
|
|
212
|
+
ck_dont = f'{GREEN}[x]{RST}' if dont_show else '[ ]'
|
|
213
|
+
|
|
214
|
+
if sel == 4:
|
|
215
|
+
buf.append(f'\033[{y};{sx + 2}H{REV} {ck_save} Save as default {RST}')
|
|
216
|
+
else:
|
|
217
|
+
buf.append(f'\033[{y};{sx + 2}H {ck_save} Save as default')
|
|
218
|
+
y += 1
|
|
219
|
+
|
|
220
|
+
if sel == 5:
|
|
221
|
+
buf.append(f'\033[{y};{sx + 2}H{REV} {ck_dont} Don\'t show again {RST}')
|
|
222
|
+
else:
|
|
223
|
+
buf.append(f'\033[{y};{sx + 2}H {ck_dont} Don\'t show again')
|
|
224
|
+
y += 2
|
|
225
|
+
|
|
226
|
+
# Hints
|
|
227
|
+
buf.append(f'\033[{y};{sx + 2}H{DIM}\u2191/\u2193:Navigate \u2190/\u2192:Change Space:Toggle Enter:Start Ctrl+Q:Quit{RST}')
|
|
228
|
+
y += 1
|
|
229
|
+
buf.append(f'\033[{y};{sx}H{PURPLE}{"─" * box_w}{RST}')
|
|
230
|
+
|
|
231
|
+
sys.stdout.write(''.join(buf))
|
|
232
|
+
sys.stdout.flush()
|
|
233
|
+
|
|
234
|
+
fd = sys.stdin.fileno()
|
|
235
|
+
old = termios.tcgetattr(fd)
|
|
236
|
+
try:
|
|
237
|
+
tty.setcbreak(fd)
|
|
238
|
+
sys.stdout.write('\033[?25l')
|
|
239
|
+
running = True
|
|
240
|
+
while running:
|
|
241
|
+
_render_setup()
|
|
242
|
+
if _sel.select([fd], [], [], 0.1)[0]:
|
|
243
|
+
c = os.read(fd, 1).decode('latin-1')
|
|
244
|
+
if c == '\x11' or c == '\x03': # Ctrl+Q / Ctrl+C
|
|
245
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
246
|
+
sys.stdout.write('\033[?25h\033[2J\033[H')
|
|
247
|
+
sys.stdout.flush()
|
|
248
|
+
context['output'] = "Setup cancelled."
|
|
249
|
+
context['messages'] = messages
|
|
250
|
+
exit()
|
|
251
|
+
|
|
252
|
+
elif c == '\x1b': # Escape sequence
|
|
253
|
+
if _sel.select([fd], [], [], 0.05)[0]:
|
|
254
|
+
c2 = os.read(fd, 1).decode('latin-1')
|
|
255
|
+
if c2 == '[':
|
|
256
|
+
c3 = os.read(fd, 1).decode('latin-1')
|
|
257
|
+
if c3 == 'A': # Up
|
|
258
|
+
sel = max(0, sel - 1)
|
|
259
|
+
elif c3 == 'B': # Down
|
|
260
|
+
sel = min(total_rows - 1, sel + 1)
|
|
261
|
+
elif c3 == 'D': # Left
|
|
262
|
+
if sel < 4:
|
|
263
|
+
f = fields[sel]
|
|
264
|
+
opts, idx = field_options[f]
|
|
265
|
+
if opts:
|
|
266
|
+
new_idx = (idx - 1) % len(opts)
|
|
267
|
+
field_options[f] = (opts, new_idx)
|
|
268
|
+
if f == 'engine':
|
|
269
|
+
nv = _voices_for_engine(opts[new_idx])
|
|
270
|
+
field_options['voice'] = (nv, 0)
|
|
271
|
+
elif c3 == 'C': # Right
|
|
272
|
+
if sel < 4:
|
|
273
|
+
f = fields[sel]
|
|
274
|
+
opts, idx = field_options[f]
|
|
275
|
+
if opts:
|
|
276
|
+
new_idx = (idx + 1) % len(opts)
|
|
277
|
+
field_options[f] = (opts, new_idx)
|
|
278
|
+
if f == 'engine':
|
|
279
|
+
nv = _voices_for_engine(opts[new_idx])
|
|
280
|
+
field_options['voice'] = (nv, 0)
|
|
281
|
+
|
|
282
|
+
elif c == 'k':
|
|
283
|
+
sel = max(0, sel - 1)
|
|
284
|
+
elif c == 'j':
|
|
285
|
+
sel = min(total_rows - 1, sel + 1)
|
|
286
|
+
elif c == 'h':
|
|
287
|
+
if sel < 4:
|
|
288
|
+
f = fields[sel]
|
|
289
|
+
opts, idx = field_options[f]
|
|
290
|
+
if opts:
|
|
291
|
+
new_idx = (idx - 1) % len(opts)
|
|
292
|
+
field_options[f] = (opts, new_idx)
|
|
293
|
+
if f == 'engine':
|
|
294
|
+
nv = _voices_for_engine(opts[new_idx])
|
|
295
|
+
field_options['voice'] = (nv, 0)
|
|
296
|
+
elif c == 'l':
|
|
297
|
+
if sel < 4:
|
|
298
|
+
f = fields[sel]
|
|
299
|
+
opts, idx = field_options[f]
|
|
300
|
+
if opts:
|
|
301
|
+
new_idx = (idx + 1) % len(opts)
|
|
302
|
+
field_options[f] = (opts, new_idx)
|
|
303
|
+
if f == 'engine':
|
|
304
|
+
nv = _voices_for_engine(opts[new_idx])
|
|
305
|
+
field_options['voice'] = (nv, 0)
|
|
306
|
+
elif c == ' ':
|
|
307
|
+
if sel == 4:
|
|
308
|
+
save_default = not save_default
|
|
309
|
+
elif sel == 5:
|
|
310
|
+
dont_show = not dont_show
|
|
311
|
+
elif c in ('\r', '\n'):
|
|
312
|
+
running = False
|
|
313
|
+
|
|
314
|
+
finally:
|
|
315
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
316
|
+
sys.stdout.write('\033[?25h\033[2J\033[H')
|
|
317
|
+
sys.stdout.flush()
|
|
318
|
+
|
|
319
|
+
# Apply selections
|
|
320
|
+
model = _get_val('model')
|
|
321
|
+
provider = _get_val('provider')
|
|
322
|
+
tts_model_name = _get_val('engine')
|
|
323
|
+
voice_name = _get_val('voice')
|
|
324
|
+
|
|
325
|
+
# Persist if requested
|
|
326
|
+
if save_default or dont_show:
|
|
327
|
+
from npcsh.config import set_npcsh_config_value
|
|
328
|
+
if save_default:
|
|
329
|
+
set_npcsh_config_value("NPCSH_TTS_ENGINE", tts_model_name)
|
|
330
|
+
set_npcsh_config_value("NPCSH_TTS_VOICE", voice_name)
|
|
331
|
+
set_npcsh_config_value("NPCSH_CHAT_MODEL", model)
|
|
332
|
+
set_npcsh_config_value("NPCSH_CHAT_PROVIDER", provider)
|
|
333
|
+
if dont_show:
|
|
334
|
+
set_npcsh_config_value("NPCSH_YAP_SETUP_DONE", "1")
|
|
335
|
+
|
|
336
|
+
if show_setup:
|
|
337
|
+
run_setup()
|
|
338
|
+
|
|
339
|
+
# Set default voice if still None
|
|
340
|
+
if not voice_name:
|
|
341
|
+
defaults = {'kokoro': 'af_heart', 'qwen3': 'ryan', 'elevenlabs': 'rachel',
|
|
342
|
+
'openai': 'alloy', 'gemini': 'en-US-Standard-A', 'gtts': 'en'}
|
|
343
|
+
voice_name = defaults.get(tts_model_name, 'default')
|
|
344
|
+
|
|
345
|
+
# ================================================================
|
|
346
|
+
# Audio models
|
|
347
|
+
# ================================================================
|
|
348
|
+
vad_model = None
|
|
349
|
+
whisper_model = None
|
|
350
|
+
|
|
351
|
+
if AUDIO_AVAILABLE:
|
|
352
|
+
try:
|
|
353
|
+
vad_model, _ = torch.hub.load(
|
|
354
|
+
repo_or_dir="snakers4/silero-vad",
|
|
355
|
+
model="silero_vad",
|
|
356
|
+
force_reload=False,
|
|
357
|
+
onnx=False,
|
|
358
|
+
verbose=False
|
|
359
|
+
)
|
|
360
|
+
vad_model.to('cpu')
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
try:
|
|
364
|
+
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
|
|
365
|
+
except Exception:
|
|
366
|
+
AUDIO_AVAILABLE = False
|
|
367
|
+
|
|
368
|
+
# ================================================================
|
|
369
|
+
# File loading for RAG
|
|
370
|
+
# ================================================================
|
|
371
|
+
loaded_chunks = {}
|
|
372
|
+
if files:
|
|
373
|
+
if isinstance(files, str):
|
|
374
|
+
files = [f.strip() for f in files.split(',')]
|
|
375
|
+
for fp in files:
|
|
376
|
+
fp = os.path.expanduser(fp)
|
|
377
|
+
if os.path.exists(fp):
|
|
378
|
+
try:
|
|
379
|
+
loaded_chunks[fp] = load_file_contents(fp)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
# System message
|
|
384
|
+
sys_msg = get_system_message(npc) if npc else "You are a helpful assistant."
|
|
385
|
+
sys_msg += "\n\nProvide brief responses of 1-2 sentences unless asked for more detail. Keep responses clear and conversational for voice."
|
|
386
|
+
if not messages or messages[0].get("role") != "system":
|
|
387
|
+
messages.insert(0, {"role": "system", "content": sys_msg})
|
|
388
|
+
|
|
389
|
+
# ================================================================
|
|
390
|
+
# State
|
|
391
|
+
# ================================================================
|
|
392
|
+
class UI:
|
|
393
|
+
tab = 0 # 0=chat, 1=settings
|
|
394
|
+
TAB_NAMES = ['Chat', 'Settings']
|
|
395
|
+
|
|
396
|
+
# chat
|
|
397
|
+
chat_log = [] # [(role, text)]
|
|
398
|
+
chat_scroll = -1
|
|
399
|
+
input_buf = ""
|
|
400
|
+
thinking = False
|
|
401
|
+
spinner_frame = 0
|
|
402
|
+
recording = False
|
|
403
|
+
rec_seconds = 0.0
|
|
404
|
+
transcribing = False
|
|
405
|
+
speaking = False
|
|
406
|
+
|
|
407
|
+
# VAD listening
|
|
408
|
+
listening = AUDIO_AVAILABLE # auto-listen by default
|
|
409
|
+
listen_stop = False # signal to stop listener thread
|
|
410
|
+
|
|
411
|
+
# settings
|
|
412
|
+
set_sel = 0
|
|
413
|
+
tts_enabled = AUDIO_AVAILABLE
|
|
414
|
+
auto_speak = True
|
|
415
|
+
vad_threshold = 0.4 # speech probability threshold
|
|
416
|
+
silence_timeout = 1.5 # seconds of silence before cut
|
|
417
|
+
min_speech = 0.3 # minimum speech duration to process
|
|
418
|
+
editing = False
|
|
419
|
+
edit_buf = ""
|
|
420
|
+
edit_key = ""
|
|
421
|
+
|
|
422
|
+
# Modal state
|
|
423
|
+
modal_open = False
|
|
424
|
+
modal_sel = 0
|
|
425
|
+
modal_engine_idx = 0
|
|
426
|
+
modal_voice_idx = 0
|
|
427
|
+
modal_model_idx = 0
|
|
428
|
+
modal_provider_idx = 0
|
|
429
|
+
modal_save = False
|
|
430
|
+
|
|
431
|
+
ui = UI()
|
|
432
|
+
|
|
433
|
+
# Initialize modal indices to current selections
|
|
434
|
+
if tts_model_name in _all_engines:
|
|
435
|
+
ui.modal_engine_idx = _all_engines.index(tts_model_name)
|
|
436
|
+
if model and model in _all_models:
|
|
437
|
+
ui.modal_model_idx = _all_models.index(model)
|
|
438
|
+
if provider and provider in _all_providers:
|
|
439
|
+
ui.modal_provider_idx = _all_providers.index(provider)
|
|
440
|
+
cur_v = _voices_for_engine(tts_model_name)
|
|
441
|
+
if voice_name in cur_v:
|
|
442
|
+
ui.modal_voice_idx = cur_v.index(voice_name)
|
|
443
|
+
|
|
444
|
+
# ================================================================
|
|
445
|
+
# Helpers
|
|
446
|
+
# ================================================================
|
|
447
|
+
def sz():
|
|
448
|
+
try:
|
|
449
|
+
s = os.get_terminal_size()
|
|
450
|
+
return s.columns, s.lines
|
|
451
|
+
except:
|
|
452
|
+
return 80, 24
|
|
453
|
+
|
|
454
|
+
TURQ = '\033[38;2;64;224;208m'
|
|
455
|
+
PURPLE = '\033[38;2;180;130;255m'
|
|
456
|
+
ORANGE = '\033[38;2;255;165;0m'
|
|
457
|
+
GREEN = '\033[32m'
|
|
458
|
+
DIM = '\033[90m'
|
|
459
|
+
BOLD = '\033[1m'
|
|
460
|
+
REV = '\033[7m'
|
|
461
|
+
RST = '\033[0m'
|
|
462
|
+
RED = '\033[31m'
|
|
463
|
+
SPINNERS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
|
|
464
|
+
|
|
465
|
+
def wrap_text(text, width):
|
|
466
|
+
lines = []
|
|
467
|
+
for line in text.split('\n'):
|
|
468
|
+
while len(line) > width:
|
|
469
|
+
lines.append(line[:width])
|
|
470
|
+
line = line[width:]
|
|
471
|
+
lines.append(line)
|
|
472
|
+
return lines
|
|
473
|
+
|
|
474
|
+
# ================================================================
|
|
475
|
+
# Audio functions
|
|
476
|
+
# ================================================================
|
|
477
|
+
def transcribe_audio(audio_path):
|
|
478
|
+
if not whisper_model or not audio_path:
|
|
479
|
+
return ""
|
|
480
|
+
try:
|
|
481
|
+
segments, _ = whisper_model.transcribe(audio_path, beam_size=5)
|
|
482
|
+
text = " ".join([seg.text for seg in segments]).strip()
|
|
483
|
+
try: os.remove(audio_path)
|
|
484
|
+
except: pass
|
|
485
|
+
return text
|
|
486
|
+
except Exception as e:
|
|
487
|
+
ui.chat_log.append(('error', f'Transcribe error: {e}'))
|
|
488
|
+
return ""
|
|
489
|
+
|
|
490
|
+
def speak_text(text):
|
|
491
|
+
if not AUDIO_AVAILABLE or not ui.tts_enabled:
|
|
492
|
+
return
|
|
493
|
+
try:
|
|
494
|
+
ui.speaking = True
|
|
495
|
+
import subprocess
|
|
496
|
+
|
|
497
|
+
audio_bytes = text_to_speech(text, engine=tts_model_name, voice=voice_name)
|
|
498
|
+
|
|
499
|
+
# Determine file format from engine
|
|
500
|
+
if tts_model_name in ('elevenlabs', 'gtts'):
|
|
501
|
+
suffix = '.mp3'
|
|
502
|
+
else:
|
|
503
|
+
suffix = '.wav'
|
|
504
|
+
|
|
505
|
+
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
|
506
|
+
tmp_path = tmp.name
|
|
507
|
+
tmp.write(audio_bytes)
|
|
508
|
+
tmp.close()
|
|
509
|
+
|
|
510
|
+
# If mp3, convert to wav for playback
|
|
511
|
+
play_path = tmp_path
|
|
512
|
+
if suffix == '.mp3':
|
|
513
|
+
wav_path = tmp_path.replace('.mp3', '.wav')
|
|
514
|
+
convert_mp3_to_wav(tmp_path, wav_path)
|
|
515
|
+
play_path = wav_path
|
|
516
|
+
|
|
517
|
+
if sys.platform == 'darwin':
|
|
518
|
+
subprocess.run(['afplay', play_path], check=True, timeout=60)
|
|
519
|
+
elif sys.platform == 'linux':
|
|
520
|
+
subprocess.run(['aplay', play_path], check=True, timeout=60,
|
|
521
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
522
|
+
|
|
523
|
+
for _p in set([tmp_path, play_path]):
|
|
524
|
+
try: os.remove(_p)
|
|
525
|
+
except: pass
|
|
526
|
+
except Exception as e:
|
|
527
|
+
ui.chat_log.append(('error', f'TTS error: {e}'))
|
|
528
|
+
finally:
|
|
529
|
+
ui.speaking = False
|
|
530
|
+
|
|
531
|
+
def save_frames_to_wav(frames, sample_width):
|
|
532
|
+
f = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
|
|
533
|
+
path = f.name
|
|
534
|
+
f.close()
|
|
535
|
+
wf = wave.open(path, 'wb')
|
|
536
|
+
wf.setnchannels(CHANNELS)
|
|
537
|
+
wf.setsampwidth(sample_width)
|
|
538
|
+
wf.setframerate(RATE)
|
|
539
|
+
wf.writeframes(b''.join(frames))
|
|
540
|
+
wf.close()
|
|
541
|
+
return path
|
|
542
|
+
|
|
543
|
+
# ================================================================
|
|
544
|
+
# VAD continuous listener
|
|
545
|
+
# ================================================================
|
|
546
|
+
def vad_listener_loop():
|
|
547
|
+
"""Background thread: continuously monitors mic, detects speech via
|
|
548
|
+
VAD, records until silence, then transcribes and sends."""
|
|
549
|
+
try:
|
|
550
|
+
p = pyaudio.PyAudio()
|
|
551
|
+
sw = p.get_sample_size(FORMAT)
|
|
552
|
+
stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE,
|
|
553
|
+
input=True, frames_per_buffer=CHUNK)
|
|
554
|
+
except Exception as e:
|
|
555
|
+
ui.chat_log.append(('error', f'Mic open failed: {e}'))
|
|
556
|
+
ui.listening = False
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
chunk_dur = CHUNK / RATE # duration of one chunk in seconds
|
|
560
|
+
|
|
561
|
+
while not ui.listen_stop:
|
|
562
|
+
# Skip if busy
|
|
563
|
+
if ui.thinking or ui.speaking or ui.transcribing:
|
|
564
|
+
time.sleep(0.1)
|
|
565
|
+
continue
|
|
566
|
+
if not ui.listening:
|
|
567
|
+
time.sleep(0.1)
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
# Read a chunk and run VAD
|
|
571
|
+
try:
|
|
572
|
+
data = stream.read(CHUNK, exception_on_overflow=False)
|
|
573
|
+
except Exception:
|
|
574
|
+
time.sleep(0.05)
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
|
578
|
+
if len(audio_np) != CHUNK:
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
tensor = torch.from_numpy(audio_np)
|
|
583
|
+
prob = vad_model(tensor, RATE).item()
|
|
584
|
+
except Exception:
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
if prob < ui.vad_threshold:
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
# Speech detected — start collecting frames
|
|
591
|
+
ui.recording = True
|
|
592
|
+
ui.rec_seconds = 0.0
|
|
593
|
+
ui.chat_scroll = -1
|
|
594
|
+
speech_frames = [data]
|
|
595
|
+
speech_dur = chunk_dur
|
|
596
|
+
silence_dur = 0.0
|
|
597
|
+
|
|
598
|
+
while not ui.listen_stop:
|
|
599
|
+
try:
|
|
600
|
+
data = stream.read(CHUNK, exception_on_overflow=False)
|
|
601
|
+
except Exception:
|
|
602
|
+
break
|
|
603
|
+
|
|
604
|
+
speech_frames.append(data)
|
|
605
|
+
speech_dur += chunk_dur
|
|
606
|
+
ui.rec_seconds = speech_dur
|
|
607
|
+
|
|
608
|
+
audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
|
609
|
+
try:
|
|
610
|
+
tensor = torch.from_numpy(audio_np)
|
|
611
|
+
prob = vad_model(tensor, RATE).item()
|
|
612
|
+
except Exception:
|
|
613
|
+
prob = 0.0
|
|
614
|
+
|
|
615
|
+
if prob < ui.vad_threshold:
|
|
616
|
+
silence_dur += chunk_dur
|
|
617
|
+
else:
|
|
618
|
+
silence_dur = 0.0
|
|
619
|
+
|
|
620
|
+
if silence_dur >= ui.silence_timeout:
|
|
621
|
+
break
|
|
622
|
+
|
|
623
|
+
# Safety: max 60 seconds
|
|
624
|
+
if speech_dur > 60.0:
|
|
625
|
+
break
|
|
626
|
+
|
|
627
|
+
ui.recording = False
|
|
628
|
+
|
|
629
|
+
# Only process if enough speech
|
|
630
|
+
if speech_dur - silence_dur < ui.min_speech:
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
# Transcribe
|
|
634
|
+
ui.transcribing = True
|
|
635
|
+
audio_path = save_frames_to_wav(speech_frames, sw)
|
|
636
|
+
text = transcribe_audio(audio_path)
|
|
637
|
+
ui.transcribing = False
|
|
638
|
+
|
|
639
|
+
if text and text.strip():
|
|
640
|
+
ui.chat_log.append(('info', f'Heard: "{text}"'))
|
|
641
|
+
ui.chat_scroll = -1
|
|
642
|
+
send_message(text)
|
|
643
|
+
|
|
644
|
+
# Cleanup
|
|
645
|
+
try:
|
|
646
|
+
stream.stop_stream()
|
|
647
|
+
stream.close()
|
|
648
|
+
p.terminate()
|
|
649
|
+
except Exception:
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
# ================================================================
|
|
653
|
+
# Chat send
|
|
654
|
+
# ================================================================
|
|
655
|
+
def send_message(text):
|
|
656
|
+
ui.chat_log.append(('user', text))
|
|
657
|
+
ui.thinking = True
|
|
658
|
+
ui.chat_scroll = -1
|
|
659
|
+
|
|
660
|
+
def worker():
|
|
661
|
+
try:
|
|
662
|
+
current_prompt = text
|
|
663
|
+
if loaded_chunks:
|
|
664
|
+
ctx_content = ""
|
|
665
|
+
for fn, chunks in loaded_chunks.items():
|
|
666
|
+
full = "\n".join(chunks)
|
|
667
|
+
ret = rag_search(text, full, similarity_threshold=0.3)
|
|
668
|
+
if ret:
|
|
669
|
+
ctx_content += f"\n{ret}\n"
|
|
670
|
+
if ctx_content:
|
|
671
|
+
current_prompt += f"\n\nContext:{ctx_content}"
|
|
672
|
+
|
|
673
|
+
resp = get_llm_response(
|
|
674
|
+
current_prompt, model=model, provider=provider,
|
|
675
|
+
messages=messages, stream=False, npc=npc
|
|
676
|
+
)
|
|
677
|
+
messages[:] = resp.get('messages', messages)
|
|
678
|
+
response_text = str(resp.get('response', ''))
|
|
679
|
+
if response_text:
|
|
680
|
+
ui.chat_log.append(('assistant', response_text))
|
|
681
|
+
if ui.auto_speak and ui.tts_enabled:
|
|
682
|
+
speak_text(response_text)
|
|
683
|
+
except Exception as e:
|
|
684
|
+
ui.chat_log.append(('error', str(e)))
|
|
685
|
+
ui.thinking = False
|
|
686
|
+
|
|
687
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
688
|
+
|
|
689
|
+
# ================================================================
|
|
690
|
+
# Rendering
|
|
691
|
+
# ================================================================
|
|
692
|
+
def render():
|
|
693
|
+
w, h = sz()
|
|
694
|
+
buf = ['\033[H']
|
|
695
|
+
|
|
696
|
+
# Tab bar
|
|
697
|
+
tabs = ''
|
|
698
|
+
for i, name in enumerate(ui.TAB_NAMES):
|
|
699
|
+
if i == ui.tab:
|
|
700
|
+
tabs += f' {REV}{BOLD} {name} {RST} '
|
|
701
|
+
else:
|
|
702
|
+
tabs += f' {DIM} {name} {RST} '
|
|
703
|
+
|
|
704
|
+
mic = ''
|
|
705
|
+
if ui.recording:
|
|
706
|
+
mic = f'{RED}\u25cf REC {ui.rec_seconds:.1f}s{RST}'
|
|
707
|
+
elif ui.transcribing:
|
|
708
|
+
mic = f'{ORANGE}\u25cf transcribing...{RST}'
|
|
709
|
+
elif ui.speaking:
|
|
710
|
+
mic = f'{GREEN}\u25cf speaking...{RST}'
|
|
711
|
+
elif ui.thinking:
|
|
712
|
+
sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
|
|
713
|
+
mic = f'{ORANGE}{sp} thinking...{RST}'
|
|
714
|
+
elif ui.listening:
|
|
715
|
+
mic = f'{TURQ}\u25cf listening{RST}'
|
|
716
|
+
|
|
717
|
+
audio_st = '\U0001f3a4' if ui.listening else ('\U0001f507' if not AUDIO_AVAILABLE else '\u23f8')
|
|
718
|
+
right = f'{npc_name} | {audio_st} | {model or "?"}@{provider or "?"}'
|
|
719
|
+
pad = w - 12 - len(right) - 20
|
|
720
|
+
header = f'{PURPLE}YAP{RST} {tabs}{" " * max(0, pad)}{mic} {DIM}{right}{RST}'
|
|
721
|
+
buf.append(f'\033[1;1H{REV} {header[:w-2].ljust(w-2)} {RST}')
|
|
722
|
+
|
|
723
|
+
if ui.tab == 0:
|
|
724
|
+
render_chat(buf, w, h)
|
|
725
|
+
elif ui.tab == 1:
|
|
726
|
+
render_settings(buf, w, h)
|
|
727
|
+
|
|
728
|
+
# Draw modal on top if open
|
|
729
|
+
if ui.modal_open:
|
|
730
|
+
render_modal(buf, w, h)
|
|
731
|
+
|
|
732
|
+
sys.stdout.write(''.join(buf))
|
|
733
|
+
sys.stdout.flush()
|
|
734
|
+
|
|
735
|
+
def render_chat(buf, w, h):
|
|
736
|
+
input_h = 3
|
|
737
|
+
chat_h = h - 2 - input_h
|
|
738
|
+
|
|
739
|
+
all_lines = []
|
|
740
|
+
_asst_pw = len(npc_name) + 2 # "name: "
|
|
741
|
+
_cont_pw = _asst_pw # continuation indent matches
|
|
742
|
+
for role, text in ui.chat_log:
|
|
743
|
+
if role == 'user':
|
|
744
|
+
tw = w - 6
|
|
745
|
+
wrapped = wrap_text(text, tw)
|
|
746
|
+
for i, l in enumerate(wrapped):
|
|
747
|
+
prefix = f'{BOLD}you:{RST} ' if i == 0 else ' '
|
|
748
|
+
all_lines.append(f'{prefix}{l}')
|
|
749
|
+
elif role == 'assistant':
|
|
750
|
+
tw = w - _asst_pw - 1
|
|
751
|
+
wrapped = wrap_text(text, tw)
|
|
752
|
+
pad = ' ' * _asst_pw
|
|
753
|
+
for i, l in enumerate(wrapped):
|
|
754
|
+
prefix = f'{PURPLE}{BOLD}{npc_name}:{RST} ' if i == 0 else pad
|
|
755
|
+
all_lines.append(f'{prefix}{l}')
|
|
756
|
+
elif role == 'info':
|
|
757
|
+
tw = w - 5
|
|
758
|
+
wrapped = wrap_text(text, tw)
|
|
759
|
+
for i, l in enumerate(wrapped):
|
|
760
|
+
prefix = f' {TURQ}\u2139 ' if i == 0 else ' '
|
|
761
|
+
all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
|
|
762
|
+
elif role == 'error':
|
|
763
|
+
tw = w - 5
|
|
764
|
+
wrapped = wrap_text(text, tw)
|
|
765
|
+
for i, l in enumerate(wrapped):
|
|
766
|
+
prefix = f' {RED}\u2717 ' if i == 0 else ' '
|
|
767
|
+
all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
|
|
768
|
+
|
|
769
|
+
if ui.recording:
|
|
770
|
+
secs = ui.rec_seconds
|
|
771
|
+
all_lines.append(f' {RED}\U0001f399 Recording... {secs:.1f}s{RST}')
|
|
772
|
+
elif ui.transcribing:
|
|
773
|
+
sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
|
|
774
|
+
all_lines.append(f' {ORANGE}{sp} Transcribing...{RST}')
|
|
775
|
+
elif ui.thinking:
|
|
776
|
+
sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
|
|
777
|
+
all_lines.append(f' {ORANGE}{sp} thinking...{RST}')
|
|
778
|
+
elif ui.speaking:
|
|
779
|
+
all_lines.append(f' {GREEN}\U0001f50a Speaking...{RST}')
|
|
780
|
+
|
|
781
|
+
# Scrolling
|
|
782
|
+
if ui.chat_scroll == -1:
|
|
783
|
+
scroll = max(0, len(all_lines) - chat_h)
|
|
784
|
+
else:
|
|
785
|
+
scroll = ui.chat_scroll
|
|
786
|
+
|
|
787
|
+
for i in range(chat_h):
|
|
788
|
+
y = 2 + i
|
|
789
|
+
li = scroll + i
|
|
790
|
+
buf.append(f'\033[{y};1H\033[K')
|
|
791
|
+
if li < len(all_lines):
|
|
792
|
+
buf.append(all_lines[li])
|
|
793
|
+
|
|
794
|
+
# Input area
|
|
795
|
+
div_y = 2 + chat_h
|
|
796
|
+
buf.append(f'\033[{div_y};1H\033[K{DIM}{"\u2500" * w}{RST}')
|
|
797
|
+
input_y = div_y + 1
|
|
798
|
+
visible = ui.input_buf[-(w-4):] if len(ui.input_buf) > w - 4 else ui.input_buf
|
|
799
|
+
buf.append(f'\033[{input_y};1H\033[K {BOLD}>{RST} {visible}\033[?25h')
|
|
800
|
+
|
|
801
|
+
# Status bar
|
|
802
|
+
if AUDIO_AVAILABLE:
|
|
803
|
+
ltog = 'Ctrl+L:Pause' if ui.listening else 'Ctrl+L:Listen'
|
|
804
|
+
hints = f'Enter:Send {ltog} Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
|
|
805
|
+
else:
|
|
806
|
+
hints = 'Enter:Send Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
|
|
807
|
+
buf.append(f'\033[{h};1H\033[K{REV} {hints[:w-2].ljust(w-2)} {RST}')
|
|
808
|
+
|
|
809
|
+
def render_settings(buf, w, h):
|
|
810
|
+
settings = [
|
|
811
|
+
('tts_enabled', 'TTS Enabled', 'On' if ui.tts_enabled else 'Off'),
|
|
812
|
+
('auto_speak', 'Auto-Speak', 'On' if ui.auto_speak else 'Off'),
|
|
813
|
+
('listening', 'Auto-Listen', 'On' if ui.listening else 'Off'),
|
|
814
|
+
('silence_timeout', 'Silence Timeout', f'{ui.silence_timeout}s'),
|
|
815
|
+
('vad_threshold', 'VAD Sensitivity', f'{ui.vad_threshold:.1f}'),
|
|
816
|
+
]
|
|
817
|
+
|
|
818
|
+
buf.append(f'\033[3;3H{BOLD}Voice Settings{RST}')
|
|
819
|
+
buf.append(f'\033[4;3H{DIM}{"\u2500" * (w - 6)}{RST}')
|
|
820
|
+
|
|
821
|
+
y = 6
|
|
822
|
+
for i, (key, label, val) in enumerate(settings):
|
|
823
|
+
if ui.editing and ui.edit_key == key:
|
|
824
|
+
buf.append(f'\033[{y};3H{ORANGE}{label}:{RST} {REV} {ui.edit_buf}_ {RST}')
|
|
825
|
+
elif i == ui.set_sel:
|
|
826
|
+
buf.append(f'\033[{y};3H{REV} {label}: {val} {RST}')
|
|
827
|
+
else:
|
|
828
|
+
buf.append(f'\033[{y};3H {BOLD}{label}:{RST} {val}')
|
|
829
|
+
y += 2
|
|
830
|
+
|
|
831
|
+
y += 1
|
|
832
|
+
buf.append(f'\033[{y};3H{DIM}TTS Engine: {tts_model_name} Voice: {voice_name or "default"}{RST}')
|
|
833
|
+
y += 1
|
|
834
|
+
buf.append(f'\033[{y};3H{DIM}LLM: {model or "?"}@{provider or "?"}{RST}')
|
|
835
|
+
y += 1
|
|
836
|
+
buf.append(f'\033[{y};3H{DIM}Audio: {"Available" if AUDIO_AVAILABLE else "Not available"}{RST}')
|
|
837
|
+
y += 1
|
|
838
|
+
if loaded_chunks:
|
|
839
|
+
buf.append(f'\033[{y};3H{DIM}Files loaded: {len(loaded_chunks)}{RST}')
|
|
840
|
+
y += 1
|
|
841
|
+
buf.append(f'\033[{y};3H{DIM}Whisper: {"Loaded" if whisper_model else "Not loaded"}{RST}')
|
|
842
|
+
|
|
843
|
+
for cy in range(y + 1, h - 1):
|
|
844
|
+
buf.append(f'\033[{cy};1H\033[K')
|
|
845
|
+
|
|
846
|
+
if ui.editing:
|
|
847
|
+
buf.append(f'\033[{h};1H\033[K{REV} Enter:Save Esc:Cancel {RST}')
|
|
848
|
+
else:
|
|
849
|
+
buf.append(f'\033[{h};1H\033[K{REV} j/k:Navigate Space:Toggle e:Edit Ctrl+S:Quick Switch Tab:Chat Ctrl+Q:Quit {RST}')
|
|
850
|
+
|
|
851
|
+
# ================================================================
|
|
852
|
+
# Modal rendering and handling
|
|
853
|
+
# ================================================================
|
|
854
|
+
def render_modal(buf, w, h):
|
|
855
|
+
box_w = min(48, w - 4)
|
|
856
|
+
box_h = 16
|
|
857
|
+
sx = max(1, (w - box_w) // 2)
|
|
858
|
+
sy = max(1, (h - box_h) // 2)
|
|
859
|
+
|
|
860
|
+
# Clear box area
|
|
861
|
+
for y in range(sy, sy + box_h):
|
|
862
|
+
buf.append(f'\033[{y};{sx}H{" " * box_w}')
|
|
863
|
+
|
|
864
|
+
# Border
|
|
865
|
+
buf.append(f'\033[{sy};{sx}H{PURPLE}\u250c{"\u2500" * (box_w - 2)}\u2510{RST}')
|
|
866
|
+
for y in range(sy + 1, sy + box_h - 1):
|
|
867
|
+
buf.append(f'\033[{y};{sx}H{PURPLE}\u2502{RST}{" " * (box_w - 2)}{PURPLE}\u2502{RST}')
|
|
868
|
+
buf.append(f'\033[{sy + box_h - 1};{sx}H{PURPLE}\u2514{"\u2500" * (box_w - 2)}\u2518{RST}')
|
|
869
|
+
|
|
870
|
+
# Title
|
|
871
|
+
title = " Quick Settings "
|
|
872
|
+
tp = sx + (box_w - len(title)) // 2
|
|
873
|
+
buf.append(f'\033[{sy};{tp}H{BOLD}{PURPLE}{title}{RST}')
|
|
874
|
+
|
|
875
|
+
lpad = sx + 3
|
|
876
|
+
y = sy + 2
|
|
877
|
+
|
|
878
|
+
modal_fields = [
|
|
879
|
+
('LLM Model', _all_models, ui.modal_model_idx),
|
|
880
|
+
('Provider', _all_providers, ui.modal_provider_idx),
|
|
881
|
+
('TTS Engine', _all_engines, ui.modal_engine_idx),
|
|
882
|
+
('Voice', _voices_for_engine(_all_engines[ui.modal_engine_idx]), ui.modal_voice_idx),
|
|
883
|
+
]
|
|
884
|
+
|
|
885
|
+
for i, (label, opts, idx) in enumerate(modal_fields):
|
|
886
|
+
val = opts[idx] if idx < len(opts) else '?'
|
|
887
|
+
if i == ui.modal_sel:
|
|
888
|
+
al = f'{TURQ}\u25c4{RST}'
|
|
889
|
+
ar = f'{TURQ}\u25ba{RST}'
|
|
890
|
+
buf.append(f'\033[{y};{lpad}H{REV} {label}: {RST} {al} {BOLD}{val}{RST} {ar}')
|
|
891
|
+
else:
|
|
892
|
+
buf.append(f'\033[{y};{lpad}H {DIM}{label}:{RST} {val}')
|
|
893
|
+
y += 2
|
|
894
|
+
|
|
895
|
+
# Save checkbox
|
|
896
|
+
ck = f'{GREEN}[x]{RST}' if ui.modal_save else '[ ]'
|
|
897
|
+
if ui.modal_sel == 4:
|
|
898
|
+
buf.append(f'\033[{y};{lpad}H{REV} {ck} Save as default {RST}')
|
|
899
|
+
else:
|
|
900
|
+
buf.append(f'\033[{y};{lpad}H {ck} Save as default')
|
|
901
|
+
y += 2
|
|
902
|
+
|
|
903
|
+
# Hints
|
|
904
|
+
buf.append(f'\033[{y};{lpad}H{DIM}\u2191\u2193:Nav \u2190\u2192:Change Spc:Toggle Enter:Apply Esc:Cancel{RST}')
|
|
905
|
+
|
|
906
|
+
def open_modal():
|
|
907
|
+
ui.modal_open = True
|
|
908
|
+
ui.modal_sel = 0
|
|
909
|
+
ui.modal_save = False
|
|
910
|
+
# Sync indices to current values
|
|
911
|
+
if tts_model_name in _all_engines:
|
|
912
|
+
ui.modal_engine_idx = _all_engines.index(tts_model_name)
|
|
913
|
+
if model and model in _all_models:
|
|
914
|
+
ui.modal_model_idx = _all_models.index(model)
|
|
915
|
+
if provider and provider in _all_providers:
|
|
916
|
+
ui.modal_provider_idx = _all_providers.index(provider)
|
|
917
|
+
cur_v = _voices_for_engine(_all_engines[ui.modal_engine_idx])
|
|
918
|
+
if voice_name in cur_v:
|
|
919
|
+
ui.modal_voice_idx = cur_v.index(voice_name)
|
|
920
|
+
else:
|
|
921
|
+
ui.modal_voice_idx = 0
|
|
922
|
+
|
|
923
|
+
def apply_modal():
|
|
924
|
+
nonlocal tts_model_name, voice_name, model, provider
|
|
925
|
+
model = _all_models[ui.modal_model_idx] if ui.modal_model_idx < len(_all_models) else model
|
|
926
|
+
provider = _all_providers[ui.modal_provider_idx] if ui.modal_provider_idx < len(_all_providers) else provider
|
|
927
|
+
tts_model_name = _all_engines[ui.modal_engine_idx] if ui.modal_engine_idx < len(_all_engines) else tts_model_name
|
|
928
|
+
cur_v = _voices_for_engine(tts_model_name)
|
|
929
|
+
voice_name = cur_v[ui.modal_voice_idx] if ui.modal_voice_idx < len(cur_v) else voice_name
|
|
930
|
+
|
|
931
|
+
ui.chat_log.append(('info', f'Settings: {model}@{provider}, TTS: {tts_model_name}/{voice_name}'))
|
|
932
|
+
|
|
933
|
+
if ui.modal_save:
|
|
934
|
+
from npcsh.config import set_npcsh_config_value
|
|
935
|
+
set_npcsh_config_value("NPCSH_TTS_ENGINE", tts_model_name)
|
|
936
|
+
set_npcsh_config_value("NPCSH_TTS_VOICE", voice_name)
|
|
937
|
+
set_npcsh_config_value("NPCSH_CHAT_MODEL", model)
|
|
938
|
+
set_npcsh_config_value("NPCSH_CHAT_PROVIDER", provider)
|
|
939
|
+
ui.chat_log.append(('info', 'Saved as defaults.'))
|
|
940
|
+
|
|
941
|
+
ui.modal_open = False
|
|
942
|
+
|
|
943
|
+
def handle_modal_key(c, fd):
|
|
944
|
+
if c == '\x1b': # Esc
|
|
945
|
+
if _sel.select([fd], [], [], 0.05)[0]:
|
|
946
|
+
c2 = os.read(fd, 1).decode('latin-1')
|
|
947
|
+
if c2 == '[':
|
|
948
|
+
c3 = os.read(fd, 1).decode('latin-1')
|
|
949
|
+
if c3 == 'A': # Up
|
|
950
|
+
ui.modal_sel = max(0, ui.modal_sel - 1)
|
|
951
|
+
elif c3 == 'B': # Down
|
|
952
|
+
ui.modal_sel = min(4, ui.modal_sel + 1)
|
|
953
|
+
elif c3 == 'D': # Left
|
|
954
|
+
_modal_cycle(-1)
|
|
955
|
+
elif c3 == 'C': # Right
|
|
956
|
+
_modal_cycle(1)
|
|
957
|
+
return True
|
|
958
|
+
# bare Esc = close
|
|
959
|
+
ui.modal_open = False
|
|
960
|
+
return True
|
|
961
|
+
|
|
962
|
+
if c == 'k':
|
|
963
|
+
ui.modal_sel = max(0, ui.modal_sel - 1)
|
|
964
|
+
elif c == 'j':
|
|
965
|
+
ui.modal_sel = min(4, ui.modal_sel + 1)
|
|
966
|
+
elif c == 'h':
|
|
967
|
+
_modal_cycle(-1)
|
|
968
|
+
elif c == 'l':
|
|
969
|
+
_modal_cycle(1)
|
|
970
|
+
elif c == ' ':
|
|
971
|
+
if ui.modal_sel == 4:
|
|
972
|
+
ui.modal_save = not ui.modal_save
|
|
973
|
+
elif c in ('\r', '\n'):
|
|
974
|
+
apply_modal()
|
|
975
|
+
elif c == '\x11': # Ctrl+Q in modal just closes it
|
|
976
|
+
ui.modal_open = False
|
|
977
|
+
return True
|
|
978
|
+
|
|
979
|
+
def _modal_cycle(direction):
|
|
980
|
+
if ui.modal_sel == 0:
|
|
981
|
+
ui.modal_model_idx = (ui.modal_model_idx + direction) % len(_all_models)
|
|
982
|
+
elif ui.modal_sel == 1:
|
|
983
|
+
ui.modal_provider_idx = (ui.modal_provider_idx + direction) % len(_all_providers)
|
|
984
|
+
elif ui.modal_sel == 2:
|
|
985
|
+
ui.modal_engine_idx = (ui.modal_engine_idx + direction) % len(_all_engines)
|
|
986
|
+
# Reset voice index when engine changes
|
|
987
|
+
ui.modal_voice_idx = 0
|
|
988
|
+
elif ui.modal_sel == 3:
|
|
989
|
+
cur_v = _voices_for_engine(_all_engines[ui.modal_engine_idx])
|
|
990
|
+
if cur_v:
|
|
991
|
+
ui.modal_voice_idx = (ui.modal_voice_idx + direction) % len(cur_v)
|
|
992
|
+
|
|
993
|
+
# ================================================================
|
|
994
|
+
# Input handling
|
|
995
|
+
# ================================================================
|
|
996
|
+
def handle_key(c, fd):
|
|
997
|
+
# Modal intercepts all keys
|
|
998
|
+
if ui.modal_open:
|
|
999
|
+
return handle_modal_key(c, fd)
|
|
1000
|
+
|
|
1001
|
+
if c == '\t':
|
|
1002
|
+
if not ui.editing:
|
|
1003
|
+
ui.tab = (ui.tab + 1) % 2
|
|
1004
|
+
return True
|
|
1005
|
+
if c == '\x11': # Ctrl+Q
|
|
1006
|
+
return False
|
|
1007
|
+
if c == '\x03': # Ctrl+C
|
|
1008
|
+
return True
|
|
1009
|
+
if c == '\x13': # Ctrl+S = open settings modal
|
|
1010
|
+
open_modal()
|
|
1011
|
+
return True
|
|
1012
|
+
|
|
1013
|
+
# Escape sequences
|
|
1014
|
+
if c == '\x1b':
|
|
1015
|
+
if _sel.select([fd], [], [], 0.05)[0]:
|
|
1016
|
+
c2 = os.read(fd, 1).decode('latin-1')
|
|
1017
|
+
if c2 == '[':
|
|
1018
|
+
c3 = os.read(fd, 1).decode('latin-1')
|
|
1019
|
+
if c3 == 'A': # Up
|
|
1020
|
+
if ui.tab == 0: _chat_scroll_up()
|
|
1021
|
+
elif ui.tab == 1 and not ui.editing and ui.set_sel > 0: ui.set_sel -= 1
|
|
1022
|
+
elif c3 == 'B': # Down
|
|
1023
|
+
if ui.tab == 0: _chat_scroll_down()
|
|
1024
|
+
elif ui.tab == 1 and not ui.editing and ui.set_sel < 4: ui.set_sel += 1
|
|
1025
|
+
elif c3 == '5': # PgUp
|
|
1026
|
+
os.read(fd, 1)
|
|
1027
|
+
if ui.tab == 0: _chat_page_up()
|
|
1028
|
+
elif c3 == '6': # PgDn
|
|
1029
|
+
os.read(fd, 1)
|
|
1030
|
+
if ui.tab == 0: _chat_page_down()
|
|
1031
|
+
elif c2 == 'O':
|
|
1032
|
+
c3 = os.read(fd, 1).decode('latin-1')
|
|
1033
|
+
if c3 == 'P': ui.tab = 0 # F1
|
|
1034
|
+
elif c3 == 'Q': ui.tab = 1 # F2
|
|
1035
|
+
else:
|
|
1036
|
+
# bare Esc
|
|
1037
|
+
if ui.tab == 1 and ui.editing:
|
|
1038
|
+
ui.editing = False
|
|
1039
|
+
ui.edit_buf = ""
|
|
1040
|
+
else:
|
|
1041
|
+
if ui.tab == 1 and ui.editing:
|
|
1042
|
+
ui.editing = False
|
|
1043
|
+
ui.edit_buf = ""
|
|
1044
|
+
return True
|
|
1045
|
+
|
|
1046
|
+
if ui.tab == 0:
|
|
1047
|
+
return handle_chat(c, fd)
|
|
1048
|
+
elif ui.tab == 1:
|
|
1049
|
+
return handle_settings(c, fd)
|
|
1050
|
+
return True
|
|
1051
|
+
|
|
1052
|
+
def _chat_scroll_up():
|
|
1053
|
+
_, h = sz()
|
|
1054
|
+
chat_h = h - 5
|
|
1055
|
+
if ui.chat_scroll == -1:
|
|
1056
|
+
ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - 1)
|
|
1057
|
+
ui.chat_scroll = max(0, ui.chat_scroll - 1)
|
|
1058
|
+
|
|
1059
|
+
def _chat_scroll_down():
|
|
1060
|
+
ui.chat_scroll = -1 if ui.chat_scroll == -1 else ui.chat_scroll + 1
|
|
1061
|
+
|
|
1062
|
+
def _chat_page_up():
|
|
1063
|
+
_, h = sz()
|
|
1064
|
+
chat_h = h - 5
|
|
1065
|
+
if ui.chat_scroll == -1:
|
|
1066
|
+
ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - chat_h)
|
|
1067
|
+
else:
|
|
1068
|
+
ui.chat_scroll = max(0, ui.chat_scroll - chat_h)
|
|
1069
|
+
|
|
1070
|
+
def _chat_page_down():
|
|
1071
|
+
ui.chat_scroll = -1
|
|
1072
|
+
|
|
1073
|
+
def handle_chat(c, fd):
|
|
1074
|
+
# Ctrl+L = toggle listening
|
|
1075
|
+
if c == '\x0c': # Ctrl+L
|
|
1076
|
+
if AUDIO_AVAILABLE:
|
|
1077
|
+
ui.listening = not ui.listening
|
|
1078
|
+
st = 'on' if ui.listening else 'off'
|
|
1079
|
+
ui.chat_log.append(('info', f'Listening {st}.'))
|
|
1080
|
+
return True
|
|
1081
|
+
|
|
1082
|
+
if ui.recording or ui.transcribing:
|
|
1083
|
+
return True
|
|
1084
|
+
|
|
1085
|
+
if ui.thinking:
|
|
1086
|
+
return True
|
|
1087
|
+
|
|
1088
|
+
if c in ('\r', '\n'):
|
|
1089
|
+
text = ui.input_buf.strip()
|
|
1090
|
+
ui.input_buf = ""
|
|
1091
|
+
if text:
|
|
1092
|
+
send_message(text)
|
|
1093
|
+
return True
|
|
1094
|
+
|
|
1095
|
+
if c == '\x7f' or c == '\x08':
|
|
1096
|
+
ui.input_buf = ui.input_buf[:-1]
|
|
1097
|
+
return True
|
|
1098
|
+
|
|
1099
|
+
if c >= ' ' and c <= '~':
|
|
1100
|
+
ui.input_buf += c
|
|
1101
|
+
ui.chat_scroll = -1
|
|
1102
|
+
return True
|
|
1103
|
+
|
|
1104
|
+
return True
|
|
1105
|
+
|
|
1106
|
+
def handle_settings(c, fd):
|
|
1107
|
+
SETTINGS_KEYS = ['tts_enabled', 'auto_speak', 'listening', 'silence_timeout', 'vad_threshold']
|
|
1108
|
+
|
|
1109
|
+
if ui.editing:
|
|
1110
|
+
if c in ('\r', '\n'):
|
|
1111
|
+
val = ui.edit_buf.strip()
|
|
1112
|
+
if ui.edit_key == 'silence_timeout':
|
|
1113
|
+
try: ui.silence_timeout = max(0.3, min(10.0, float(val)))
|
|
1114
|
+
except: pass
|
|
1115
|
+
elif ui.edit_key == 'vad_threshold':
|
|
1116
|
+
try: ui.vad_threshold = max(0.1, min(0.9, float(val)))
|
|
1117
|
+
except: pass
|
|
1118
|
+
ui.editing = False
|
|
1119
|
+
ui.edit_buf = ""
|
|
1120
|
+
elif c == '\x7f' or c == '\x08':
|
|
1121
|
+
ui.edit_buf = ui.edit_buf[:-1]
|
|
1122
|
+
elif c >= ' ' and c <= '~':
|
|
1123
|
+
ui.edit_buf += c
|
|
1124
|
+
return True
|
|
1125
|
+
|
|
1126
|
+
if c == 'j' and ui.set_sel < len(SETTINGS_KEYS) - 1:
|
|
1127
|
+
ui.set_sel += 1
|
|
1128
|
+
elif c == 'k' and ui.set_sel > 0:
|
|
1129
|
+
ui.set_sel -= 1
|
|
1130
|
+
elif c == ' ':
|
|
1131
|
+
key = SETTINGS_KEYS[ui.set_sel]
|
|
1132
|
+
if key == 'tts_enabled':
|
|
1133
|
+
ui.tts_enabled = not ui.tts_enabled
|
|
1134
|
+
elif key == 'auto_speak':
|
|
1135
|
+
ui.auto_speak = not ui.auto_speak
|
|
1136
|
+
elif key == 'listening':
|
|
1137
|
+
ui.listening = not ui.listening
|
|
1138
|
+
st = 'on' if ui.listening else 'off'
|
|
1139
|
+
ui.chat_log.append(('info', f'Listening {st}.'))
|
|
1140
|
+
elif c == 'e':
|
|
1141
|
+
key = SETTINGS_KEYS[ui.set_sel]
|
|
1142
|
+
if key in ('silence_timeout', 'vad_threshold'):
|
|
1143
|
+
ui.editing = True
|
|
1144
|
+
ui.edit_key = key
|
|
1145
|
+
ui.edit_buf = str(ui.silence_timeout if key == 'silence_timeout' else ui.vad_threshold)
|
|
1146
|
+
return True
|
|
1147
|
+
|
|
1148
|
+
# ================================================================
|
|
1149
|
+
# Welcome
|
|
1150
|
+
# ================================================================
|
|
1151
|
+
ui.chat_log.append(('info', f'YAP voice chat. NPC: {npc_name}.'))
|
|
1152
|
+
ui.chat_log.append(('info', f'TTS: {tts_model_name}/{voice_name} LLM: {model or "?"}@{provider or "?"}'))
|
|
1153
|
+
if AUDIO_AVAILABLE:
|
|
1154
|
+
ui.chat_log.append(('info', 'Listening for speech. Just start talking, or type text.'))
|
|
1155
|
+
ui.chat_log.append(('info', 'Ctrl+L to pause/resume listening. Ctrl+S to change settings.'))
|
|
1156
|
+
else:
|
|
1157
|
+
ui.chat_log.append(('info', 'Audio not available. Text mode only. Ctrl+S to change settings.'))
|
|
1158
|
+
if loaded_chunks:
|
|
1159
|
+
ui.chat_log.append(('info', f'{len(loaded_chunks)} files loaded for context.'))
|
|
1160
|
+
|
|
1161
|
+
# Start VAD listener thread
|
|
1162
|
+
_listener_thread = None
|
|
1163
|
+
if AUDIO_AVAILABLE and vad_model is not None:
|
|
1164
|
+
_listener_thread = threading.Thread(target=vad_listener_loop, daemon=True)
|
|
1165
|
+
_listener_thread.start()
|
|
1166
|
+
|
|
1167
|
+
# ================================================================
|
|
1168
|
+
# Main loop
|
|
1169
|
+
# ================================================================
|
|
1170
|
+
fd = sys.stdin.fileno()
|
|
1171
|
+
old_settings = termios.tcgetattr(fd)
|
|
1172
|
+
try:
|
|
1173
|
+
tty.setcbreak(fd)
|
|
1174
|
+
sys.stdout.write('\033[?25l\033[2J')
|
|
1175
|
+
running = True
|
|
1176
|
+
while running:
|
|
1177
|
+
render()
|
|
1178
|
+
if ui.thinking or ui.recording or ui.transcribing or ui.speaking or ui.listening:
|
|
1179
|
+
ui.spinner_frame += 1
|
|
1180
|
+
if _sel.select([fd], [], [], 0.15)[0]:
|
|
1181
|
+
c = os.read(fd, 1).decode('latin-1')
|
|
1182
|
+
running = handle_key(c, fd)
|
|
1183
|
+
finally:
|
|
1184
|
+
ui.listen_stop = True
|
|
1185
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
1186
|
+
sys.stdout.write('\033[?25h\033[2J\033[H')
|
|
1187
|
+
sys.stdout.flush()
|
|
1188
|
+
|
|
1189
|
+
context['output'] = "Exited yap mode."
|
|
1190
|
+
context['messages'] = messages
|