2023-04-16 16:59:18 +05:00
|
|
|
|
import datetime
|
|
|
|
|
|
import json
|
2023-04-16 15:56:20 +05:00
|
|
|
|
import os
|
2023-04-16 16:59:18 +05:00
|
|
|
|
import queue
|
2023-04-16 15:56:20 +05:00
|
|
|
|
import random
|
2023-04-16 16:59:18 +05:00
|
|
|
|
import struct
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import time
|
2026-04-22 23:17:40 +03:00
|
|
|
|
import webbrowser
|
2023-04-16 16:59:18 +05:00
|
|
|
|
from ctypes import POINTER, cast
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
2026-04-23 10:54:20 +03:00
|
|
|
|
import numpy as np
|
2023-04-16 16:59:18 +05:00
|
|
|
|
import openai
|
2026-04-22 17:32:46 +03:00
|
|
|
|
from openai import OpenAI
|
2023-04-16 15:56:20 +05:00
|
|
|
|
import simpleaudio as sa
|
|
|
|
|
|
import vosk
|
2026-04-22 19:32:30 +03:00
|
|
|
|
import webrtcvad
|
2023-04-16 16:59:18 +05:00
|
|
|
|
import yaml
|
|
|
|
|
|
from comtypes import CLSCTX_ALL
|
2023-04-16 15:56:20 +05:00
|
|
|
|
from fuzzywuzzy import fuzz
|
2023-04-16 16:59:18 +05:00
|
|
|
|
from pvrecorder import PvRecorder
|
2023-04-16 15:56:20 +05:00
|
|
|
|
from pycaw.pycaw import (
|
|
|
|
|
|
AudioUtilities,
|
|
|
|
|
|
IAudioEndpointVolume
|
|
|
|
|
|
)
|
2023-04-16 16:59:18 +05:00
|
|
|
|
from rich import print
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
2023-04-16 16:59:18 +05:00
|
|
|
|
import config
|
|
|
|
|
|
import tts
|
2026-04-23 10:53:18 +03:00
|
|
|
|
from intent import IntentClassifier
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
2023-04-16 16:59:18 +05:00
|
|
|
|
# some consts
|
2023-04-16 15:56:20 +05:00
|
|
|
|
CDIR = os.getcwd()
|
2023-04-16 16:59:18 +05:00
|
|
|
|
VA_CMD_LIST = yaml.safe_load(
|
|
|
|
|
|
open('commands.yaml', 'rt', encoding='utf8'),
|
|
|
|
|
|
)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
2026-04-22 17:50:32 +03:00
|
|
|
|
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
|
|
|
|
|
2026-04-22 17:32:46 +03:00
|
|
|
|
client = OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
model = vosk.Model("model_small")
|
|
|
|
|
|
samplerate = 16000
|
|
|
|
|
|
device = config.MICROPHONE_INDEX
|
2026-04-22 17:46:44 +03:00
|
|
|
|
|
|
|
|
|
|
# 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)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
kaldi_rec = vosk.KaldiRecognizer(model, samplerate)
|
|
|
|
|
|
q = queue.Queue()
|
|
|
|
|
|
|
2026-04-22 19:32:30 +03:00
|
|
|
|
VAD_FRAME_MS = 30
|
|
|
|
|
|
VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2
|
|
|
|
|
|
vad = webrtcvad.Vad(config.VAD_AGGRESSIVENESS)
|
|
|
|
|
|
|
2026-04-23 10:53:18 +03:00
|
|
|
|
intent_classifier = IntentClassifier()
|
|
|
|
|
|
intent_classifier.prime({c: v['phrases'] for c, v in VA_CMD_LIST.items()})
|
|
|
|
|
|
|
2026-04-23 10:54:20 +03:00
|
|
|
|
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 15:56:20 +05:00
|
|
|
|
|
2023-04-16 17:15:36 +05:00
|
|
|
|
def gpt_answer():
|
|
|
|
|
|
global message_log
|
|
|
|
|
|
|
2023-04-17 16:02:33 +03:00
|
|
|
|
try:
|
2026-04-22 17:32:46 +03:00
|
|
|
|
response = client.chat.completions.create(
|
|
|
|
|
|
model=config.GROQ_MODEL,
|
2023-04-17 16:02:33 +03:00
|
|
|
|
messages=message_log,
|
2026-04-22 17:32:46 +03:00
|
|
|
|
max_tokens=256,
|
2023-04-17 16:02:33 +03:00
|
|
|
|
temperature=0.7,
|
|
|
|
|
|
top_p=1,
|
2026-04-22 17:32:46 +03:00
|
|
|
|
stop=None,
|
2023-04-17 16:02:33 +03:00
|
|
|
|
)
|
2026-04-22 17:32:46 +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()
|
2026-04-22 17:32:46 +03:00
|
|
|
|
return "Запрос отклонён моделью."
|
|
|
|
|
|
except openai.RateLimitError:
|
|
|
|
|
|
return "Лимит запросов исчерпан."
|
|
|
|
|
|
except openai.APIConnectionError:
|
|
|
|
|
|
return "Не могу связаться с сервером."
|
|
|
|
|
|
except openai.APIError:
|
|
|
|
|
|
return "Groq токен не рабочий."
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
2023-04-16 17:15:36 +05:00
|
|
|
|
return response.choices[0].message.content
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
2023-04-16 15:56:20 +05:00
|
|
|
|
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()
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
if wait_done:
|
2023-04-16 16:59:18 +05:00
|
|
|
|
play_obj.wait_done()
|
2023-04-16 15:56:20 +05:00
|
|
|
|
# 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)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
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
|
2023-04-16 15:56:20 +05:00
|
|
|
|
print(f"Распознано: {voice}")
|
|
|
|
|
|
|
|
|
|
|
|
cmd = recognize_cmd(filter_cmd(voice))
|
|
|
|
|
|
|
|
|
|
|
|
print(cmd)
|
|
|
|
|
|
|
2026-04-23 10:53:18 +03:00
|
|
|
|
min_percent = int(round(config.INTENT_SIMILARITY_THRESHOLD * 100))
|
2023-04-16 15:56:20 +05:00
|
|
|
|
if len(cmd['cmd'].strip()) <= 0:
|
|
|
|
|
|
return False
|
2026-04-23 10:53:18 +03:00
|
|
|
|
elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys():
|
2023-04-16 15:56:20 +05:00
|
|
|
|
# play("not_found")
|
|
|
|
|
|
# tts.va_speak("Что?")
|
|
|
|
|
|
if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75:
|
2023-04-16 17:15:36 +05:00
|
|
|
|
|
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})
|
|
|
|
|
|
|
2023-04-16 15:56:20 +05:00
|
|
|
|
recorder.stop()
|
2023-04-16 17:15:36 +05:00
|
|
|
|
tts.va_speak(response)
|
|
|
|
|
|
time.sleep(0.5)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
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):
|
2026-04-23 10:53:18 +03:00
|
|
|
|
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))}
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 19:34:00 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-22 23:17:40 +03:00
|
|
|
|
elif t == 'shell':
|
|
|
|
|
|
if action.get('wait'):
|
|
|
|
|
|
subprocess.check_call(action['cmd'], shell=True)
|
|
|
|
|
|
else:
|
|
|
|
|
|
subprocess.Popen(action['cmd'], shell=True)
|
|
|
|
|
|
elif t == 'url':
|
2026-04-27 20:06:26 +03:00
|
|
|
|
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
|
2026-04-22 23:17:40 +03:00
|
|
|
|
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)
|
2026-04-22 19:34:00 +03:00
|
|
|
|
elif t == 'multi':
|
|
|
|
|
|
for step in action['steps']:
|
|
|
|
|
|
run_action(step)
|
|
|
|
|
|
|
|
|
|
|
|
|
2023-04-16 15:56:20 +05:00
|
|
|
|
def execute_cmd(cmd: str, voice: str):
|
2026-04-22 19:34:00 +03:00
|
|
|
|
spec = VA_CMD_LIST.get(cmd)
|
|
|
|
|
|
if not spec:
|
|
|
|
|
|
return
|
|
|
|
|
|
action = spec['action']
|
|
|
|
|
|
run_action(action)
|
|
|
|
|
|
confirm = spec.get('confirm_sound')
|
|
|
|
|
|
if confirm is None and action['type'] == 'exe':
|
|
|
|
|
|
confirm = 'ok'
|
|
|
|
|
|
if confirm:
|
|
|
|
|
|
play(confirm)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 17:46:44 +03:00
|
|
|
|
recorder = PvRecorder(device_index=config.MICROPHONE_INDEX, frame_length=512)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
recorder.start()
|
|
|
|
|
|
print('Using device: %s' % recorder.selected_device)
|
|
|
|
|
|
|
2026-04-22 17:46:44 +03:00
|
|
|
|
print(f"Jarvis (v{config.VA_VER}) начал свою работу ...")
|
2023-04-16 15:56:20 +05:00
|
|
|
|
play("run")
|
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
|
|
2026-04-22 17:46:44 +03:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
pcm = recorder.read()
|
2026-04-22 17:46:44 +03:00
|
|
|
|
sp = struct.pack("h" * len(pcm), *pcm)
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
2026-04-22 17:46:44 +03:00
|
|
|
|
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()
|
2026-04-22 19:32:30 +03:00
|
|
|
|
listen_started = time.time()
|
|
|
|
|
|
speech_ms = 0
|
|
|
|
|
|
silence_ms = 0
|
|
|
|
|
|
vad_buf = b""
|
|
|
|
|
|
finalize = False
|
2026-04-23 10:54:20 +03:00
|
|
|
|
cmd_buf = b"" if config.DENOISE_ENABLED else None
|
2026-04-22 19:32:30 +03:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
elapsed_ms = (time.time() - listen_started) * 1000
|
|
|
|
|
|
if elapsed_ms >= config.COMMAND_MAX_LISTEN_MS:
|
|
|
|
|
|
break
|
2023-04-16 15:56:20 +05:00
|
|
|
|
|
|
|
|
|
|
pcm = recorder.read()
|
|
|
|
|
|
sp = struct.pack("h" * len(pcm), *pcm)
|
2026-04-23 10:54:20 +03:00
|
|
|
|
if cmd_buf is None:
|
|
|
|
|
|
kaldi_rec.AcceptWaveform(sp)
|
|
|
|
|
|
else:
|
|
|
|
|
|
cmd_buf += sp
|
2026-04-22 19:32:30 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
2023-04-16 15:56:20 +05:00
|
|
|
|
break
|
|
|
|
|
|
|
2026-04-22 19:32:30 +03:00
|
|
|
|
if finalize or speech_ms > 0:
|
2026-04-23 10:54:20 +03:00
|
|
|
|
if cmd_buf is not None:
|
|
|
|
|
|
kaldi_rec.AcceptWaveform(denoise_pcm(cmd_buf))
|
2026-04-22 19:32:30 +03:00
|
|
|
|
text = json.loads(kaldi_rec.FinalResult()).get("text", "")
|
|
|
|
|
|
if text:
|
|
|
|
|
|
va_respond(text)
|
|
|
|
|
|
|
2026-04-22 17:46:44 +03:00
|
|
|
|
wake_rec.Reset()
|
|
|
|
|
|
|
2023-04-16 15:56:20 +05:00
|
|
|
|
except Exception as err:
|
|
|
|
|
|
print(f"Unexpected {err=}, {type(err)=}")
|
|
|
|
|
|
raise
|