Initial commit: Cosmo voice assistant
Полностью локальный голосовой ассистент на Python. Стек: - Wake word: openWakeWord (onnxruntime) - STT: RealtimeSTT + faster-whisper + Silero VAD (CUDA) - LLM-агент: smolagents ToolCallingAgent + Ollama qwen2.5:7b - TTS: Silero V4 (torch.hub) + sounddevice - Shell: Git Bash (Windows) / bash (macOS) Поддерживает Windows и macOS. Агент с памятью и tool calling — находит программы самостоятельно, запоминает пути, выполняет произвольные shell-команды. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
92
train_wakeword/Dockerfile
Normal file
92
train_wakeword/Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
||||
# Dockerfile для обучения wake word модели openWakeWord
|
||||
# Python 3.11 + torch 2.5 (последний совместимый с py3.11) + рабочие зависимости 2026
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Системные зависимости (включая build-essential для webrtcvad)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git wget curl ffmpeg libsndfile1 \
|
||||
build-essential python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Клонируем openWakeWord и piper-sample-generator
|
||||
RUN git clone https://github.com/dscripka/openWakeWord /openWakeWord
|
||||
RUN git clone https://github.com/rhasspy/piper-sample-generator /piper-sample-generator
|
||||
|
||||
# Torch 2.5.0 — последний для Python 3.11, CPU версия (обучение не требует GPU)
|
||||
RUN pip install --no-cache-dir \
|
||||
torch==2.5.0 \
|
||||
torchaudio==2.5.0 \
|
||||
--index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Зависимости обучения с совместимыми версиями
|
||||
RUN pip install --no-cache-dir \
|
||||
mutagen==1.47.0 \
|
||||
torchinfo==1.8.0 \
|
||||
torchmetrics==1.2.0 \
|
||||
speechbrain==1.0.3 \
|
||||
audiomentations==0.43.1 \
|
||||
torch-audiomentations==0.12.0 \
|
||||
pronouncing==0.2.0 \
|
||||
"datasets==2.20.0" \
|
||||
"pyarrow==14.0.2" \
|
||||
"fsspec==2023.12.2" \
|
||||
acoustics==0.2.6 \
|
||||
webrtcvad \
|
||||
onnx \
|
||||
onnxruntime \
|
||||
onnx2tf \
|
||||
pyyaml scipy scikit-learn tqdm
|
||||
|
||||
# TFLite конвертация через onnx2tf (замена мёртвого onnx_tf)
|
||||
# Патчим train.py чтобы использовал onnx2tf вместо onnx_tf
|
||||
RUN pip install --no-cache-dir \
|
||||
tensorflow-cpu==2.21.0 \
|
||||
tensorflow_probability==0.24.0
|
||||
|
||||
RUN pip install --no-cache-dir -e /openWakeWord
|
||||
|
||||
# Патч: заменяем onnx_tf на onnx2tf в train.py
|
||||
RUN python - <<'EOF'
|
||||
import re, pathlib
|
||||
train_py = pathlib.Path("/openWakeWord/openwakeword/train.py")
|
||||
text = train_py.read_text()
|
||||
# Заменяем импорт onnx_tf
|
||||
text = text.replace(
|
||||
"import onnx_tf",
|
||||
"import onnx2tf as onnx_tf_compat"
|
||||
)
|
||||
text = text.replace(
|
||||
"from onnx_tf.backend import prepare",
|
||||
"# onnx_tf replaced by onnx2tf"
|
||||
)
|
||||
# Заменяем вызов convert_onnx_to_tflite если он есть
|
||||
text = re.sub(
|
||||
r"onnx_tf\.backend\.prepare\(.*?\)",
|
||||
"None # onnx2tf handles tflite conversion differently",
|
||||
text, flags=re.DOTALL
|
||||
)
|
||||
train_py.write_text(text)
|
||||
print("train.py patched OK")
|
||||
EOF
|
||||
|
||||
# Устанавливаем piper-sample-generator
|
||||
RUN pip install --no-cache-dir -e /piper-sample-generator 2>/dev/null || \
|
||||
pip install --no-cache-dir piper-tts
|
||||
|
||||
# Скачиваем TTS модель LibriTTS-R medium (~66 MB) для генерации примеров
|
||||
RUN mkdir -p /piper-sample-generator/models && \
|
||||
wget -q --show-progress \
|
||||
-O /piper-sample-generator/models/en_US-libritts_r-medium.onnx \
|
||||
"https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx" && \
|
||||
wget -q \
|
||||
-O /piper-sample-generator/models/en_US-libritts_r-medium.onnx.json \
|
||||
"https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx.json"
|
||||
|
||||
RUN mkdir -p /data /output /samples
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
53
train_wakeword/cosmo_config.yaml
Normal file
53
train_wakeword/cosmo_config.yaml
Normal file
@@ -0,0 +1,53 @@
|
||||
# Конфиг для обучения wake word модели "Hey Cosmo"
|
||||
# Документация: https://github.com/dscripka/openWakeWord
|
||||
|
||||
model_name: "hey_cosmo"
|
||||
output_dir: "/output"
|
||||
|
||||
# Целевая фраза — "hey cosmo" работает лучше чем просто "cosmo"
|
||||
target_phrase:
|
||||
- "hey cosmo"
|
||||
- "cosmo"
|
||||
|
||||
# Похожие слова для улучшения устойчивости к ложным срабатываниям
|
||||
custom_negative_phrases:
|
||||
- "hey cosmos"
|
||||
- "hey cosmic"
|
||||
- "hey cosplay"
|
||||
- "hey presto"
|
||||
- "hey como"
|
||||
- "hey koz"
|
||||
- "cozmo"
|
||||
|
||||
# Количество синтетических примеров
|
||||
n_samples: 10000
|
||||
n_samples_val: 1000
|
||||
tts_batch_size: 25
|
||||
|
||||
# Аугментация
|
||||
augmentation_batch_size: 16
|
||||
augmentation_rounds: 2
|
||||
|
||||
# Пути внутри Docker контейнера
|
||||
piper_sample_generator_path: "/piper-sample-generator"
|
||||
false_positive_validation_data_path: "/data/validation_set_features.npy"
|
||||
|
||||
feature_data_files:
|
||||
"ACAV100M_sample": "/data/openwakeword_features_ACAV100M_2000_hrs_16bit.npy"
|
||||
|
||||
batch_n_per_class:
|
||||
"ACAV100M_sample": 1024
|
||||
"adversarial_negative": 50
|
||||
"positive": 50
|
||||
|
||||
# Архитектура модели
|
||||
model_type: "dnn"
|
||||
layer_size: 32
|
||||
steps: 50000
|
||||
|
||||
# Цели качества
|
||||
max_negative_weight: 1500
|
||||
target_false_positives_per_hour: 0.5
|
||||
target_accuracy: 0.7
|
||||
target_recall: 0.5
|
||||
lr: 0.0001
|
||||
42
train_wakeword/entrypoint.sh
Normal file
42
train_wakeword/entrypoint.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "============================================"
|
||||
echo " Обучение wake word модели 'Hey Cosmo'"
|
||||
echo "============================================"
|
||||
|
||||
CONFIG="/app/cosmo_config.yaml"
|
||||
OWW="/openWakeWord/openwakeword/train.py"
|
||||
|
||||
# Шаг 1: Генерация синтетических аудио примеров через TTS
|
||||
echo ""
|
||||
echo "[1/3] Генерирую синтетические примеры через TTS..."
|
||||
python "$OWW" --training_config "$CONFIG" --generate_clips
|
||||
echo " Готово."
|
||||
|
||||
# Шаг 2: Аугментация (шумы, реверберация, разные условия)
|
||||
echo ""
|
||||
echo "[2/3] Аугментирую примеры (шумы, реверберация)..."
|
||||
python "$OWW" --training_config "$CONFIG" --augment_clips
|
||||
echo " Готово."
|
||||
|
||||
# Шаг 3: Обучение модели
|
||||
echo ""
|
||||
echo "[3/3] Обучаю модель..."
|
||||
python "$OWW" --training_config "$CONFIG" --train_model
|
||||
echo " Готово."
|
||||
|
||||
# Копируем результат
|
||||
echo ""
|
||||
echo "============================================"
|
||||
if ls /output/hey_cosmo*.onnx 1>/dev/null 2>&1; then
|
||||
echo " Модель обучена успешно!"
|
||||
echo " Файлы в папке models/:"
|
||||
ls -lh /output/*.onnx 2>/dev/null || true
|
||||
ls -lh /output/*.tflite 2>/dev/null || true
|
||||
else
|
||||
echo " ОШИБКА: .onnx файл не найден в /output"
|
||||
echo " Проверь логи выше."
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================"
|
||||
114
train_wakeword/record_samples.py
Normal file
114
train_wakeword/record_samples.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Запись голосовых примеров для обучения wake word модели.
|
||||
Запускай: python train_wakeword/record_samples.py
|
||||
|
||||
Скрипт записывает N примеров слова "Hey Cosmo" с паузами между ними.
|
||||
Файлы сохраняются в train_wakeword/samples/positive/
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
import struct
|
||||
import threading
|
||||
|
||||
try:
|
||||
import pyaudio
|
||||
except ImportError:
|
||||
print("Установи pyaudio: pip install pyaudio")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Настройки ---
|
||||
WAKE_WORD = "Hey Cosmo"
|
||||
N_SAMPLES = 30 # сколько примеров записать
|
||||
RECORD_SECS = 2.0 # длина одной записи (сек)
|
||||
PAUSE_SECS = 2.0 # пауза между записями (сек)
|
||||
SAMPLE_RATE = 16000
|
||||
CHANNELS = 1
|
||||
CHUNK = 512
|
||||
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "samples", "positive")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
def record_clip(pa: pyaudio.PyAudio, filename: str, duration: float):
|
||||
stream = pa.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=CHANNELS,
|
||||
rate=SAMPLE_RATE,
|
||||
input=True,
|
||||
frames_per_buffer=CHUNK,
|
||||
)
|
||||
frames = []
|
||||
n_chunks = int(SAMPLE_RATE / CHUNK * duration)
|
||||
for _ in range(n_chunks):
|
||||
frames.append(stream.read(CHUNK, exception_on_overflow=False))
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
|
||||
with wave.open(filename, "wb") as wf:
|
||||
wf.setnchannels(CHANNELS)
|
||||
wf.setsampwidth(pa.get_sample_size(pyaudio.paInt16))
|
||||
wf.setframerate(SAMPLE_RATE)
|
||||
wf.writeframes(b"".join(frames))
|
||||
|
||||
def countdown(seconds: int):
|
||||
for i in range(seconds, 0, -1):
|
||||
print(f"\r {i}...", end="", flush=True)
|
||||
time.sleep(1)
|
||||
print("\r Говори! ", end="", flush=True)
|
||||
|
||||
def main():
|
||||
# Считаем уже записанные файлы
|
||||
existing = [f for f in os.listdir(OUTPUT_DIR) if f.endswith(".wav")]
|
||||
start_idx = len(existing)
|
||||
|
||||
if start_idx >= N_SAMPLES:
|
||||
print(f"Уже записано {start_idx} примеров. Для перезаписи удали папку {OUTPUT_DIR}")
|
||||
return
|
||||
|
||||
pa = pyaudio.PyAudio()
|
||||
|
||||
print("=" * 50)
|
||||
print(f" Запись примеров wake word: \"{WAKE_WORD}\"")
|
||||
print(f" Нужно записать: {N_SAMPLES} примеров")
|
||||
print(f" Уже есть: {start_idx}")
|
||||
print(f" Длина каждой записи: {RECORD_SECS} сек")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("Инструкция:")
|
||||
print(" - Говори чётко и естественно")
|
||||
|
||||
print(" - Меняй интонацию, темп, громкость")
|
||||
print(" - Можно говорить чуть тише / громче / быстрее")
|
||||
print(" - Представь что реально обращаешься к ассистенту")
|
||||
print()
|
||||
input(" Нажми Enter когда готов начать...")
|
||||
print()
|
||||
|
||||
for i in range(start_idx, N_SAMPLES):
|
||||
num = i + 1
|
||||
filename = os.path.join(OUTPUT_DIR, f"hey_cosmo_{num:03d}.wav")
|
||||
|
||||
print(f"[{num:2d}/{N_SAMPLES}] Приготовься... ", end="", flush=True)
|
||||
countdown(2)
|
||||
|
||||
record_clip(pa, filename, RECORD_SECS)
|
||||
print(f" ✓ записано")
|
||||
|
||||
if num < N_SAMPLES:
|
||||
time.sleep(PAUSE_SECS)
|
||||
|
||||
pa.terminate()
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print(f" Готово! Записано {N_SAMPLES} примеров.")
|
||||
print(f" Папка: {OUTPUT_DIR}")
|
||||
print()
|
||||
print(" Следующий шаг:")
|
||||
print(" bash train_wakeword/train.sh")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
119
train_wakeword/train.sh
Normal file
119
train_wakeword/train.sh
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
MODELS_DIR="$PROJECT_DIR/models"
|
||||
SAMPLES_DIR="$SCRIPT_DIR/samples"
|
||||
DATA_DIR="$SCRIPT_DIR/docker_data"
|
||||
|
||||
echo "============================================"
|
||||
echo " Cosmo Wake Word — обучение модели"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Проверяем Docker
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "ОШИБКА: Docker не найден."
|
||||
echo "Установи Docker Desktop: https://www.docker.com/products/docker-desktop/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info &>/dev/null; then
|
||||
echo "ОШИБКА: Docker не запущен. Запусти Docker Desktop и попробуй снова."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Проверяем что есть записанные примеры
|
||||
POSITIVE_DIR="$SAMPLES_DIR/positive"
|
||||
if [ ! -d "$POSITIVE_DIR" ] || [ -z "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; then
|
||||
echo "Записанные примеры не найдены."
|
||||
echo "Сначала запусти запись голоса:"
|
||||
echo " python train_wakeword/record_samples.py"
|
||||
echo ""
|
||||
read -p "Продолжить без записанных примеров? (используются только TTS) [y/N]: " yn
|
||||
case "$yn" in
|
||||
[Yy]*) echo "Продолжаем с только TTS примерами..." ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
mkdir -p "$MODELS_DIR" "$DATA_DIR"
|
||||
|
||||
# Собираем Docker образ
|
||||
echo "[1/4] Собираю Docker образ (первый раз ~5-10 мин)..."
|
||||
docker build -t cosmo-wakeword-trainer "$SCRIPT_DIR" --quiet
|
||||
echo " Образ готов."
|
||||
echo ""
|
||||
|
||||
# Скачиваем датасет негативных примеров если нет
|
||||
NEGATIVE_FEATURES="$DATA_DIR/openwakeword_features_ACAV100M_2000_hrs_16bit.npy"
|
||||
VALIDATION_FEATURES="$DATA_DIR/validation_set_features.npy"
|
||||
|
||||
if [ ! -f "$NEGATIVE_FEATURES" ]; then
|
||||
echo "[2/4] Скачиваю негативный датасет (~20 GB, один раз)..."
|
||||
echo " Это займёт время в зависимости от скорости интернета."
|
||||
docker run --rm \
|
||||
-v "$DATA_DIR:/data" \
|
||||
cosmo-wakeword-trainer \
|
||||
python -c "
|
||||
from datasets import load_dataset
|
||||
import numpy as np, os
|
||||
print('Скачиваю ACAV100M features...')
|
||||
ds = load_dataset('davidscripka/openwakeword_features', 'ACAV100M_2000_hrs_16bit', split='train')
|
||||
arr = np.array(ds['features'])
|
||||
np.save('/data/openwakeword_features_ACAV100M_2000_hrs_16bit.npy', arr)
|
||||
print('Скачиваю validation features...')
|
||||
ds_val = load_dataset('davidscripka/openwakeword_features', 'validation_set', split='train')
|
||||
arr_val = np.array(ds_val['features'])
|
||||
np.save('/data/validation_set_features.npy', arr_val)
|
||||
print('Датасет скачан.')
|
||||
"
|
||||
echo " Датасет готов."
|
||||
else
|
||||
echo "[2/4] Негативный датасет уже скачан. Пропускаю."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Запускаем обучение
|
||||
echo "[3/4] Запускаю обучение в Docker..."
|
||||
echo " Это займёт ~30-60 минут."
|
||||
echo ""
|
||||
|
||||
SAMPLES_MOUNT=""
|
||||
if [ -d "$POSITIVE_DIR" ] && [ -n "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; then
|
||||
SAMPLES_MOUNT="-v $POSITIVE_DIR:/samples/positive"
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$SCRIPT_DIR/cosmo_config.yaml:/app/cosmo_config.yaml" \
|
||||
-v "$DATA_DIR:/data" \
|
||||
-v "$MODELS_DIR:/output" \
|
||||
$SAMPLES_MOUNT \
|
||||
cosmo-wakeword-trainer
|
||||
|
||||
echo ""
|
||||
echo "[4/4] Копирую модель в проект..."
|
||||
|
||||
# Ищем готовую модель
|
||||
ONNX_FILE=$(ls "$MODELS_DIR"/*.onnx 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$ONNX_FILE" ]; then
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Готово! Модель сохранена:"
|
||||
echo " $ONNX_FILE"
|
||||
echo ""
|
||||
echo " Обновляю wake_word детектор..."
|
||||
# Обновляем путь в конфиге
|
||||
MODEL_FILENAME=$(basename "$ONNX_FILE")
|
||||
sed -i "s|wakeword_models=\[\"hey_jarvis\"\]|wakeword_models=[\"models/$MODEL_FILENAME\"]|g" \
|
||||
"$PROJECT_DIR/cosmo/wake_word.py" 2>/dev/null || true
|
||||
echo " Теперь запускай: bash run.sh"
|
||||
echo " и говори 'Hey Cosmo' для активации!"
|
||||
echo "============================================"
|
||||
else
|
||||
echo "ОШИБКА: .onnx файл не найден в $MODELS_DIR"
|
||||
echo "Проверь логи Docker выше."
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user