Современные голосовые помощники это мощные приложения, сочетающие обработку речи, машинное обучение и интеграцию с внешними API. В этой статье создадим базовый проект персонального ассистента на Python, используя библиотеки whisper, webrtcvad, gTTS и другие. Наш ассистент будет:
- Слушать микрофон
- Определять начало и конец речи с помощью VAD (Voice Activity Detection)
- Преобразовывать речь в текст через модель Whisper
- Отправлять запросы на локальный LLM для генерации ответа
- Читать ответ вслух с помощью gTTS
- Начинать/останавливать запись по клавише пробел

Проект может служить как началом для экспериментов, так и для прототипирования реальных решений.

Установка зависимостей

В начале установим необходимые библиотеки:

pip install numpy sounddevice keyboard whisper torch webrtcvad requests colorama gTTS

Также потребуется локальный сервер с LLM, например llama.cpp или LM Studio, слушающий по адресу http://localhost:1234.

Обработка звука и запись голоса

Для работы с аудио используется библиотека sounddevice. Создаём поток записи с частотой 16 кГц и ожидаем нажатие пробела — это наш способ начала/остановки записи.

def record_audio():
    global recording
    print("Нажмите Пробел для начала записи...")
    with sd.InputStream(samplerate=SAMPLE_RATE, channels=CHANNELS, dtype=DTYPE, callback=callback):
        while True:
            if keyboard.is_pressed('space'):
                toggle_recording()
                while keyboard.is_pressed('space'):
                    pass
            time.sleep(0.1)

Каждый фрагмент аудио добавляется в буфер, который затем анализируется с помощью VAD (webrtcvad) для определения наличия речи.

Распознавание речи с помощью Whisper

Whisper — одна из популярных моделей распознавания речи. Мы используем её через библиотеку whisper, загружая модель medium и используя графический ускоритель (GPU), если он доступен.

model = whisper.load_model("medium").to(device)

После окончания речи (определяется по паузам) фрагмент передаётся в модель:

result = model.transcribe(audio_float, language="ru", verbose=None)
text = result["text"].strip()

Генерация ответа от ИИ

Для генерации ответа используем, например, локально установленную модель google/gemma-3-4B через приложение LM Studio , которое позволяет запускать LLM-модели локально на нашей машине и создавать API-сервер.

После загрузки модели google/gemma-3-4b в LM Studio, запускаем её в режиме сервера. HTTP-сервер принимает JSON-запросы по адресу http://localhost:1234/v1/chat/completions. Таким образом, наш Python-скрипт отправляет туда текстовый запрос, и получает готовый ответ от модели:

def generate_response(text):
    data = {
        "messages": [{"role": "user", "content": text}],
    }
    response = requests.post("http://localhost:1234/v1/chat/completions", json=data)
    return response.json()['choices'][0]['message']['content']

Этот подход позволяет работать с мощной ИИ-моделью без выхода в интернет, сохраняя конфиденциальность данных и обеспечивая приемлемую скорость работы (зависит от мощности процессора и графической карты). Убедитесь, что в LM Studio вы выбрали корректную модель и выбрали у сервера Status: Running, чтобы скрипт мог с ней взаимодействовать.

Преобразование текста в речь (TTS)

Для озвучивания ответа используется библиотека gTTS (Google Text-to-Speech). Она проста в использовании и отлично подходит для начального уровня, (модуль gTTS_module.py):

import io, os, contextlib
from gtts import gTTS
import pygame
from threading import Thread
import keyboard  # Для отслеживания клавиш

# Глобальная переменная для остановки воспроизведения
_playing = False

def text_to_speech_withEsc(text: str, lang: str = 'ru'):
    """
    Преобразует текст в речь и воспроизводит его.
    Остановка возможна нажатием клавиши Esc.
    """
    try:
        # Генерация аудио в память
        tts = gTTS(text=text, lang=lang)
        fp = io.BytesIO()
        tts.write_to_fp(fp)
        fp.seek(0)

        # Инициализация Pygame и загрузка аудио из памяти
        pygame.mixer.init()
        pygame.mixer.music.load(fp)
        pygame.mixer.music.play()

        # Воспроизводим, пока не закончится или не нажмем Esc
        while pygame.mixer.music.get_busy():
            if keyboard.is_pressed('esc'):
                pygame.mixer.music.stop()
                print("Воспроизведение остановлено (Esc)")
                break

        pygame.mixer.quit()

    except Exception as e:
        print(f"Ошибка при озвучивании: {e}")
    finally:
        pass

Цветовая схема и интерфейс

Чтобы вывод был удобнее читать, применяем цвета с помощью colorama. Можно выбрать между светлой и тёмной темой оформления. Также добавлена анимация «думающего» ассистента во время генерации ответа:

loading_animation(duration=1, text="Генерация ответа...")

Запуск и работа

Запускаем LLM сервер и основной скрипт, нажимаем Пробел — и задаем вопрос или любое другое предложение. Ассистент его распознает, отправляет в модель, получает ответ и читает его вслух (файл pers_assist.py).

import numpy as np
import sounddevice as sd
import keyboard
import whisper
import threading
import torch
import webrtcvad
import requests
from colorama import Fore, Style, init
import time
import re
import gTTS_module


# Инициализация colorama
init(autoreset=True)

# === Цветовые схемы ===
THEMES = {
    "light": {
        "user": Fore.BLUE,
        "assistant": Fore.LIGHTBLACK_EX,
        "thinking": Fore.MAGENTA,
        "background": Style.BRIGHT,
        "prompt": "Светлая"
    },
    "dark": {
        "user": Fore.CYAN,
        "assistant": Fore.LIGHTGREEN_EX,
        "thinking": Fore.YELLOW,
        "background": Style.DIM,
        "prompt": "Тёмная"
    }
}

THEME = THEMES["light"]
#print(f"\n Установлена {THEME['prompt']} тема\n")
  
# --- Настройки ---
SAMPLE_RATE = 16000
CHANNELS = 1
DTYPE = np.int16

SEGMENT_DURATION = 0.02  # 20 мс для VAD
SEGMENT_SAMPLES = int(SAMPLE_RATE * SEGMENT_DURATION)

MIN_SPEECH_CHUNKS = 10     # минимум фрагментов с голосом подряд
SILENCE_TIMEOUT = 1.5      # секунд ожидания перед новой строкой

# --- Инициализация модели Whisper с поддержкой CUDA ---
device = "cuda" if torch.cuda.is_available() else "cpu"
#print(f"[Используется устройство]: {device.upper()}")
model = whisper.load_model("medium").to(device)  #можно так: model = whisper.load_model("small", device="cpu") 

# --- Инициализация VAD ---
vad = webrtcvad.Vad()
vad.set_mode(3 )  # чувствительность 0 - высокая, 3 - низкая
  
def is_speech(frame_bytes):
    try:
        return vad.is_speech(frame_bytes, SAMPLE_RATE)
    except: 
        return False
  
recording = False
audio_buffer = []
buffer_index = 0 
lock = threading.Lock()
last_speech_time = None

# --- Callback записи ---
def callback(indata, frames, time, status):
    if recording:
        with lock:
            audio_buffer.extend(indata.copy().flatten())

# --- Управление записью ---
def record_audio():
    global recording
    print("Нажмите Пробел для начала записи...")
    with sd.InputStream(samplerate=SAMPLE_RATE, channels=CHANNELS, dtype=DTYPE, callback=callback):
        while True:
            if keyboard.is_pressed('space'):
                toggle_recording()
                while keyboard.is_pressed('space'):
                    pass
            time.sleep(0.1)

def toggle_recording():
    global recording, audio_buffer, buffer_index
    global speech_segment, speech_started, new_line_pending, current_pause, last_speech_time

    recording = not recording
    if recording:
        print("\n[Запись началась...]")
        audio_buffer.clear()
        buffer_index = 0

        # Сброс состояния VAD
        speech_segment = []   
        speech_started = False    
        new_line_pending = False
        current_pause = 0.0
        last_speech_time = time.time()  # ← обновляем время начала 
    else:
        print("[Запись остановлена.]")

def generate_response(text):
    data = { 
    "messages": [
        {"role": "user", "content": text}       
    ],
    #"temperature": 0.0,        # минимальная случайность
    #"max_tokens": 10,          # минимум токенов для ответа
    #"stream": False,           # отключает потоковую передачу
    #"stop": ["\n"]             # остановка после первой строки 
    } 
    response = requests.post(
        "http://localhost:1234/v1/chat/completions",
        json=data
    )
    assist_reply = response.json()['choices'][0]['message']['content']
    # Удаляем теги вместе с содержимым между ними
    #cleaned_text = re.sub(r'\<think\>.*?<\</think\>', '', assist_reply, flags=re.DOTALL)
    #print("Ответ ассистента:", assist_reply) 
    return assist_reply 

# === Анимация загрузки ===
def loading_animation(duration=1 , text="Думаю"):
    symbols = [ '⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽','⣾']
    end_time = time.time() + duration
    idx = 0
    while time.time() < end_time:
        print(f"\r{THEME['thinking']}[{symbols[idx % len(symbols)]}] {text}{Style.RESET_ALL}", end="")
        idx += 1
        time.sleep(0.1)
    print(" " * (len(text) + 6), end="\r")  # Очистка строки

def process_stream():
    global last_speech_time, buffer_index
    global speech_segment, speech_started, new_line_pending, current_pause
    global recording   

    while True:
        if not recording:
            time.sleep(0.5)
            continue
        question_text = ""        
        with lock:
            available = len(audio_buffer)

        while buffer_index + SEGMENT_SAMPLES <= available:
            segment = audio_buffer[buffer_index:buffer_index + SEGMENT_SAMPLES]
            buffer_index += SEGMENT_SAMPLES

            segment_np = np.array(segment, dtype=np.int16)
            frame_bytes = segment_np.tobytes()

            try:
                is_silence = not is_speech(frame_bytes)

                if not is_silence:
                    speech_segment.extend(segment)
                    speech_started = True
                    new_line_pending = False
                    last_speech_time = time.time()  # ← обновляем время речи
                elif speech_started:
                    current_pause = time.time() - last_speech_time

                    if current_pause > SILENCE_TIMEOUT:
                        if speech_segment:
                            # Распознаём и выводим
                            audio_float = np.array(speech_segment, dtype=np.float32) / 32768.0
                            result = model.transcribe(audio_float, language="ru", verbose=None)

                            text = result["text"].strip()
                            if text.startswith("Редактор субтитров"): # баг whisper, реакция на шум
                                text = ""
                                continue
                            question_text += " " + text    
                            if text:
                                print(f"{THEME['user']}Вы: {Style.RESET_ALL}{text}" , end=" ", flush=True)
                                
                            speech_segment = []

                        print()  # новая строка
                        speech_segment = []
                        speech_started = False
                        new_line_pending = False
                         # Генерация ответа
                        loading_animation(text="Генерация ответа...")
                        #print(f"\r{THEME['thinking']}[{symbols[idx % len(symbols)]}] {text}{Style.RESET_ALL}", end="")
                        #print(f"{THINKING_COLOR}Генерация ответа...{RESET}", end="\r")
                        response = generate_response(question_text) 
                        print(f"{THEME['assistant']}Ассистент: {response}{Style.RESET_ALL}")
                        question_text = "" 
                        recording = False
                        gTTS_module.text_to_speech_withEsc(response)
                        recording = True
      
            except Exception as e: 
                print(f"[Ошибка]: {e}")
 
      time.sleep(0.05)

# --- Точка входа ---
if __name__ == "__main__":
    print("[Voice-assisient приложение запущено.]")
    threading.Thread(target=record_audio, daemon=True).start()
    threading.Thread(target=process_stream, daemon=True).start()

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\nВыход.")

Выводы

Полный код проекта доступен на github. Созданный голосовой ассистент — это пилотный проект, который можно развивать в сторону полноценного AI-ассистента для дома или офиса. Он объединяет несколько технологий: обработку звука, модели машинного обучения и работу с API. Проект может стать основой для тех кто интересуется темой создания персональных ассистентов.

Комментарии (6)


  1. shoytov
    19.06.2025 07:00

    Позволю себе немного критики: на мой взгляд, в этом коде много "ошибок начинающих" от повсеместного использования глобальных переменных, обработки всех подряд исключений (except без указания конкретного) до стилистических (одновременное использование "наравне" одинарных и двойных кавычек), банально pep8 тут не соблюдено... Также совершенно непонятно, почему в примерах есть закоментированный код: если он был нужен при отладке, в результирующем примере его следовало бы убрать.


  1. cahdro
    19.06.2025 07:00

    Редми на гитхабе делал жпт) Это видно по эмодзи


    1. snakes_are_long
      19.06.2025 07:00

      будто что-то плохое =)


  1. snakes_are_long
    19.06.2025 07:00

    круто!

    делаю что-то подобное вместе с Джемини/Кодексом на протяжении уже пары месяцев, лениво вечерами. ИИ (Джемини) полностью пишет весь код (включая ридми и документацию). архитектуру продумываем вместе

    спасибо за статью )


    1. Johny23
      19.06.2025 07:00

      Слишком медленно для такого простого, тут работаты на 10 часов за компом без перерыва, смысл тянуть время? Ещё и Вcе это нужно завернуть в контейнеры и апи допилить для универсальности, но это все равно изобретение велосипеда, уже есть готовые проекты открытые причем с gpu ускорением, а не работа на cpu

      Мне как то нужно было срочно защитить код в контейнерах, я систему привязки к железу в контейнерах с учётом шифрования сигнатур, и генератор лицензий, + замуровать доступ в контейнер на глухо, чтоб не взломать систему и не украсть код за 12 часов запилил 30 версий приложений защиты выпустил, пока финалку не сделал:)))) и все это автоматизировал скриптами. С учётом того, что пришлось ещё закрытый код компилировать:))


      1. snakes_are_long
        19.06.2025 07:00

        задача другая =)

        для меня это скорее изучение возможностей коллаборации с ИИ (Джемини), как можно вместе с ним разрабатывать и рефакторить что-то вместе, развивать и поддерживать проект на протяжении длительного времени - т.е. из диалога в диалог, через сброс контекстного окна

        мной не написано ни строчки кода, и даже четких задач вида "напиши мне класс/микросервис" я перед ним не ставила, до всего этого он доходил сам, документировал всё сам, проблемы тоже исправлял сам )