Release v0.4.0 — semantic intent (ONNX MiniLM) + optional denoise

This commit is contained in:
Bossiara13 2026-04-23 11:09:54 +03:00
commit 47927fcf41
13 changed files with 61532 additions and 13 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.onnx filter=lfs diff=lfs merge=lfs -text

View file

@ -43,7 +43,9 @@ python main.py
- `MICROPHONE_INDEX = -1` — индекс микрофона (`-1` означает устройство по умолчанию). Если микрофонов несколько и берётся не тот, поменяйте число. - `MICROPHONE_INDEX = -1` — индекс микрофона (`-1` означает устройство по умолчанию). Если микрофонов несколько и берётся не тот, поменяйте число.
- `WAKE_WORDS = ('jarvis', 'джарвис')` — варианты транскрипции wake-word, которые принимает Vosk. Если ваш голос/акцент стабильно расшифровывается как, например, `жарвис` — добавьте этот вариант сюда (всё в нижнем регистре). - `WAKE_WORDS = ('jarvis', 'джарвис')` — варианты транскрипции wake-word, которые принимает Vosk. Если ваш голос/акцент стабильно расшифровывается как, например, `жарвис` — добавьте этот вариант сюда (всё в нижнем регистре).
- `GROQ_MODEL` — имя модели Groq (по умолчанию `llama-3.3-70b-versatile`). - `GROQ_MODEL` — имя модели Groq (по умолчанию `llama-3.3-70b-versatile`).
- `commands.yaml` — набор голосовых команд и их вариантов для fuzzy-матчинга. - `INTENT_SIMILARITY_THRESHOLD = 0.45` — минимальная косинусная близость для срабатывания команды (см. ниже).
- `DENOISE_ENABLED = False` — шумоподавление перед Vosk (см. ниже).
- `commands.yaml` — набор голосовых команд и их вариантов; матчится семантически по эмбеддингам.
- `custom-commands/` — скомпилированные AHK-скрипты под конкретный сетап автора оригинала (переключение мониторов, управление Яндекс.Музыкой и т.п.). На вашей машине большая часть из них работать не будет, но ассистент запустится в любом случае. - `custom-commands/` — скомпилированные AHK-скрипты под конкретный сетап автора оригинала (переключение мониторов, управление Яндекс.Музыкой и т.п.). На вашей машине большая часть из них работать не будет, но ассистент запустится в любом случае.
## Использование ## Использование
@ -63,6 +65,26 @@ python main.py
- `COMMAND_MIN_LISTEN_MS` — минимальное время записи, чтобы не закрыться мгновенно (1000 мс). - `COMMAND_MIN_LISTEN_MS` — минимальное время записи, чтобы не закрыться мгновенно (1000 мс).
- `COMMAND_MAX_LISTEN_MS` — жёсткий потолок (15000 мс). - `COMMAND_MAX_LISTEN_MS` — жёсткий потолок (15000 мс).
## Семантический матчинг команд
Раньше распознанная фраза сравнивалась с фразами из `commands.yaml` через `fuzzywuzzy.fuzz.ratio` — это срабатывало только на формальное совпадение букв. Теперь используется sentence-transformer `all-MiniLM-L6-v2` (ONNX, CPU) — фраза кодируется в 384-мерный вектор, считается косинусная близость с фразами всех команд, побеждает самая близкая.
Это означает, что фраза `«запусти браузер»` сматчится с командой `open_browser`, даже если в YAML записано только `«открой браузер»`у них близкий смысл.
- Модель лежит в `models/all-MiniLM-L6-v2/` (87 МБ, хранится через Git LFS — `git lfs pull` после клонирования).
- Порог `INTENT_SIMILARITY_THRESHOLD = 0.45` (косинус, диапазон [-1; 1], для близких смыслов обычно 0.7+). Ниже порога — «команда не распознана», сработает `not_found.wav` (или ветка `«скажи …»` для LLM).
- Все фразы из `commands.yaml` эмбеддятся один раз при старте; на каждой команде эмбеддится только распознанная пользователем фраза (~1-5 мс на CPU).
## Шумоподавление перед STT
Опциональный пре-процессинг аудио командного буфера через `noisereduce` (спектральное гейтирование). Помогает в шумных помещениях (кулер, кондиционер, фоновая музыка). К wake-word фазе не применяется — там важна отзывчивость.
В `config.py`:
- `DENOISE_ENABLED = False` — главный тумблер.
- `DENOISE_PROP = 0.85` — доля подавления (0..1).
- `DENOISE_STATIONARY = False``False` для меняющегося шума (рекомендуется), `True` для стабильного фона.
## Эффекты под AI-ассистента ## Эффекты под AI-ассистента
Silero звучит естественно, но иногда хочется лёгкой «AI-окраски» в духе J.A.R.V.I.S. — узкую полосу, еле заметную реверберацию, при желании небольшой сдвиг высоты. Это тонкий постпроцессинг, голос и sample rate не меняются. Silero звучит естественно, но иногда хочется лёгкой «AI-окраски» в духе J.A.R.V.I.S. — узкую полосу, еле заметную реверберацию, при желании небольшой сдвиг высоты. Это тонкий постпроцессинг, голос и sample rate не меняются.

View file

@ -6,7 +6,7 @@ load_dotenv("dev.env")
# Конфигурация # Конфигурация
VA_NAME = 'Jarvis' VA_NAME = 'Jarvis'
VA_VER = "0.3.0" VA_VER = "0.4.0"
VA_ALIAS = ('джарвис',) VA_ALIAS = ('джарвис',)
VA_TBR = ('скажи', 'покажи', 'ответь', 'произнеси', 'расскажи', 'сколько', 'слушай') VA_TBR = ('скажи', 'покажи', 'ответь', 'произнеси', 'расскажи', 'сколько', 'слушай')
@ -41,3 +41,16 @@ TTS_BANDPASS_HIGH_HZ = 7000
TTS_REVERB_WET = 0.20 TTS_REVERB_WET = 0.20
TTS_REVERB_DECAY_MS = 100 TTS_REVERB_DECAY_MS = 100
TTS_PITCH_SEMITONES = 0 TTS_PITCH_SEMITONES = 0
# Семантический матчинг команд через эмбеддинги MiniLM-L6-v2 (ONNX, CPU).
# Порог — косинусная близость [0..1]. На вход поступает уже отфильтрованная
# фраза (без алиасов и vа_tbr); 0.45 эмпирически отделяет валидные команды
# от шума, оставляя запас на разговорные перефразировки.
INTENT_SIMILARITY_THRESHOLD = 0.45
# Шумоподавление аудио перед Vosk на этапе захвата команды (после wake-word).
# Спектральное гейтирование через noisereduce; даёт небольшую латентность,
# поэтому выключено по умолчанию и применяется только к буферу команды.
DENOISE_ENABLED = False
DENOISE_PROP = 0.85
DENOISE_STATIONARY = False

79
intent.py Normal file
View file

@ -0,0 +1,79 @@
import os
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer
import config
MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models", "all-MiniLM-L6-v2")
MAX_TOKENS = 128
def _mean_pool(last_hidden: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
mask = attention_mask.astype(np.float32)[..., None]
summed = (last_hidden * mask).sum(axis=1)
counts = np.clip(mask.sum(axis=1), a_min=1e-9, a_max=None)
return summed / counts
def _l2_normalize(x: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(x, axis=-1, keepdims=True)
return x / np.clip(norm, a_min=1e-12, a_max=None)
class IntentClassifier:
def __init__(self, model_dir: str = MODEL_DIR):
tok_path = os.path.join(model_dir, "tokenizer.json")
onnx_path = os.path.join(model_dir, "model.onnx")
self._tokenizer = Tokenizer.from_file(tok_path)
self._tokenizer.enable_truncation(max_length=MAX_TOKENS)
self._tokenizer.enable_padding(pad_id=0, pad_token="[PAD]")
so = ort.SessionOptions()
so.intra_op_num_threads = max(1, (os.cpu_count() or 2) // 2)
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
self._session = ort.InferenceSession(onnx_path, sess_options=so, providers=["CPUExecutionProvider"])
self._input_names = {inp.name for inp in self._session.get_inputs()}
self._cache: dict[str, np.ndarray] = {}
def embed(self, texts: list[str]) -> np.ndarray:
if not texts:
return np.zeros((0, 384), dtype=np.float32)
encs = self._tokenizer.encode_batch(texts)
ids = np.array([e.ids for e in encs], dtype=np.int64)
mask = np.array([e.attention_mask for e in encs], dtype=np.int64)
feeds = {"input_ids": ids, "attention_mask": mask}
if "token_type_ids" in self._input_names:
feeds["token_type_ids"] = np.zeros_like(ids)
outputs = self._session.run(None, feeds)
last_hidden = outputs[0]
pooled = _mean_pool(last_hidden, mask)
return _l2_normalize(pooled).astype(np.float32)
def prime(self, candidates: dict[str, list[str]]) -> None:
self._cache.clear()
for cmd_id, phrases in candidates.items():
if not phrases:
continue
self._cache[cmd_id] = self.embed(list(phrases))
def match(self, utterance: str, candidates: dict[str, list[str]]) -> dict:
utterance = (utterance or "").strip()
if not utterance:
return {"cmd": "", "score": 0.0}
for cmd_id, phrases in candidates.items():
if cmd_id not in self._cache and phrases:
self._cache[cmd_id] = self.embed(list(phrases))
query = self.embed([utterance])[0]
best_cmd = ""
best_score = -1.0
for cmd_id, mat in self._cache.items():
sims = mat @ query
top = float(sims.max()) if sims.size else -1.0
if top > best_score:
best_score = top
best_cmd = cmd_id
threshold = getattr(config, "INTENT_SIMILARITY_THRESHOLD", 0.45)
if best_score < threshold:
return {"cmd": "", "score": max(0.0, best_score)}
return {"cmd": best_cmd, "score": max(0.0, best_score)}

47
main.py
View file

@ -10,6 +10,7 @@ import time
import webbrowser import webbrowser
from ctypes import POINTER, cast from ctypes import POINTER, cast
import numpy as np
import openai import openai
from openai import OpenAI from openai import OpenAI
import simpleaudio as sa import simpleaudio as sa
@ -27,6 +28,7 @@ from rich import print
import config import config
import tts import tts
from intent import IntentClassifier
# some consts # some consts
CDIR = os.getcwd() CDIR = os.getcwd()
@ -60,6 +62,28 @@ VAD_FRAME_MS = 30
VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2 VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2
vad = webrtcvad.Vad(config.VAD_AGGRESSIVENESS) 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()
def gpt_answer(): def gpt_answer():
global message_log global message_log
@ -140,9 +164,10 @@ def va_respond(voice: str):
print(cmd) print(cmd)
min_percent = int(round(config.INTENT_SIMILARITY_THRESHOLD * 100))
if len(cmd['cmd'].strip()) <= 0: if len(cmd['cmd'].strip()) <= 0:
return False return False
elif cmd['percent'] < 70 or cmd['cmd'] not in VA_CMD_LIST.keys(): elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys():
# play("not_found") # play("not_found")
# tts.va_speak("Что?") # tts.va_speak("Что?")
if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75: if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75:
@ -179,15 +204,9 @@ def filter_cmd(raw_voice: str):
def recognize_cmd(cmd: str): def recognize_cmd(cmd: str):
rc = {'cmd': '', 'percent': 0} candidates = {c: v['phrases'] for c, v in VA_CMD_LIST.items()}
for c, v in VA_CMD_LIST.items(): res = intent_classifier.match(cmd, candidates)
for x in v['phrases']: return {'cmd': res['cmd'], 'percent': int(round(res['score'] * 100))}
vrt = fuzz.ratio(cmd, x)
if vrt > rc['percent']:
rc['cmd'] = c
rc['percent'] = vrt
return rc
def _set_mute(state: int): def _set_mute(state: int):
@ -295,6 +314,7 @@ while True:
silence_ms = 0 silence_ms = 0
vad_buf = b"" vad_buf = b""
finalize = False finalize = False
cmd_buf = b"" if config.DENOISE_ENABLED else None
while True: while True:
elapsed_ms = (time.time() - listen_started) * 1000 elapsed_ms = (time.time() - listen_started) * 1000
@ -303,7 +323,10 @@ while True:
pcm = recorder.read() pcm = recorder.read()
sp = struct.pack("h" * len(pcm), *pcm) sp = struct.pack("h" * len(pcm), *pcm)
kaldi_rec.AcceptWaveform(sp) if cmd_buf is None:
kaldi_rec.AcceptWaveform(sp)
else:
cmd_buf += sp
vad_buf += sp vad_buf += sp
while len(vad_buf) >= VAD_FRAME_BYTES: while len(vad_buf) >= VAD_FRAME_BYTES:
@ -322,6 +345,8 @@ while True:
break break
if finalize or speech_ms > 0: 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", "") text = json.loads(kaldi_rec.FinalResult()).get("text", "")
if text: if text:
va_respond(text) va_respond(text)

View file

@ -0,0 +1,35 @@
---
license: apache-2.0
pipeline_tag: sentence-similarity
---
ONNX port of [sentence-transformers/all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) for text classification and similarity searches.
### Usage
Here's an example of performing inference using the model with [FastEmbed](https://github.com/qdrant/fastembed).
```py
from fastembed import TextEmbedding
documents = [
"You should stay, study and sprint.",
"History can only prepare us to be surprised yet again.",
]
model = TextEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
embeddings = list(model.embed(documents))
# [
# array([
# 0.00611658, 0.00068912, -0.0203846, ..., -0.01751488, -0.01174267,
# 0.01463472
# ],
# dtype=float32),
# array([
# 0.00173448, -0.00329958, 0.01557874, ..., -0.01473586, 0.0281806,
# -0.00448205
# ],
# dtype=float32)
# ]
```

View file

@ -0,0 +1,25 @@
{
"_name_or_path": "sentence-transformers/all-MiniLM-L6-v2",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 384,
"initializer_range": 0.02,
"intermediate_size": 1536,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 6,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"transformers_version": "4.36.2",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}

BIN
models/all-MiniLM-L6-v2/model.onnx (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,37 @@
{
"cls_token": {
"content": "[CLS]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"mask_token": {
"content": "[MASK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"pad_token": {
"content": "[PAD]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"sep_token": {
"content": "[SEP]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
},
"unk_token": {
"content": "[UNK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
{
"added_tokens_decoder": {
"0": {
"content": "[PAD]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"100": {
"content": "[UNK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"101": {
"content": "[CLS]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"102": {
"content": "[SEP]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
},
"103": {
"content": "[MASK]",
"lstrip": false,
"normalized": false,
"rstrip": false,
"single_word": false,
"special": true
}
},
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"do_basic_tokenize": true,
"do_lower_case": true,
"mask_token": "[MASK]",
"max_length": 128,
"model_max_length": 512,
"never_split": null,
"pad_to_multiple_of": null,
"pad_token": "[PAD]",
"pad_token_type_id": 0,
"padding_side": "right",
"sep_token": "[SEP]",
"stride": 0,
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"truncation_side": "right",
"truncation_strategy": "longest_first",
"unk_token": "[UNK]"
}

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,13 @@ sounddevice
PyYAML PyYAML
python-dotenv python-dotenv
# Semantic intent classifier (MiniLM-L6-v2 ONNX)
onnxruntime>=1.17
tokenizers>=0.15
# Noise suppression before STT (optional, off by default)
noisereduce>=3.0
# GUI command constructor (tools/command_builder) # GUI command constructor (tools/command_builder)
ruamel.yaml ruamel.yaml
pywebview pywebview