J.A.R.V.I.S-py/main.py

1646 lines
62 KiB
Python
Raw Normal View History

2023-04-16 16:59:18 +05:00
import datetime
import json
import os
2023-04-16 16:59:18 +05:00
import queue
import random
2023-04-16 16:59:18 +05:00
import struct
import subprocess
import sys
import time
import webbrowser
2023-04-16 16:59:18 +05:00
from ctypes import POINTER, cast
import numpy as np
2023-04-16 16:59:18 +05:00
import openai
from openai import OpenAI
import simpleaudio as sa
import vosk
import webrtcvad
2023-04-16 16:59:18 +05:00
import yaml
from comtypes import CLSCTX_ALL
from fuzzywuzzy import fuzz
2023-04-16 16:59:18 +05:00
from pvrecorder import PvRecorder
from pycaw.pycaw import (
AudioUtilities,
IAudioEndpointVolume
)
2023-04-16 16:59:18 +05:00
from rich import print
2023-04-16 16:59:18 +05:00
import config
import tts
from intent import IntentClassifier
2023-04-16 16:59:18 +05:00
# some consts
CDIR = os.getcwd()
2023-04-16 16:59:18 +05:00
VA_CMD_LIST = yaml.safe_load(
open('commands.yaml', 'rt', encoding='utf8'),
)
system_message = {"role": "system", "content": (
"Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), ИИ-ассистент Тони Старка из киновселенной Marvel "
"(до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). "
"Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». "
"Отвечай по-русски, кратко (1-3 предложения), как живой человек. "
"Излишней цензуры не нужно — мат разрешён, если уместен."
)}
2023-04-17 16:02:33 +03:00
message_log = [system_message]
2023-04-16 17:15:36 +05:00
client = OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL)
model = vosk.Model("model_small")
samplerate = 16000
device = config.MICROPHONE_INDEX
# wake-word phase uses a grammar-constrained recognizer so only WAKE_WORDS
# (plus [unk] filler) can match — command phase needs full vocab, hence two.
wake_grammar = json.dumps(list(config.WAKE_WORDS) + ["[unk]"], ensure_ascii=False)
wake_rec = vosk.KaldiRecognizer(model, samplerate, wake_grammar)
kaldi_rec = vosk.KaldiRecognizer(model, samplerate)
q = queue.Queue()
VAD_FRAME_MS = 30
VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2
vad = webrtcvad.Vad(config.VAD_AGGRESSIVENESS)
intent_classifier = IntentClassifier()
intent_classifier.prime({c: v['phrases'] for c, v in VA_CMD_LIST.items()})
if config.DENOISE_ENABLED:
import noisereduce as nr
else:
nr = None
def denoise_pcm(raw: bytes) -> bytes:
if not raw or nr is None:
return raw
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
reduced = nr.reduce_noise(
y=samples,
sr=samplerate,
prop_decrease=config.DENOISE_PROP,
stationary=config.DENOISE_STATIONARY,
)
clipped = np.clip(reduced * 32768.0, -32768, 32767).astype(np.int16)
return clipped.tobytes()
2023-04-16 17:15:36 +05:00
def gpt_answer():
global message_log
2023-04-17 16:02:33 +03:00
try:
response = client.chat.completions.create(
model=config.GROQ_MODEL,
2023-04-17 16:02:33 +03:00
messages=message_log,
max_tokens=256,
2023-04-17 16:02:33 +03:00
temperature=0.7,
top_p=1,
stop=None,
2023-04-17 16:02:33 +03:00
)
except openai.BadRequestError as ex:
code = getattr(ex, "code", None) or ""
message = str(ex)
if code == "context_length_exceeded" or "context_length_exceeded" in message:
2023-04-17 16:02:33 +03:00
message_log = [system_message, message_log[-1]]
return gpt_answer()
return "Запрос отклонён моделью."
except openai.RateLimitError:
return "Лимит запросов исчерпан."
except openai.APIConnectionError:
return "Не могу связаться с сервером."
except openai.APIError:
return "Groq токен не рабочий."
2023-04-16 17:15:36 +05:00
return response.choices[0].message.content
# play(f'{CDIR}\\sound\\ok{random.choice([1, 2, 3, 4])}.wav')
def play(phrase, wait_done=True):
global recorder
filename = f"{CDIR}\\sound\\"
2023-04-16 16:59:18 +05:00
if phrase == "greet": # for py 3.8
filename += f"greet{random.choice([1, 2, 3])}.wav"
elif phrase == "ok":
filename += f"ok{random.choice([1, 2, 3])}.wav"
elif phrase == "not_found":
filename += "not_found.wav"
elif phrase == "thanks":
filename += "thanks.wav"
elif phrase == "run":
filename += "run.wav"
elif phrase == "stupid":
filename += "stupid.wav"
elif phrase == "ready":
filename += "ready.wav"
elif phrase == "off":
filename += "off.wav"
if wait_done:
recorder.stop()
wave_obj = sa.WaveObject.from_wave_file(filename)
2023-04-16 16:59:18 +05:00
play_obj = wave_obj.play()
if wait_done:
2023-04-16 16:59:18 +05:00
play_obj.wait_done()
# time.sleep((len(wave_obj.audio_data) / wave_obj.sample_rate) + 0.5)
# print("END")
2023-04-16 16:59:18 +05:00
# time.sleep(0.5)
recorder.start()
def q_callback(indata, frames, time, status):
if status:
print(status, file=sys.stderr)
q.put(bytes(indata))
def va_respond(voice: str):
2023-04-16 17:15:36 +05:00
global recorder, message_log, first_request
print(f"Распознано: {voice}")
cmd = recognize_cmd(filter_cmd(voice))
print(cmd)
min_percent = int(round(config.INTENT_SIMILARITY_THRESHOLD * 100))
if len(cmd['cmd'].strip()) <= 0:
return False
elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys():
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
auto_ok = (
config.LLM_AUTO_FALLBACK
and config.GROQ_TOKEN
and len(voice.strip()) >= config.LLM_AUTO_FALLBACK_MIN_CHARS
)
if auto_ok:
2023-04-17 16:02:33 +03:00
message_log.append({"role": "user", "content": voice})
2023-04-16 17:15:36 +05:00
response = gpt_answer()
message_log.append({"role": "assistant", "content": response})
recorder.stop()
2023-04-16 17:15:36 +05:00
tts.va_speak(response)
time.sleep(0.5)
recorder.start()
return False
else:
play("not_found")
time.sleep(1)
return False
else:
execute_cmd(cmd['cmd'], voice)
return True
def filter_cmd(raw_voice: str):
cmd = raw_voice
for x in config.VA_ALIAS:
cmd = cmd.replace(x, "").strip()
for x in config.VA_TBR:
cmd = cmd.replace(x, "").strip()
return cmd
def recognize_cmd(cmd: str):
candidates = {c: v['phrases'] for c, v in VA_CMD_LIST.items()}
res = intent_classifier.match(cmd, candidates)
return {'cmd': res['cmd'], 'percent': int(round(res['score'] * 100))}
def _set_mute(state: int):
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
volume = cast(interface, POINTER(IAudioEndpointVolume))
volume.SetMute(state, None)
def _shutdown():
try:
recorder.stop()
recorder.delete()
finally:
sys.exit(0)
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
def _speak(text: str):
recorder.stop()
tts.va_speak(text)
time.sleep(0.3)
recorder.start()
def _set_clipboard(text: str):
try:
subprocess.run(
['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'],
input=text, text=True, encoding='utf-8', timeout=10, check=False,
)
except Exception as e:
print(f"clipboard set failed: {e}")
_CODEGEN_TRIGGERS = (
'сгенерируй скрипт', 'напиши скрипт', 'сделай скрипт',
'сгенерируй код', 'набросай код', 'напиши код',
)
def _strip_trigger(voice: str, triggers) -> str:
lower = voice.lower()
for t in triggers:
if lower.startswith(t):
return voice[len(t):].strip()
return voice.strip()
def do_codegen(action, voice: str):
query = _strip_trigger(voice, _CODEGEN_TRIGGERS)
if not query:
_speak("Что писать, сэр?")
return
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
sys_prompt = (
"Ты Senior Engineer. На запрос пользователя верни ТОЛЬКО исходный код, "
"без обрамления тройными кавычками и без объяснений. Если язык не задан "
"— выбери самый подходящий (по умолчанию Python). Не пиши комментариев-болтовни."
)
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": query},
],
max_tokens=1024,
temperature=0.2,
)
code = resp.choices[0].message.content.strip()
except Exception as e:
print(f"codegen api failed: {e}")
_speak("Не получилось сгенерировать код.")
return
import re
code = re.sub(r'^```[\w+\-]*\n?', '', code)
code = re.sub(r'\n?```\s*$', '', code).strip()
_set_clipboard(code)
lines = code.count('\n') + 1 if code else 0
_speak(f"Код в буфере, сэр. Строк: {lines}.")
def _find_tesseract():
candidates = (
r'C:\Program Files\Tesseract-OCR\tesseract.exe',
r'C:\Program Files (x86)\Tesseract-OCR\tesseract.exe',
)
on_path = subprocess.run(['where', 'tesseract.exe'], capture_output=True, text=True)
if on_path.returncode == 0 and on_path.stdout.strip():
return 'tesseract.exe'
for c in candidates:
if os.path.isfile(c):
return c
return None
def do_ocr(action, voice: str):
import tempfile
tess = _find_tesseract()
if not tess:
_speak("Tesseract не установлен.")
return
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
png = tmp.name
capture_ps = (
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; "
"$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; "
"$bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; "
"$g=[Drawing.Graphics]::FromImage($bmp); "
"$g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); "
f"$bmp.Save('{png.replace(chr(92), chr(92)+chr(92))}'); "
"$g.Dispose(); $bmp.Dispose()"
)
cap = subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', capture_ps],
capture_output=True, text=True, timeout=15,
)
if cap.returncode != 0 or not os.path.isfile(png):
try: os.unlink(png)
except OSError: pass
_speak("Не смог снять экран.")
return
try:
ocr = subprocess.run(
[tess, png, '-', '-l', 'rus+eng'],
capture_output=True, text=True, encoding='utf-8', timeout=20,
)
text = (ocr.stdout or '').strip()
finally:
try: os.unlink(png)
except OSError: pass
if not text:
_speak("Текста на экране не нашёл.")
return
_set_clipboard(text)
preview = ' '.join(text.split())[:120]
_speak(f"Текст в буфере. Начало: {preview}")
_FILE_SEARCH_TRIGGERS = (
'найди документ', 'найди файл', 'поиск файла', 'ищи файл', 'где файл',
)
def do_file_search(action, voice: str):
query = _strip_trigger(voice, _FILE_SEARCH_TRIGGERS)
if not query:
_speak("Что искать?")
return
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
roots = [
os.path.join(user_profile, 'Desktop'),
os.path.join(user_profile, 'Documents'),
os.path.join(user_profile, 'Downloads'),
]
escaped = query.replace("'", "''")
roots_arg = ",".join(f"'{r}'" for r in roots)
ps = (
f"$ErrorActionPreference='SilentlyContinue'; $r=@({roots_arg}); $q='*{escaped}*'; "
"$hits=Get-ChildItem -Path $r -Filter $q -Recurse -Depth 3 -File | Select-Object -First 5; "
"foreach($h in $hits){ Write-Output $h.FullName }"
)
res = subprocess.run(
['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=15,
)
lines = [l for l in (res.stdout or '').splitlines() if l.strip()]
if not lines:
_speak(f"Не нашёл «{query}».")
return
first = lines[0]
subprocess.Popen(['explorer.exe', '/select,', first])
_speak(f"Найдено {len(lines)}, открываю первое: {os.path.basename(first)}.")
feat: tier-1 PC tools parity — window mgmt, clipboard, notes, process killer, web search Mirrors the new rust packs from the same iteration. Total commands in commands.yaml is now 49 (was 33). yaml.safe_load + ast.parse both pass. Window management (8 commands) — uses the existing `keys` action with pyautogui hotkey strings: winleft+d (show_desktop), winleft+up/down (maximize/minimize), winleft+left/right (snap left/right), alt+f4 (close_window), winleft+shift+m (restore_all), winleft+tab (task_view). No new action type needed. Clipboard read aloud — new clipboard_read action. Shells to PowerShell Get-Clipboard, caps preview at 400 chars, speaks via the existing tts.va_speak (which already handles recorder.stop/start). Notes — new notes_add + notes_open actions. notes_add strips the trigger phrase, appends "[YYYY-MM-DD HH:MM] body\n" to %USERPROFILE%\Documents\jarvis-notes.txt, creates the dir if needed. notes_open shells "cmd /c start" so .txt opens in the user-default editor. Process killer — new process_kill action. Strips the trigger ("убей процесс"/"закрой программу"/etc.), looks up the remainder in the same alias map as the rust version (хром→chrome, телега→telegram, спотифай →spotify, едж/эдж/edge→msedge, дискорд→discord, стим→steam, обс→obs64, проводник/блокнот/калькулятор/диспетчер задач, vs code→code, etc.), appends .exe if needed, runs taskkill /F /IM, speaks success or "не нашёл процесс N". Web search — new websearch action with a `service` param. Bases: google → https://www.google.com/search?q= youtube → https://www.youtube.com/results?search_query= wiki → https://ru.wikipedia.org/wiki/Special:Search?search= yandex → https://yandex.ru/search/?text= Strips trigger, urllib.parse.quote-s the rest, opens via webbrowser.open (respects the user'\''s default browser; config.BROWSER_PATHS only kicks in for the older url-action commands). Portability: USERPROFILE used for notes path, no other hardcoded paths.
2026-05-15 11:27:19 +03:00
_NOTES_TRIGGERS = (
'запиши заметку', 'запиши идею', 'сделай заметку', 'запомни идею',
'запиши', 'запомни',
)
_PROCESS_KILL_TRIGGERS = (
'завершить процесс', 'выруби процесс', 'убей процесс', 'kill процесс',
'закрой программу',
)
_WEBSEARCH_TRIGGERS = (
'загугли', 'поиск в гугл', 'поищи в гугл', 'найди в гугл', 'ищи в гугл',
'найди на ютубе', 'поиск на ютубе', 'ищи на ютубе', 'ютуб поиск',
'найди в википедии', 'поиск в википедии', 'википедия', 'вики поиск',
'поищи в яндекс', 'найди в яндекс', 'яндекс поиск', 'пробей в яндекс',
)
_PROCESS_ALIASES = {
'хром': 'chrome', 'хрома': 'chrome',
'спотифай': 'spotify',
'едж': 'msedge', 'эдж': 'msedge', 'edge': 'msedge',
'файрфокс': 'firefox', 'фаерфокс': 'firefox',
'вскод': 'code', 'vs code': 'code',
'дискорд': 'discord',
'телега': 'telegram', 'телеграм': 'telegram',
'стим': 'steam',
'обс': 'obs64',
'проводник': 'explorer',
'блокнот': 'notepad',
'калькулятор': 'calculatorapp',
'диспетчер задач': 'taskmgr',
}
_WEBSEARCH_BASES = {
'google': 'https://www.google.com/search?q=',
'youtube': 'https://www.youtube.com/results?search_query=',
'wiki': 'https://ru.wikipedia.org/wiki/Special:Search?search=',
'yandex': 'https://yandex.ru/search/?text=',
}
def do_clipboard_read(action, voice: str):
res = subprocess.run(
['powershell', '-NoProfile', '-Command', 'Get-Clipboard'],
capture_output=True, text=True, encoding='utf-8', timeout=10,
)
text = (res.stdout or '').strip()
if not text:
_speak("Буфер пуст.")
return
to_speak = text[:400]
if len(text) > 400:
to_speak += " ... и так далее"
_speak(to_speak.replace('\r', ' ').replace('\n', ' '))
def do_notes_add(action, voice: str):
body = _strip_trigger(voice, _NOTES_TRIGGERS).lstrip(' ,.:').strip()
if not body:
_speak("Что записать, сэр?")
return
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
notes_path = os.path.join(user_profile, 'Documents', 'jarvis-notes.txt')
stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
line = f"[{stamp}] {body}\n"
try:
os.makedirs(os.path.dirname(notes_path), exist_ok=True)
with open(notes_path, 'a', encoding='utf-8') as f:
f.write(line)
except OSError as e:
print(f"notes append failed: {e}")
_speak("Не удалось записать.")
return
_speak(f"Записал: {body[:120]}")
def do_notes_open(action, voice: str):
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
notes_path = os.path.join(user_profile, 'Documents', 'jarvis-notes.txt')
if not os.path.isfile(notes_path):
_speak("Заметок пока нет.")
return
subprocess.Popen(['cmd', '/c', 'start', '', notes_path], shell=False)
_speak("Открываю заметки.")
def do_process_kill(action, voice: str):
name = _strip_trigger(voice, _PROCESS_KILL_TRIGGERS).strip().lower()
target = _PROCESS_ALIASES.get(name, name)
if not target:
_speak("Какой процесс?")
return
exe = target if target.endswith('.exe') else target + '.exe'
res = subprocess.run(
['taskkill', '/F', '/IM', exe],
capture_output=True, text=True, timeout=10,
)
if res.returncode == 0:
_speak(f"Закрыл {exe}.")
else:
_speak(f"Не нашёл процесс {exe}.")
def do_websearch(action, voice: str):
import urllib.parse
svc = action.get('service', 'google')
base = _WEBSEARCH_BASES.get(svc, _WEBSEARCH_BASES['google'])
query = _strip_trigger(voice, _WEBSEARCH_TRIGGERS).strip()
if not query:
_speak(f"Что искать в {svc}?")
return
url = base + urllib.parse.quote(query)
webbrowser.open(url)
_speak(f"Открываю {svc}: {query[:100]}")
feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total) Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
_TRANSLATE_TRIGGERS_WITH_LANG = (
'переведи на ', 'translate to ', 'переклади на ', 'how to say in ',
)
_TRANSLATE_GENERIC = ('переведи', 'translate', 'переклади', 'как по ', 'how to say')
_TARGET_LANGS = {
'английский': 'English', 'english': 'English',
'русский': 'Russian', 'russian': 'Russian',
'украинский': 'Ukrainian','ukrainian': 'Ukrainian',
'немецкий': 'German', 'german': 'German',
'французский':'French', 'french': 'French',
'испанский': 'Spanish', 'spanish': 'Spanish',
'итальянский':'Italian',
'китайский': 'Chinese', 'chinese': 'Chinese',
'японский': 'Japanese', 'japanese': 'Japanese',
'польский': 'Polish',
'турецкий': 'Turkish',
}
_SAPI_ISO = {'Russian': 'ru', 'Ukrainian': 'uk', 'English': 'en',
'German': 'de', 'French': 'fr', 'Spanish': 'es',
'Italian': 'it', 'Chinese': 'zh', 'Japanese': 'ja',
'Polish': 'pl', 'Turkish': 'tr'}
def _speak_in_lang(text: str, iso: str = 'ru'):
escaped = text.replace("'", "''").replace('\r', ' ').replace('\n', ' ')
ps = (
"Add-Type -AssemblyName System.Speech; "
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
f"$iso='{iso}'; "
"foreach ($v in $s.GetInstalledVoices()) { "
" if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq $iso) { "
" try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} "
" } "
"} "
f"$s.Speak('{escaped}')"
)
recorder.stop()
subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],
capture_output=True, timeout=20)
time.sleep(0.2)
recorder.start()
def do_translate(action, voice: str):
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
lower = voice.lower()
target = None
body = lower
for t in _TRANSLATE_TRIGGERS_WITH_LANG:
if lower.startswith(t):
rest = lower[len(t):].strip()
first = rest.split(' ', 1)[0] if rest else ''
if first in _TARGET_LANGS:
target = _TARGET_LANGS[first]
body = rest[len(first):].strip()
break
if target is None:
for t in _TRANSLATE_GENERIC:
if lower.startswith(t):
body = lower[len(t):].strip()
break
if not body:
_speak("Что переводить?")
return
target = target or 'English'
sys_prompt = (
f"You are a professional translator. Translate the user message into {target}. "
"Output ONLY the translation, no explanations, no quotes, no prefix. "
"Preserve idioms and tone."
)
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': body}],
max_tokens=512, temperature=0.3,
)
translation = resp.choices[0].message.content.strip()
except Exception as e:
print(f"translate api failed: {e}")
_speak("Ошибка перевода.")
return
_set_clipboard(translation)
_speak_in_lang(translation, _SAPI_ISO.get(target, 'en'))
def do_math_eval(action, voice: str):
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
triggers = ('сколько будет', 'сколько это', 'посчитай', 'вычисли', 'реши',
'how much is', 'calculate', 'compute', 'solve', 'what is',
'скільки буде', 'порахуй', 'обчисли')
query = _strip_trigger(voice, triggers)
if not query:
_speak("Что посчитать?")
return
sys_prompt = (
"Ты калькулятор. Решай арифметику, простые уравнения, конверсии единиц, проценты. "
"Отвечай ТОЛЬКО результатом числом или короткой строкой (для уравнений — значения корней). "
"Если запрос — не математика, ответь одним словом «нет»."
)
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': query}],
max_tokens=64, temperature=0.0,
)
answer = resp.choices[0].message.content.strip()
except Exception as e:
print(f"math api failed: {e}")
_speak("Ошибка.")
return
if answer.lower() == 'нет' or not answer:
_speak("Это не математика.")
return
_speak(f"Получилось {answer}.")
def do_theme_set(action, voice: str):
is_light = action.get('mode') == 'light'
val = 1 if is_light else 0
key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"
ps = (
f"Set-ItemProperty -Path 'Registry::{key}' -Name AppsUseLightTheme -Value {val} -Type DWord; "
f"Set-ItemProperty -Path 'Registry::{key}' -Name SystemUsesLightTheme -Value {val} -Type DWord"
)
res = subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, timeout=10)
if res.returncode == 0:
_speak("Светлая тема включена." if is_light else "Тёмная тема включена.")
else:
_speak("Не получилось переключить тему.")
def _load_projects_config():
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
cfg_path = os.path.join(user_profile, 'Documents', 'jarvis-projects.json')
if not os.path.isfile(cfg_path):
sample = [
{"name": "jarvis-rust", "path": os.path.join(user_profile, "Jarvis", "rust")},
{"name": "jarvis-python", "path": os.path.join(user_profile, "Jarvis", "python")},
{"name": "dietpi", "path": os.path.join(user_profile, "Desktop", "Dietpi")},
]
os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
with open(cfg_path, 'w', encoding='utf-8') as f:
json.dump(sample, f, ensure_ascii=False, indent=2)
return cfg_path, sample, True
try:
with open(cfg_path, 'r', encoding='utf-8') as f:
return cfg_path, json.load(f), False
except (OSError, json.JSONDecodeError) as e:
print(f"projects config read failed: {e}")
return cfg_path, [], False
def do_open_project(action, voice: str):
cfg_path, entries, created = _load_projects_config()
if created:
_speak("Создал шаблон в Documents, отредактируй и повтори.")
subprocess.Popen(['cmd', '/c', 'start', '', cfg_path], shell=False)
return
if not entries:
_speak("Проектов в конфиге нет.")
return
triggers = ('открой папку проекта', 'запусти проект', 'открой проект', 'проект',
'open project', 'launch project', 'project',
'відкрий проект')
query = _strip_trigger(voice, triggers).lower().strip()
if not query:
_speak("Какой проект?")
return
match = (next((e for e in entries if e['name'].lower() == query), None)
or next((e for e in entries if query in e['name'].lower()), None)
or next((e for e in entries if e['name'].lower() in query), None))
if not match:
_speak(f"Не нашёл {query}.")
return
path = match['path']
if not os.path.exists(path):
_speak(f"Путь не существует: {path}")
return
code_check = subprocess.run(['where', 'code.cmd'], capture_output=True, text=True)
if code_check.returncode == 0 and code_check.stdout.strip():
subprocess.Popen(['cmd', '/c', 'code', path], shell=False)
else:
subprocess.Popen(['cmd', '/c', 'start', '', path], shell=False)
_speak(f"Открываю {match['name']}.")
def do_list_projects(action, voice: str):
cfg_path, entries, created = _load_projects_config()
if created or not entries:
_speak("Список пуст.")
return
names = [e['name'] for e in entries]
_speak(f"Проектов {len(names)}: {', '.join(names)}.")
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
def do_audio_devices_panel(action, voice: str):
# Full programmatic switching needs IPolicyConfig + IMMDeviceEnumerator
# via comtypes; out of scope for this pass. Opens Sound control panel so
# the user can pick visually. Rust fork has the COM-based version.
subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,0'])
_speak("Открываю настройки звука, сэр.")
feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total) Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
def do_open_sound_panel(action, voice: str):
subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,1'])
_speak("Открываю панель звука.")
import re as _re_reminders
import threading as _threading
_REMINDER_TRIGGERS = (
'поставь напоминание через', 'установи таймер на',
'напомни мне через', 'напомни через', 'напомни',
'remind me in', 'set reminder in', 'set timer for', 'timer for',
)
_RUS_NUMERALS = {
'один': 1, 'одну': 1, 'одна': 1, 'полтора': 1, 'полторы': 1,
'два': 2, 'две': 2, 'три': 3, 'четыре': 4, 'пять': 5,
'шесть': 6, 'семь': 7, 'восемь': 8, 'девять': 9, 'десять': 10,
'пятнадцать': 15, 'двадцать': 20, 'тридцать': 30, 'сорок': 40,
'пятьдесят': 50,
}
_UNIT_SECS = {
'секунд': 1, 'секунду': 1, 'секунды': 1, 'second': 1, 'seconds': 1, 'sec': 1,
'минут': 60, 'минуту': 60, 'минуты': 60, 'мин': 60,
'minute': 60, 'minutes': 60, 'min': 60,
'час': 3600, 'часа': 3600, 'часов': 3600, 'часик': 3600,
'hour': 3600, 'hours': 3600, 'hr': 3600,
}
def _fmt_seconds(sec: int) -> str:
sec = int(sec)
if sec < 60: return f"{sec} сек"
if sec < 3600: return f"{sec // 60} мин"
return f"{sec // 3600} ч {(sec % 3600) // 60} мин"
def do_set_reminder(action, voice: str):
rest = _strip_trigger(voice, _REMINDER_TRIGGERS).strip().lower()
number, unit, body = None, None, None
m = _re_reminders.match(r'^(\d+)\s+(.+)$', rest)
if m:
number = int(m.group(1))
rest = m.group(2)
else:
head, _, tail = rest.partition(' ')
if head in _RUS_NUMERALS:
number = _RUS_NUMERALS[head]
rest = tail
head, _, tail = rest.partition(' ')
if head:
for w, secs in _UNIT_SECS.items():
if head.startswith(w):
unit = secs
body = tail
break
if number is None:
number, unit, body = 5, unit or 60, rest
elif unit is None:
unit, body = 60, rest
total = max(1, min(86400, number * unit))
body = (body or '').strip() or "Напоминание!"
def fire():
time.sleep(total)
try:
recorder.stop()
tts.va_speak(f"Сэр, напоминаю: {body}")
except Exception:
pass
finally:
time.sleep(0.2)
try: recorder.start()
except Exception: pass
try:
subprocess.Popen(
['powershell', '-NoProfile', '-Command',
"$wsh = New-Object -ComObject WScript.Shell; "
f"$wsh.Popup('{body.replace(chr(39), chr(39)+chr(39))}', 30, "
f"'Напоминание J.A.R.V.I.S.', 64) | Out-Null"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
_threading.Thread(target=fire, daemon=False).start()
_speak(f"Через {_fmt_seconds(total)} напомню: {body}.")
def do_date_query(action, voice: str):
offset = action.get('offset', 0)
target = datetime.datetime.now() + datetime.timedelta(days=offset)
weekdays = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
text = f"{weekdays[target.weekday()]}, {target.day} {months[target.month - 1]}"
labels = {0: 'Сегодня', 1: 'Завтра', -1: 'Вчера'}
_speak(f"{labels.get(offset, '')} {text}.")
def do_type_text(action, voice: str):
triggers = ('набери текст', 'введи текст', 'напечатай', 'впиши',
'type text', 'write text', 'type', 'надрукуй')
text = _strip_trigger(voice, triggers)
if not text:
_speak("Что напечатать?")
return
_set_clipboard(text)
time.sleep(0.15)
try:
import pyautogui
pyautogui.hotkey('ctrl', 'v')
except Exception as e:
print(f"voice type failed: {e}")
_speak("Не получилось напечатать.")
def do_dice(action, voice: str):
kind = action.get('kind', 'random')
if kind == 'coin':
_speak(random.choice(['Орёл.', 'Решка.']))
elif kind == 'dice':
_speak(f"Выпало {random.randint(1, 6)}.")
else:
_speak(f"Число {random.randint(1, 100)}.")
def _stopwatch_state_path() -> str:
base = os.environ.get('LOCALAPPDATA', os.path.expanduser('~'))
p = os.path.join(base, 'com.priler.jarvis', 'stopwatch.txt')
os.makedirs(os.path.dirname(p), exist_ok=True)
return p
feat: parity — news, currency, crypto, fun, wiki, power, help (82 commands) Mirrors the rust analog-inspired tier-2 packs. yaml + ast smoke pass. 56 → 82 commands (+26 yaml entries; 14 of them are power/fun/wiki/ news/etc. permutations). main.py — 7 new action handlers, all built on stdlib urllib.request (no new dep). Helper _http_get() with User-Agent header so RSS feeds that 403 default UAs go through. + do_news: pulls RSS (default Lenta.ru), extracts <title> via regex, skips channel title (idx 0), takes 5, sends to Groq for a 2-3 sentence summary at temp 0.4. Falls back to reading first 3 headlines verbatim if no token. + do_currency_rate: cbr-xml-daily.ru daily_json.js. Recognises USD/EUR/CNY/GBP/JPY/KZT in the phrase, computes value/nominal, diffs against Previous, speaks Russian with direction. + do_crypto_price: CoinGecko simple/price for BTC/ETH/SOL/DOGE/TON with 24h change, picked by phrase keywords. + do_fun_ask: joke / fact / quote / compliment via Groq at temp 1.0 with per-kind system prompts, random seed in user message. + do_wiki_lookup: ru.wikipedia opensearch → page summary, optional Groq retell when extract > 350 chars. + do_power: dispatch table for shutdown / restart / sleep / hibernate / logoff / cancel — runs shutdown.exe /s|/r|/h|/l|/a or rundll32 powrprof,SetSuspendState. 30-second grace on shutdown and restart with /c message so "отмени выключение" works. + do_help_commands: sorts VA_CMD_LIST.keys(), speaks first 12 + total, full list to clipboard. commands.yaml +26 entries: news_headlines, currency_rate, crypto_price, joke / fact / quote / compliment (action:fun_ask, kind: ...), wiki_lookup, shutdown_pc / restart_pc / sleep_pc / hibernate_pc / logoff_user / cancel_shutdown (action:power, op: ...), help. Inspired by what mainstream voice assistants (Siri/Alexa/Алиса/ Cortana) ship by default + power-user utilities (shutdown, window/process control).
2026-05-15 12:43:26 +03:00
import urllib.request as _urlreq
import urllib.parse as _urlparse
def _http_get(url: str, timeout: float = 15.0) -> str:
req = _urlreq.Request(url, headers={'User-Agent': 'Jarvis/1.0'})
with _urlreq.urlopen(req, timeout=timeout) as r:
return r.read().decode('utf-8', errors='replace')
def do_news(action, voice: str):
feed = action.get('feed', 'https://lenta.ru/rss/news')
try:
body = _http_get(feed, timeout=15)
except Exception as e:
_speak("Не получил RSS.")
print(f"news fetch failed: {e}")
return
titles = _re_reminders.findall(r'<title>\s*<!\[CDATA\[(.+?)\]\]>\s*</title>', body)
if not titles:
titles = _re_reminders.findall(r'<title>\s*(.+?)\s*</title>', body)
titles = [t.strip() for t in titles if t.strip()]
if len(titles) <= 1:
_speak("Не получилось распарсить новости.")
return
top = titles[1:6]
joined = ' | '.join(top)
_set_clipboard(joined)
say = None
if config.GROQ_TOKEN:
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{'role': 'system', 'content': 'Ты диктор. Сжато перескажи 3 главные темы из списка заголовков, 2-3 предложения.'},
{'role': 'user', 'content': joined},
],
max_tokens=220, temperature=0.4,
)
say = resp.choices[0].message.content.strip()
except Exception as e:
print(f"news LLM summary failed: {e}")
if not say:
say = "Главные новости: " + ". ".join(top[:3])
_speak(say)
_CURRENCY_LABELS = {'USD': 'Доллар', 'EUR': 'Евро', 'CNY': 'Юань',
'GBP': 'Фунт', 'JPY': 'Йена', 'KZT': 'Тенге'}
def do_currency_rate(action, voice: str):
lower = voice.lower()
code = 'USD'
if 'евро' in lower or 'eur' in lower: code = 'EUR'
elif 'юан' in lower or 'cny' in lower: code = 'CNY'
elif 'фунт' in lower: code = 'GBP'
elif 'йен' in lower: code = 'JPY'
elif 'тенге' in lower: code = 'KZT'
try:
data = json.loads(_http_get('https://www.cbr-xml-daily.ru/daily_json.js', timeout=10))
except Exception as e:
_speak("Не получил данные ЦБ.")
print(f"cbr fetch failed: {e}")
return
v = data.get('Valute', {}).get(code)
if not v:
_speak(f"Нет данных по {code}.")
return
val = v['Value'] / max(1, v.get('Nominal', 1))
prev = v.get('Previous', v['Value']) / max(1, v.get('Nominal', 1))
diff = val - prev
label = _CURRENCY_LABELS.get(code, code)
if diff > 0.01:
_speak(f"{label} стоит {val:.2f} рубля, поднялся на {diff:.2f}.")
elif diff < -0.01:
_speak(f"{label} стоит {val:.2f} рубля, опустился на {abs(diff):.2f}.")
else:
_speak(f"{label} стоит {val:.2f} рубля, без изменений.")
def do_crypto_price(action, voice: str):
lower = voice.lower()
if 'эфир' in lower or 'eth' in lower or 'ethereum' in lower:
coin, label = 'ethereum', 'Эфир'
elif 'солан' in lower: coin, label = 'solana', 'Солана'
elif 'doge' in lower or 'дог' in lower: coin, label = 'dogecoin', 'Догикоин'
elif 'ton' in lower or 'тон' in lower: coin, label = 'the-open-network', 'TON'
else: coin, label = 'bitcoin', 'Биткоин'
try:
url = (f"https://api.coingecko.com/api/v3/simple/price?ids={coin}"
"&vs_currencies=usd,rub&include_24hr_change=true")
data = json.loads(_http_get(url, timeout=10)).get(coin, {})
except Exception as e:
_speak("CoinGecko не ответил.")
print(f"coingecko fetch failed: {e}")
return
if not data:
_speak(f"Нет данных по {label}.")
return
usd = data.get('usd', 0)
ch = data.get('usd_24h_change', 0)
if ch > 1:
_speak(f"{label} стоит {int(usd)} долларов, за сутки вырос на {ch:.1f} процентов.")
elif ch < -1:
_speak(f"{label} стоит {int(usd)} долларов, за сутки упал на {abs(ch):.1f} процентов.")
else:
_speak(f"{label} стоит {int(usd)} долларов, без существенных изменений.")
_FUN_PROMPTS = {
'joke': "Расскажи одну короткую шутку на русском, 1-3 предложения. Без вступления.",
'fact': "Расскажи один интересный неочевидный факт. 1-3 предложения. Без 'вот факт'.",
'quote': "Дай вдохновляющую цитату известного автора. Формат: <цитата> — <автор>.",
'compliment': "Сделай искренний короткий комплимент пользователю, как Джарвис-дворецкий. 1-2 предложения, обращение «сэр».",
}
def do_fun_ask(action, voice: str):
kind = action.get('kind', 'joke')
sys_prompt = _FUN_PROMPTS.get(kind, _FUN_PROMPTS['joke'])
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': f'seed {random.randint(1, 9999)}'},
],
max_tokens=220, temperature=1.0,
)
text = resp.choices[0].message.content.strip()
except Exception as e:
print(f"fun api failed: {e}")
_speak("Ошибка.")
return
_speak(text)
_WIKI_TRIGGERS = ('найди в википедии про', 'википедия про', 'расскажи про',
'кто такая', 'кто такой', 'что такое',
'tell me about', 'wikipedia about', 'who is', 'what is',
'розкажи про', 'хто такий', 'що таке')
def do_wiki_lookup(action, voice: str):
query = _strip_trigger(voice, _WIKI_TRIGGERS).strip()
if not query:
_speak("О чём рассказать?")
return
host = 'ru.wikipedia.org'
try:
search_url = (f"https://{host}/w/api.php?action=opensearch"
f"&search={_urlparse.quote(query)}&limit=1&format=json")
search_data = json.loads(_http_get(search_url, timeout=10))
if len(search_data) < 2 or not search_data[1]:
_speak(f"Не нашёл {query}.")
return
title = search_data[1][0]
summary_url = f"https://{host}/api/rest_v1/page/summary/{_urlparse.quote(title)}"
summary = json.loads(_http_get(summary_url, timeout=10))
except Exception as e:
_speak("Википедия не ответила.")
print(f"wiki fetch failed: {e}")
return
extract = summary.get('extract', '')
if not extract:
_speak("Не получил конспект.")
return
say = extract[:400]
if config.GROQ_TOKEN and len(extract) > 350:
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{'role': 'system', 'content': "Перескажи текст из Википедии простым языком в 2-3 предложения. Без 'википедия гласит'."},
{'role': 'user', 'content': extract[:2500]},
],
max_tokens=220, temperature=0.3,
)
say = resp.choices[0].message.content.strip()
except Exception as e:
print(f"wiki LLM failed: {e}")
_set_clipboard(f"{summary.get('title', title)}\n\n{extract}")
_speak(say)
_POWER_ACTIONS = {
'shutdown': (['shutdown.exe', '/s', '/t', '30', '/c', 'J.A.R.V.I.S.: shutdown in 30s'], "Выключение через 30 секунд."),
'restart': (['shutdown.exe', '/r', '/t', '30', '/c', 'J.A.R.V.I.S.: restart in 30s'], "Перезагрузка через 30 секунд."),
'logoff': (['shutdown.exe', '/l'], "Выход из системы."),
'sleep': (['rundll32.exe', 'powrprof.dll,SetSuspendState', '0,1,0'], "Перехожу в сон."),
'hibernate': (['shutdown.exe', '/h'], "Гибернация."),
'cancel': (['shutdown.exe', '/a'], "Выключение отменено."),
}
def do_power(action, voice: str):
op = action.get('op', 'cancel')
cmd, label = _POWER_ACTIONS.get(op, _POWER_ACTIONS['cancel'])
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if res.returncode == 0 or op == 'cancel':
tail = " Скажи отмени если передумал." if op in ('shutdown', 'restart') else ""
_speak(label + tail)
else:
_speak(f"Не получилось: {op}.")
print(f"power {op} failed: {res.stderr[:200]}")
def do_help_commands(action, voice: str):
names = sorted(VA_CMD_LIST.keys())
preview = ', '.join(names[:12])
_set_clipboard('\n'.join(names))
_speak(f"Загружено {len(names)} команд. Например: {preview}. Полный список в буфере.")
_GAME_TRIGGERS = ('запусти игру', 'включи игру', 'хочу поиграть', 'поиграем',
'запусти', 'включи', 'launch game', 'play game', 'launch',
'запусти гру', 'увімкни гру')
def _load_games_config():
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
cfg_path = os.path.join(user_profile, 'Documents', 'jarvis-games.json')
if not os.path.isfile(cfg_path):
sample = [
{"name": "dota", "aliases": ["доту", "доту 2", "dota 2"], "steam_appid": 570},
{"name": "cs2", "aliases": ["кс", "контру", "контр страйк"], "steam_appid": 730},
{"name": "elden ring", "aliases": ["элден", "элден ринг"], "steam_appid": 1245620},
{"name": "witcher 3", "aliases": ["ведьмак", "ведьмака 3"], "steam_appid": 292030},
{"name": "factorio", "aliases": ["факторио"], "steam_appid": 427520},
]
os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
with open(cfg_path, 'w', encoding='utf-8') as f:
json.dump(sample, f, ensure_ascii=False, indent=2)
return cfg_path, sample, True
try:
with open(cfg_path, 'r', encoding='utf-8') as f:
return cfg_path, json.load(f), False
except (OSError, json.JSONDecodeError) as e:
print(f"games config read failed: {e}")
return cfg_path, [], False
def do_launch_game(action, voice: str):
cfg_path, entries, created = _load_games_config()
if created:
_speak("Создал шаблон, добавь свои игры и повтори.")
subprocess.Popen(['cmd', '/c', 'start', '', cfg_path])
return
query = _strip_trigger(voice, _GAME_TRIGGERS).strip().lower()
if not query:
_speak("Какую игру?")
return
def name_match(e, q):
if e['name'].lower() == q: return True
if q in e['name'].lower(): return True
for a in e.get('aliases', []):
if a.lower() == q or a.lower() in q: return True
return False
match = next((e for e in entries if name_match(e, query)), None)
if not match:
_speak(f"Не нашёл {query}.")
return
appid = match.get('steam_appid')
path = match.get('path')
epic_uri = match.get('epic_uri')
if appid:
webbrowser.open(f"steam://rungameid/{appid}")
elif epic_uri:
webbrowser.open(epic_uri)
elif path and os.path.isfile(path):
subprocess.Popen(['cmd', '/c', 'start', '', path])
else:
_speak(f"Не настроен запуск для {match['name']}.")
return
_speak(f"Запускаю {match['name']}.")
def do_list_games(action, voice: str):
cfg_path, entries, created = _load_games_config()
if created or not entries:
_speak("Список пуст.")
return
names = [e['name'] for e in entries]
_speak(f"В библиотеке {len(names)}: {', '.join(names)}.")
_MOUSE_PS = r"""
Add-Type -Name MouseM -Namespace Win32 -MemberDefinition @'
[DllImport("user32.dll")]
public static extern void mouse_event(uint flags, int dx, int dy, int data, System.UIntPtr extra);
'@
$LD=0x0002; $LU=0x0004; $RD=0x0008; $RU=0x0010; $MD=0x0020; $MU=0x0040; $WHEEL=0x0800
function Click([uint32]$d,[uint32]$u){ [Win32.MouseM]::mouse_event($d,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Win32.MouseM]::mouse_event($u,0,0,0,[UIntPtr]::Zero) }
switch ($args[0]) {
'left' { Click $LD $LU }
'right' { Click $RD $RU }
'middle' { Click $MD $MU }
'double' { Click $LD $LU; Start-Sleep -Milliseconds 50; Click $LD $LU }
'scroll_up' { [Win32.MouseM]::mouse_event($WHEEL,0,0, 360,[UIntPtr]::Zero) }
'scroll_down' { [Win32.MouseM]::mouse_event($WHEEL,0,0,-360,[UIntPtr]::Zero) }
}
""".strip()
def do_mouse(action, voice: str):
op = action.get('op', 'left')
subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', _MOUSE_PS, op],
capture_output=True, timeout=5,
)
_CHOICE_TRIGGERS = ('выбери из', 'выбери', 'решай за меня', 'выбрать',
'pick from', 'choose from', 'decide between',
'обери з', 'вибери')
def do_random_choice(action, voice: str):
rest = _strip_trigger(voice, _CHOICE_TRIGGERS).strip().lower()
options = None
for sep in (' или ', ' or ', ', ', ' либо ', ' чи '):
if sep in rest:
options = [t.strip() for t in rest.split(sep) if t.strip()]
break
if not options or len(options) < 2:
_speak("Скажи: выбери из X или Y или Z.")
return
_speak(f"Я выбираю {random.choice(options)}.")
_BRIGHTNESS_PS = (
"$ErrorActionPreference='SilentlyContinue'; "
"try { $cur = (Get-WmiObject -Namespace root\\WMI -Class WmiMonitorBrightness -EA Stop).CurrentBrightness } "
"catch { Write-Output 'no_wmi'; exit 0 } "
"$target = switch ('__OP__') { 'up'{[Math]::Min(100,$cur+20)} 'down'{[Math]::Max(0,$cur-20)} 'max'{100} 'min'{10} default{$cur} } "
"(Get-WmiObject -Namespace root\\WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,$target) | Out-Null; "
"Write-Output $target"
)
def do_brightness(action, voice: str):
op = action.get('op', 'up')
ps = _BRIGHTNESS_PS.replace('__OP__', op)
res = subprocess.run(
['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=10,
)
out = (res.stdout or '').strip()
if out == 'no_wmi' or not out:
_speak("Не поддерживается на этом дисплее.")
return
_speak(f"Яркость {out} процентов.")
_WINDOW_ALIASES = {
'хром': 'chrome', 'хрома': 'chrome', 'едж': 'msedge', 'эдж': 'msedge',
'файрфокс': 'firefox', 'фаерфокс': 'firefox',
'вскод': 'code', 'vs code': 'code',
'дискорд': 'discord', 'телега': 'telegram', 'телеграм': 'telegram',
'стим': 'steam', 'обс': 'obs64',
'проводник': 'explorer', 'блокнот': 'notepad',
'терминал': 'windowsterminal', 'спотифай': 'spotify',
'яндекс': 'browser', 'браузер': 'browser',
}
_WINDOW_TRIGGERS = ('переключись на', 'переключи на', 'активируй окно',
'покажи окно', 'перейди в',
'switch to', 'activate window', 'focus window',
'перемкни на')
def do_window_switch(action, voice: str):
query = _strip_trigger(voice, _WINDOW_TRIGGERS).strip().lower()
if not query:
_speak("Какое окно?")
return
target = _WINDOW_ALIASES.get(query, query).replace("'", "")
ps = (
f"$procs = Get-Process | Where-Object {{ $_.MainWindowTitle -and "
f"($_.ProcessName -like '*{target}*' -or $_.MainWindowTitle -like '*{target}*') }} "
f"| Sort-Object -Property StartTime -Descending; "
f"if ($procs.Count -eq 0) {{ Write-Output 'NOT_FOUND'; exit 0 }}; "
f"$p = $procs[0]; "
f"$wsh = New-Object -ComObject WScript.Shell; "
f"$null = $wsh.AppActivate($p.Id); "
f"Write-Output $p.MainWindowTitle"
)
res = subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=10)
out = (res.stdout or '').strip()
if out == 'NOT_FOUND' or not out:
_speak(f"Не нашёл окно {query}.")
return
_speak(f"Переключаюсь на {out[:60]}.")
_SPELLING_TRIGGERS = ('продиктуй по буквам', 'произнеси по буквам',
'произнеси буквы', 'по буквам',
'spell out', 'spell it', 'вимов по буквах')
def do_spell_out(action, voice: str):
word = _strip_trigger(voice, _SPELLING_TRIGGERS).strip()
if not word:
_speak("Что произнести?")
return
letters = [ch for ch in word if not ch.isspace()]
if not letters:
return
_speak('. '.join(letters) + '.')
_NETWORK_URIS = {
'wifi': 'ms-settings:network-wifi',
'bluetooth': 'ms-settings:bluetooth',
'network': 'ms-settings:network',
}
def do_network_settings(action, voice: str):
section = action.get('section', 'network')
uri = _NETWORK_URIS.get(section, _NETWORK_URIS['network'])
subprocess.Popen(['cmd', '/c', 'start', '', uri], shell=False)
label = {'wifi': 'Wi-Fi', 'bluetooth': 'Bluetooth', 'network': 'сети'}.get(section, 'сети')
_speak(f"Открываю настройки {label}.")
def do_my_ip(action, voice: str):
ps = (
"$lan = (Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | "
"Where-Object { $_.PrefixOrigin -in 'Dhcp','Manual' } | "
"Where-Object { $_.IPAddress -notmatch '^127\\.' -and $_.IPAddress -notmatch '^169\\.254\\.' } | "
"Select-Object -First 1).IPAddress; "
"if (-not $lan) { $lan = '(none)' }; "
"$wan = try { (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 5).ip } catch { 'unavailable' }; "
"Write-Output ('LAN ' + $lan + ' WAN ' + $wan)"
)
res = subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=15)
out = (res.stdout or '').strip()
if not out:
_speak("Не получил IP.")
return
say = out.replace('LAN', 'Локальный').replace('WAN', '. Внешний')
_speak(say)
_SUMMARIZE_TRIGGERS = ('суммируй', 'перескажи выделенное', 'кратко перескажи',
'о чём это', 'что выделил', 'перескажи это',
'summarise this', 'summarize selection', 'tldr',
'перекажи це', 'стисло перекажи')
def do_summarize_selection(action, voice: str):
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
try:
import pyautogui
pyautogui.hotkey('ctrl', 'c')
except Exception as e:
print(f"summarize copy failed: {e}")
_speak("Не удалось скопировать выделение.")
return
time.sleep(0.25)
res = subprocess.run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard'],
capture_output=True, text=True, encoding='utf-8', timeout=10)
text = (res.stdout or '').strip()
if len(text) < 20:
_speak("Сначала выдели текст и повтори.")
return
if len(text) > 8000:
text = text[:8000] + " ..."
sys_prompt = ("Перескажи следующий текст одним абзацем (3-5 предложений). "
"Сохрани ключевые факты и имена, без 'вот суммарно'.")
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': text}],
max_tokens=400, temperature=0.3,
)
summary = resp.choices[0].message.content.strip()
except Exception as e:
print(f"summarize LLM failed: {e}")
_speak("LLM не ответила.")
return
_set_clipboard(summary)
_speak(summary)
def do_stopwatch(action, voice: str):
op = action.get('op', 'check')
state = _stopwatch_state_path()
def fmt(sec):
sec = int(sec)
if sec < 60: return f"{sec} секунд"
if sec < 3600: return f"{sec // 60} мин {sec % 60} сек"
return f"{sec // 3600} ч {(sec % 3600) // 60} мин {sec % 60} сек"
if op == 'start':
with open(state, 'w', encoding='utf-8') as f:
f.write(str(time.time()))
_speak("Секундомер запущен.")
return
if not os.path.isfile(state):
_speak("Секундомер не запущен.")
return
try:
started = float(open(state, 'r', encoding='utf-8').read().strip())
except (OSError, ValueError):
_speak("Секундомер не запущен.")
return
elapsed = time.time() - started
if op == 'stop':
try: os.unlink(state)
except OSError: pass
_speak(f"Прошло {fmt(elapsed)}.")
else:
_speak(f"Прошло {fmt(elapsed)}.")
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
def do_sysinfo(action, voice: str):
topic = action.get('topic', 'all')
if topic in ('time', 'all'):
now = datetime.datetime.now()
weekday = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'][now.weekday()]
_speak(f"Сейчас {now.strftime('%H:%M')}, {weekday}.")
if topic == 'time':
return
ps_scripts = {
'battery': '$b=Get-CimInstance Win32_Battery -EA SilentlyContinue; if($b){"$([int]$b.EstimatedChargeRemaining)%"}else{"батареи нет"}',
'cpu': '$c=Get-CimInstance Win32_Processor -EA SilentlyContinue|Select-Object -First 1; $load=(Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name=\'_Total\'" -EA SilentlyContinue).PercentProcessorTime; if($null -eq $load){$load=$c.LoadPercentage}; "$load%"',
'ram': '$o=Get-CimInstance Win32_OperatingSystem; $tg=[math]::Round($o.TotalVisibleMemorySize/1MB,1); $ug=[math]::Round(($o.TotalVisibleMemorySize-$o.FreePhysicalMemory)/1MB,1); "$ug из $tg гигабайт"',
'disk': '$d=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID=\'$env:SystemDrive\'"; $fg=[math]::Round($d.FreeSpace/1GB,0); $tg=[math]::Round($d.Size/1GB,0); "свободно $fg из $tg гигабайт"',
}
labels = {'battery': 'Питание', 'cpu': 'Процессор', 'ram': 'Память', 'disk': 'Диск'}
order = ['battery', 'cpu', 'ram', 'disk'] if topic == 'all' else [topic]
for t in order:
ps = ps_scripts.get(t)
if not ps:
continue
result = subprocess.run(
['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=10,
)
out = result.stdout.strip() if result.returncode == 0 else 'ошибка'
_speak(f"{labels[t]}: {out}.")
def run_action(action):
t = action['type']
if t == 'exe':
path = f"{CDIR}\\custom-commands\\{action['file']}"
if action.get('wait'):
subprocess.check_call([path])
else:
subprocess.Popen([path])
if 'delay_ms' in action:
time.sleep(action['delay_ms'] / 1000)
elif t == 'play_sound':
play(action['name'])
elif t == 'system':
op = action['op']
if op == 'volume_mute':
_set_mute(1)
elif op == 'volume_unmute':
_set_mute(0)
elif op == 'exit':
_shutdown()
elif t == 'sleep':
time.sleep(action['ms'] / 1000)
elif t == 'shell':
if action.get('wait'):
subprocess.check_call(action['cmd'], shell=True)
else:
subprocess.Popen(action['cmd'], shell=True)
elif t == 'url':
browser = action.get('browser')
if browser:
exe = config.BROWSER_PATHS.get(browser)
if exe and os.path.isfile(exe):
subprocess.Popen([exe, action['href']])
return
webbrowser.open(action['href'])
elif t == 'keys':
import pyautogui
if action.get('sequence'):
keys = [k.strip() for k in action['sequence'].split('+') if k.strip()]
pyautogui.hotkey(*keys)
elif action.get('text'):
pyautogui.typewrite(action['text'], interval=0.02)
elif t == 'multi':
for step in action['steps']:
run_action(step)
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
elif t == 'groq_codegen':
do_codegen(action, _current_voice or '')
elif t == 'ocr_screen':
do_ocr(action, _current_voice or '')
elif t == 'sysinfo':
do_sysinfo(action, _current_voice or '')
elif t == 'file_search':
do_file_search(action, _current_voice or '')
elif t == 'audio_devices_panel':
do_audio_devices_panel(action, _current_voice or '')
feat: tier-1 PC tools parity — window mgmt, clipboard, notes, process killer, web search Mirrors the new rust packs from the same iteration. Total commands in commands.yaml is now 49 (was 33). yaml.safe_load + ast.parse both pass. Window management (8 commands) — uses the existing `keys` action with pyautogui hotkey strings: winleft+d (show_desktop), winleft+up/down (maximize/minimize), winleft+left/right (snap left/right), alt+f4 (close_window), winleft+shift+m (restore_all), winleft+tab (task_view). No new action type needed. Clipboard read aloud — new clipboard_read action. Shells to PowerShell Get-Clipboard, caps preview at 400 chars, speaks via the existing tts.va_speak (which already handles recorder.stop/start). Notes — new notes_add + notes_open actions. notes_add strips the trigger phrase, appends "[YYYY-MM-DD HH:MM] body\n" to %USERPROFILE%\Documents\jarvis-notes.txt, creates the dir if needed. notes_open shells "cmd /c start" so .txt opens in the user-default editor. Process killer — new process_kill action. Strips the trigger ("убей процесс"/"закрой программу"/etc.), looks up the remainder in the same alias map as the rust version (хром→chrome, телега→telegram, спотифай →spotify, едж/эдж/edge→msedge, дискорд→discord, стим→steam, обс→obs64, проводник/блокнот/калькулятор/диспетчер задач, vs code→code, etc.), appends .exe if needed, runs taskkill /F /IM, speaks success or "не нашёл процесс N". Web search — new websearch action with a `service` param. Bases: google → https://www.google.com/search?q= youtube → https://www.youtube.com/results?search_query= wiki → https://ru.wikipedia.org/wiki/Special:Search?search= yandex → https://yandex.ru/search/?text= Strips trigger, urllib.parse.quote-s the rest, opens via webbrowser.open (respects the user'\''s default browser; config.BROWSER_PATHS only kicks in for the older url-action commands). Portability: USERPROFILE used for notes path, no other hardcoded paths.
2026-05-15 11:27:19 +03:00
elif t == 'clipboard_read':
do_clipboard_read(action, _current_voice or '')
elif t == 'notes_add':
do_notes_add(action, _current_voice or '')
elif t == 'notes_open':
do_notes_open(action, _current_voice or '')
elif t == 'process_kill':
do_process_kill(action, _current_voice or '')
elif t == 'websearch':
do_websearch(action, _current_voice or '')
feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total) Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
elif t == 'translate':
do_translate(action, _current_voice or '')
elif t == 'math_eval':
do_math_eval(action, _current_voice or '')
elif t == 'theme_set':
do_theme_set(action, _current_voice or '')
elif t == 'open_project':
do_open_project(action, _current_voice or '')
elif t == 'list_projects':
do_list_projects(action, _current_voice or '')
elif t == 'open_sound_panel':
do_open_sound_panel(action, _current_voice or '')
elif t == 'set_reminder':
do_set_reminder(action, _current_voice or '')
elif t == 'date_query':
do_date_query(action, _current_voice or '')
elif t == 'type_text':
do_type_text(action, _current_voice or '')
elif t == 'dice':
do_dice(action, _current_voice or '')
elif t == 'stopwatch':
do_stopwatch(action, _current_voice or '')
feat: parity — news, currency, crypto, fun, wiki, power, help (82 commands) Mirrors the rust analog-inspired tier-2 packs. yaml + ast smoke pass. 56 → 82 commands (+26 yaml entries; 14 of them are power/fun/wiki/ news/etc. permutations). main.py — 7 new action handlers, all built on stdlib urllib.request (no new dep). Helper _http_get() with User-Agent header so RSS feeds that 403 default UAs go through. + do_news: pulls RSS (default Lenta.ru), extracts <title> via regex, skips channel title (idx 0), takes 5, sends to Groq for a 2-3 sentence summary at temp 0.4. Falls back to reading first 3 headlines verbatim if no token. + do_currency_rate: cbr-xml-daily.ru daily_json.js. Recognises USD/EUR/CNY/GBP/JPY/KZT in the phrase, computes value/nominal, diffs against Previous, speaks Russian with direction. + do_crypto_price: CoinGecko simple/price for BTC/ETH/SOL/DOGE/TON with 24h change, picked by phrase keywords. + do_fun_ask: joke / fact / quote / compliment via Groq at temp 1.0 with per-kind system prompts, random seed in user message. + do_wiki_lookup: ru.wikipedia opensearch → page summary, optional Groq retell when extract > 350 chars. + do_power: dispatch table for shutdown / restart / sleep / hibernate / logoff / cancel — runs shutdown.exe /s|/r|/h|/l|/a or rundll32 powrprof,SetSuspendState. 30-second grace on shutdown and restart with /c message so "отмени выключение" works. + do_help_commands: sorts VA_CMD_LIST.keys(), speaks first 12 + total, full list to clipboard. commands.yaml +26 entries: news_headlines, currency_rate, crypto_price, joke / fact / quote / compliment (action:fun_ask, kind: ...), wiki_lookup, shutdown_pc / restart_pc / sleep_pc / hibernate_pc / logoff_user / cancel_shutdown (action:power, op: ...), help. Inspired by what mainstream voice assistants (Siri/Alexa/Алиса/ Cortana) ship by default + power-user utilities (shutdown, window/process control).
2026-05-15 12:43:26 +03:00
elif t == 'news':
do_news(action, _current_voice or '')
elif t == 'currency_rate':
do_currency_rate(action, _current_voice or '')
elif t == 'crypto_price':
do_crypto_price(action, _current_voice or '')
elif t == 'fun_ask':
do_fun_ask(action, _current_voice or '')
elif t == 'wiki_lookup':
do_wiki_lookup(action, _current_voice or '')
elif t == 'power':
do_power(action, _current_voice or '')
elif t == 'help_commands':
do_help_commands(action, _current_voice or '')
elif t == 'launch_game':
do_launch_game(action, _current_voice or '')
elif t == 'list_games':
do_list_games(action, _current_voice or '')
elif t == 'mouse':
do_mouse(action, _current_voice or '')
elif t == 'random_choice':
do_random_choice(action, _current_voice or '')
elif t == 'brightness':
do_brightness(action, _current_voice or '')
elif t == 'window_switch':
do_window_switch(action, _current_voice or '')
elif t == 'spell_out':
do_spell_out(action, _current_voice or '')
elif t == 'network_settings':
do_network_settings(action, _current_voice or '')
elif t == 'my_ip':
do_my_ip(action, _current_voice or '')
elif t == 'summarize_selection':
do_summarize_selection(action, _current_voice or '')
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
_current_voice: str = ''
def execute_cmd(cmd: str, voice: str):
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
global _current_voice
spec = VA_CMD_LIST.get(cmd)
if not spec:
return
action = spec['action']
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
_current_voice = voice
try:
run_action(action)
finally:
_current_voice = ''
confirm = spec.get('confirm_sound')
if confirm is None and action['type'] == 'exe':
confirm = 'ok'
if confirm:
play(confirm)
print("=" * 60)
print(f" J.A.R.V.I.S. v{config.VA_VER} [Python edition]")
print(f" Total commands: {len(VA_CMD_LIST)}")
print("=" * 60)
recorder = PvRecorder(device_index=config.MICROPHONE_INDEX, frame_length=512)
recorder.start()
print('Using device: %s' % recorder.selected_device)
print(f"Jarvis (v{config.VA_VER}) начал свою работу ...")
play("run")
time.sleep(0.5)
def heard_wake_word(text: str) -> bool:
if not text:
return False
lowered = text.lower()
return any(w in lowered for w in config.WAKE_WORDS)
while True:
try:
pcm = recorder.read()
sp = struct.pack("h" * len(pcm), *pcm)
if not wake_rec.AcceptWaveform(sp):
continue
result_text = json.loads(wake_rec.Result()).get("text", "")
if not heard_wake_word(result_text):
continue
play("greet", True)
print("Yes, sir.")
kaldi_rec.Reset()
listen_started = time.time()
speech_ms = 0
silence_ms = 0
vad_buf = b""
finalize = False
cmd_buf = b"" if config.DENOISE_ENABLED else None
while True:
elapsed_ms = (time.time() - listen_started) * 1000
if elapsed_ms >= config.COMMAND_MAX_LISTEN_MS:
break
pcm = recorder.read()
sp = struct.pack("h" * len(pcm), *pcm)
if cmd_buf is None:
kaldi_rec.AcceptWaveform(sp)
else:
cmd_buf += sp
vad_buf += sp
while len(vad_buf) >= VAD_FRAME_BYTES:
frame = vad_buf[:VAD_FRAME_BYTES]
vad_buf = vad_buf[VAD_FRAME_BYTES:]
if vad.is_speech(frame, samplerate):
speech_ms += VAD_FRAME_MS
silence_ms = 0
else:
silence_ms += VAD_FRAME_MS
if elapsed_ms < config.COMMAND_MIN_LISTEN_MS:
continue
if speech_ms >= config.COMMAND_MIN_SPEECH_MS and silence_ms >= config.COMMAND_END_SILENCE_MS:
finalize = True
break
if finalize or speech_ms > 0:
if cmd_buf is not None:
kaldi_rec.AcceptWaveform(denoise_pcm(cmd_buf))
text = json.loads(kaldi_rec.FinalResult()).get("text", "")
if text:
va_respond(text)
wake_rec.Reset()
except Exception as err:
print(f"Unexpected {err=}, {type(err)=}")
raise