Привет, Хабр!

В прошлой статье я описал аппаратную реализацию своего голосового ассистента на базе бюджетного одноплатника Orange Pi Zero 2W с 4Гб оперативной памяти. Эту же статью хочу посвятить программной реализации данного устройства. Если стало интересно, то добро пожаловать под кат.

Наверное, многие из вас представляют примерный принцип работы голосовых ассистентов, ниже на изображении представлена упрошенная логика работы данного устройства.


Как можно видеть, одной из ключевых функций является преобразование речи в текстовые данные, другими словами — транскрибация. Далее нам необходимо обработать полученные текстовые данные и определить в них наличие команды, выполнив сравнение со словарем команд. Иногда, данный словарь называют навыками. Ну и последний этап — это выполнение распознанной команды. Каждая команда в словаре имеет «ссылку» на определенную функцию, которая и будет выполняться при совпадении команды. Собственно, вот и примитивное описание работы нашего голосового ассистента. Конечно, наше устройство не должно безмолвно выполнять команды, а иметь какое-то взаимодействие с пользователем и желательно естественным для него способом, поэтому, для этих целей устройство имеет функцию синтеза речи.

Подготовка


Прежде чем продолжить, необходимо выполнить действия из предыдущей статьи:

  • Подключить звуковой модуль;
  • Установить операционную систему;
  • Сконфигурировать аудиоустройства.

В качестве языка программирования для нашего устройства выбран Python — необходимо установить и его. Рекомендуется использовать версию не ниже 3.11. Если перечисленные условия выполнены, то продолжим по порядку.

Транскрибация


Так как в нашем устройстве мы придерживаемся концепции независимости от внешних сервисов, то и распознавание речи должно выполняться исключительно локально. Сейчас существует много библиотек на базе ML для распознавания речи в оффлайн режиме, но мои поиски и тесты привели к отличному решению под названием Vosk от компании Alphacephei. Учитывая ограниченные системные ресурсы нашего одноплатного компьютера, мы можем с уверенностью сказать что данная библиотека замечательно вписывается в наш проект.

Преимущества библиотеки:

  • Поддерживает 20+ языков и диалектов — русский, английский, индийский английский, немецкий, французский, португальский, испанский, китайский, турецкий, вьетнамский, итальянский, голландский, валенсийский, арабский, греческий, персидский, филиппинский, украинский, казахский, шведский, японский, эсперанто, хинди, чешский, польский, узбекский, корейский, таджикский, гуджарати. В скором времени будут добавлены и другие.
  • Работает без доступа к сети даже на мобильных устройствах — Raspberry Pi, Android, iOS.
  • Устанавливается с помощью простой команды pip3 install vosk без дополнительных шагов.
  • Модели для каждого языка занимают всего 50Мб, но есть и гораздо более точные большие модели для более точного распознавания.
  • Сделана для потоковой обработки звука, что позволяет реализовать мгновенную реакцию на команды.
  • Поддерживает несколько популярных языков программирования — Java, C#, Javascript и других.
  • Позволяет быстро настраивать словарь распознавания для улучшения точности распознавания.
  • Позволяет идентифицировать говорящего.

Итак, для установки пакета нам необходимо выполнить команду:

pip3 install vosk

Ниже представлен пример использования потокового распознавания речи с системного микрофона:

import sounddevice as sd
import vosk
import json
import queue

device_m = 2                                                  # Индекс аудиоустройства (микрофон)
model = vosk.Model("model_stt/vosk-model-small-ru-0.22")      # Модель нейросети
samplerate = 44100                                            # Частота дискретизации микрофона
q = queue.Queue()                                             # Потоковый контейнер


def q_callback(indata, frames, time, status):
    q.put(bytes(indata))


def voce_listen():
    with sd.RawInputStream(callback=q_callback, channels=1, samplerate=samplerate, device=device_m, dtype='int16'):
        rec = vosk.KaldiRecognizer(model, samplerate)
        sd.sleep(-20)
        while True:
            data = q.get()
            if rec.AcceptWaveform(data):
                res = json.loads(rec.Result())["text"]
                if res:
                    print(f"Фраза целиком: {res}")
            else:
                res = json.loads(rec.PartialResult())["partial"]
                if res:
                    print(f"Поток: {res}")


if __name__ == "__main__":
    voce_listen()

Результат вывода данного примера:

Поток: один два 
Поток: один два три это 
Поток: один два три это тест
Поток: один два три это тест
Фраза целиком: один два три это тест

Как можно видеть из примера, для вывода текстовых данных используется два метода rec.Result() и rec.PartialResult(). Оба метода выводят текстовые данные в формате json, первый метод отвечает за вывод фразы целиком, а второй за вывод слов по мере распознавания. Для нашего проекта целесообразней использовать первый метод.
Кстати, объединив синтез и распознавание речи, можно делать забавные вещи, например ;):

Поиск и выполнение команды


Выше мы определились что входные данные для распознавания команд будем получать с помощью метода rec.Result(), который выводит фразу целиком. Теперь осталось составить словарь команд с которым мы и будем работать. Ниже представлен пример данного словаря.

command_dic = {
    "help": ('список команд', 'команды', 'что ты умеешь', 'твои навыки', 'навыки'),
    "about": ('расскажи о себе', 'что ты такое', 'ты кто', 'о себе'),
    "ctime": ('время', 'текущее время', 'сейчас времени', 'который час', 'сколько время'),
    "ask_chat_gpt": ('спроси чат джи пи ти', 'спроси чат', 'спроси умную нейросеть', 'узнай у чат джи пи ти', 'давай поговорим с чат джи пи ти'),
    "lightON": ('включи свет', 'свет включи', 'зажги свет', 'свет включи'),
    "lightOFF": ('выключи свет', 'свет отключи', 'погаси свет', 'свет выключи'),
    "SmartSwichON": ('включи розетку', 'вкл розетку', 'розетку включить', 'розетку вкл'),
    "SmartSwichOFF": ('выключи розетку', 'выкл розетку', 'розетку отключи', 'розетку выкл'),
    "SmartPARAM": ('информация о розетке', 'данные розетки', 'информация сети питания', 'данные умной розетки'),
    "Weather": ('какая погода', 'какая погода на улице', 'информация о погоде', 'какая погода сейчас'),
    "Weather_temp": ('температура на улице', 'какая сейчас температура на улице', 'информация о температуре на улице', 'уличная температура'),
    "temp_home": ('температура в доме', 'какая сейчас температура в доме', 'температура в комнате', 'комнатная температура'),
    "air_home": ('атмосфера в доме', 'качество воздуха в доме', 'воздух в комнате', 'загрязнение воздуха в комнате'),
    "LightShowOn": ('включи красоту', 'включи огоньки', 'включи праздник', 'включи яркие огоньки'),
    "LightShowOff": ('выключи красоту', 'выключи огоньки', 'выключи праздник', 'выключи яркие огоньки', 'отключи красоту', 'отключи огоньки', 'отключи праздник', 'отключи яркие огоньки' ),
    "HumOn": ('включи увлажнитель', 'запусти увлажнитель', 'начать увлажнение'),
    "HumOff": ('выключи увлажнитель', 'отключи увлажнитель', 'прекратить увлажнение'),
    "robovacuum_start": ('запускай максима', 'выпускай монстра', 'выпускай зверя','начни уборку', 'начинай уборку', 'запусти пылесос'),
    "robovacuum_stop": ('останови максима', 'загони обратно монстра', 'угомони монстра', 'прекрати уборку', 'останови пылесос', 'останови уборку', 'хватит уборки'),
    "volup": ('громче', 'добавь громкость', 'сделай громче', 'добавь звук'),
    "voldown": ('тише', 'убавь громкость', 'сделай тише', 'убавь звук'),
    "volset": ('установи уровень громкости', 'уровень громкости', 'громкость на'),
    "usd_curs": ('курс доллара', 'цена доллара', 'стоимость доллара'),
    "usd_euro": ('курс евро', 'цена евро', 'стоимость евро'),
    "marko": ('марко', 'марка', 'марке'),
    "btc_usd": ('курс биткойна', 'цена биткойна', 'стоимость биткойна', 'биткоин'),
    "youtube_counter": ('статистика ютуб', 'как дела с ютубом', 'сколько подписчиков на ютубе', 'что с подписчиками'),
    "tv_pause": ('поставь на паузу', 'поставь телевизор на паузу', 'телевизор пауза', 'пауза'),
    "tv_play": ('сними с паузы', 'сними телевизор с паузы', 'запусти воспроизведение', 'воспроизведение')
}

Как можно видеть, наш словарь состоит из ключей и кортежей строк, в которых указаны варианты произношения команд. Как вы понимаете, стандартные методы сравнения строк здесь не сработают, точнее, могут сработать если вы заучите все команды наизусть ;) — но это не наш путь, поэтому мы будем использовать алгоритмы нечеткого сравнения. Для наших целей идеально подходит библиотека FuzzyWuzzy, которая в своем функционале использует метрику «Расстояние Левенштейна».

Для установки пакета воспользуемся следующей командой:

pip3 install fuzzywuzzy

И чтобы ускорить работу библиотеки в 4-10 раз (согласно официальной документации), установим дополнительный пакет:

pip3 install python-Levenshtein

Для нечеткого сравнения строк используется следующий метод:

fuzz.ratio('Строка один','Строка два')

Выводом данного метода является процент схожести строки. Для наглядности, запустим следующий пример функции с использованием нашего словаря команд:


def recognize_command(cmd: str):
    for c, v in command_dic.items():
        for x in v:
            similarity = fuzz.ratio(cmd, x)
        print(f"Совпадение команды: {similarity}% | Ключ: {c}")

if __name__ == "__main__":
    recognize_command(" время ")

После выполнения в терминале мы получим следующий вывод:

Совпадение команды: 15% | Ключ: help
Совпадение команды: 31% | Ключ: about
Совпадение команды: 60% | Ключ: ctime
Совпадение команды: 26% | Ключ: ask_chat_gpt
Совпадение команды: 33% | Ключ: lightON
Совпадение команды: 32% | Ключ: lightOFF
Совпадение команды: 33% | Ключ: SmartSwichON
Совпадение команды: 32% | Ключ: SmartSwichOFF
Совпадение команды: 22% | Ключ: SmartPARAM
Совпадение команды: 15% | Ключ: Weather
Совпадение команды: 23% | Ключ: Weather_temp
Совпадение команды: 21% | Ключ: temp_home
Совпадение команды: 17% | Ключ: air_home
Совпадение команды: 30% | Ключ: LightShowOn
Совпадение команды: 29% | Ключ: LightShowOff
Совпадение команды: 25% | Ключ: HumOn
Совпадение команды: 21% | Ключ: HumOff
Совпадение команды: 18% | Ключ: robovacuum_start
Совпадение команды: 20% | Ключ: robovacuum_stop
Совпадение команды: 22% | Ключ: volup
Совпадение команды: 24% | Ключ: voldown
Совпадение команды: 32% | Ключ: volset
Совпадение команды: 17% | Ключ: usd_curs
Совпадение команды: 29% | Ключ: usd_euro
Совпадение команды: 33% | Ключ: marko
Совпадение команды: 0% | Ключ: btc_usd
Совпадение команды: 16% | Ключ: youtube_counter
Совпадение команды: 0% | Ключ: tv_pause
Совпадение команды: 27% | Ключ: tv_play

где успешно определилась команда «ctime» с 60% схожести. И немного изменив функцию до следующего варианта:

def recognize_command(cmd: str):
    similarity_percent = 60
    command = 'no_data'
    for c, v in command_dic.items():
        for x in v:
            similarity = fuzz.ratio(cmd, x)
        print(f"Совпадение команды: {similarity}% | Ключ: {c}")
        if similarity >= similarity_percent:
            command = c
    return command

Мы уже можем отправлять ключ распознанной команды в обработчик команд. В качестве обработчика команд используется оператор match, ниже пример функции обработчика:

def command_processing(key: str):
    match key:
        case 'help':
            f_help()
        case 'about':
            f_about()
        case 'ctime':
            f_ctime()
        case 'lightON':
            f_lightON()
        case 'lightOFF':
            f_lightOFF()
        case _:
            print('Нет данных')

В данном обработчике выполняется код в операторе case, значение в котором соответствует проверяемому ключу. Ниже пример моей функции включения света:

def f_lightON():
    try:
        contents = urllib.request.urlopen("http://192.168.1.56/status").read()
        response0 = json.loads(contents)
        if response0['channel1'] == 'Off' and response0['channel2'] == 'Off':
            text = "Включила свет"
        if response0['channel1'] == 'On' and response0['channel2'] == 'Off':
            text = "Первый светильник уже включен, включила второй!"
        if response0['channel1'] == 'Off' and response0['channel2'] == 'On':
            text = "Второй светильник уже включен, включила первый!"
        if response0['channel1'] == 'On' and response0['channel2'] == 'On':
            text = "Свет уже включен! Но я могу выключить, если попросите!"
        if response0['channel1'] == 'Off':
            response2 = requests.get('http://192.168.1.56/powerS')
        if response0['channel2'] == 'Off':
            response2 = requests.get('http://192.168.1.56/powerS2')
        tts.speak(text)
    except:
        tts.speak("Сожалею, но возникла ошибка, попробуйте позже!")

Синтез речи


Как средство натурального взаимодействия между человеком и машиной, голосовой интерфейс заслуженно занимает высокую популярность, одной из составляющих данного взаимодействия является система синтеза речи. Современные системы синтеза речи, как правило, построены на базе алгоритмов машинного обучения, поэтому и в нашем проекте будем использовать подобные решения. В ходе поисков оптимального варианта для используемого в проекте одноплатного компьютера, я наткнулся на решение, которое отлично сочетало в себе скорость и качество синтеза речи — это бесплатные LM модели от компании Silero. Сама система синтеза речи построена на популярном фреймворке машинного обучения PyTorch. Вот пример реализации системы синтеза речи для данного проекта:

import os
import torch
import sounddevice as sd
import time

current_directory = os.getcwd()
path = 'model_tts'
isExist = os.path.exists(path)

if not isExist:
    os.makedirs(path)

local_file_ru = 'model_tts/4_ru_model.pt'
sample_rate = 24000  # 8000, 24000, 48000 - частота дискретизации генерируемого аудиопотока
speaker = 'kseniya'  # aidar, baya, kseniya, xenia, random - модель голоса
put_accent = True
put_yo = False
device = torch.device('cpu')  # cpu или gpu
torch.set_num_threads(8)  # количество задействованных потоков CPU

if not os.path.isfile(local_file_ru):
    torch.hub.download_url_to_file('https://models.silero.ai/models/tts/ru/v4_ru.pt', local_file_ru)

model = torch.package.PackageImporter(local_file_ru).load_pickle("tts_models", "model")

torch._C._jit_set_profiling_mode(False)
torch.set_grad_enabled(False)
model.to(device)
sd.default.device = 4  # аудиоустройство для вывода


def speak(text: str):
    audio = model.apply_tts(text=text + "..",
                            speaker=speaker,
                            sample_rate=sample_rate,
                            put_accent=put_accent,
                            put_yo=put_yo)

    sd.play(audio, sample_rate)
    time.sleep((len(audio) / (sample_rate)) + 0.5)
    sd.stop()
    del audio  # освобождаем память


if __name__ == "__main__":
    speak("Это тестовый синтез речи")

Для работы данного скрипта, необходимо установить следующие зависимости:

pip3 install numpy torch

Как можно видеть из примера, реализация в коде не сложная, что позволяет легко интегрировать данный метод синтеза речи в проект. Но есть один нюанс :) Пытливые умы уже, наверное, догадались о чем я. Дело в том, что при воспроизведении синтезированной речи наш алгоритм распознавания речи неизбежно будет задействован — это приведет к избыточной нагрузке на системные ресурсы нашего одноплатника, что крайне негативно скажется на работу системы в целом. Чтобы избежать подобного, воспользуемся небольшим трюком, изменим код функции системы распознавания речи следующим образом:


mic = 2                                   # адрес аудиоустройства микрофона
on_mic  = f'amixer -c {mic} set Mic cap'  # команда отключения глушилки
off_mic = f'amixer -c {mic} set Mic nocap'# команда на глушение микрофона

def speak(text: str):
    audio = model.apply_tts(text=text + "..",
                            speaker=speaker,
                            sample_rate=sample_rate,
                            put_accent=put_accent,
                            put_yo=put_yo)
    os.system(off_mic)      # глушим микрофон
    sd.play(audio, sample_rate)
    time.sleep((len(audio) / sample_rate) + 0.5)
    sd.stop()
    os.system(on_mic)        # отключаем глушилку микрофона
    del audio                # освобождаем память

Как можно видеть из комментариев к коду, мы реализовали «глушение» микрофона с помощью простой команды для командной строки ), это позволяет решить выше описанную проблему.

Еще одной неожиданной проблемой синтеза речи оказалось то, что модель «не понимает» числа, например если на вход подать строку «было 10 яблок», то на выходе мы получим синтез речи с фразой «было яблок». В дополнение к этой проблеме требовалось еще реализовать и склонение единиц измерения. Ниже представлен код, который решает эти проблемы:

import decimal

units = (
    u'ноль',

    (u'один', u'одна'),
    (u'два', u'две'),

    u'три', u'четыре', u'пять',
    u'шесть', u'семь', u'восемь', u'девять'
)

teens = (
    u'десять', u'одиннадцать',
    u'двенадцать', u'тринадцать',
    u'четырнадцать', u'пятнадцать',
    u'шестнадцать', u'семнадцать',
    u'восемнадцать', u'девятнадцать'
)

tens = (
    teens,
    u'двадцать', u'тридцать',
    u'сорок', u'пятьдесят',
    u'шестьдесят', u'семьдесят',
    u'восемьдесят', u'девяносто'
)

hundreds = (
    u'сто', u'двести',
    u'триста', u'четыреста',
    u'пятьсот', u'шестьсот',
    u'семьсот', u'восемьсот',
    u'девятьсот'
)

orders = (
    ((u'тысяча', u'тысячи', u'тысяч'), 'f'),
    ((u'миллион', u'миллиона', u'миллионов'), 'm'),
    ((u'миллиард', u'миллиарда', u'миллиардов'), 'm'),
)

minus = u'минус'


def thousand(rest, sex):
    prev = 0
    plural = 2
    name = []
    use_teens = 10 <= rest % 100 <= 19
    if not use_teens:
        data = ((units, 10), (tens, 100), (hundreds, 1000))
    else:
        data = ((teens, 10), (hundreds, 1000))
    for names, x in data:
        cur = int(((rest - prev) % x) * 10 / x)
        prev = rest % x
        if x == 10 and use_teens:
            plural = 2
            name.append(teens[cur])
        elif cur == 0:
            continue
        elif x == 10:
            name_ = names[cur]
            if isinstance(name_, tuple):
                name_ = name_[0 if sex == 'm' else 1]
            name.append(name_)
            if 2 <= cur <= 4:
                plural = 1
            elif cur == 1:
                plural = 0
            else:
                plural = 2
        else:
            name.append(names[cur - 1])
    return plural, name


def num2text(num, main_units=((u'', u'', u''), 'm')):
    _orders = (main_units,) + orders
    if num == 0:
        return ' '.join((units[0], _orders[0][0][2])).strip()

    rest = abs(num)
    ord = 0
    name = []
    while rest > 0:
        plural, nme = thousand(rest % 1000, _orders[ord][1])
        if nme or ord == 0:
            name.append(_orders[ord][0][plural])
        name += nme
        rest = int(rest / 1000)
        ord += 1
    if num < 0:
        name.append(minus)
    name.reverse()
    return ' '.join(name).strip()


def decimal2text(value, places=2,
                 int_units=(('', '', ''), 'm'),
                 exp_units=(('', '', ''), 'm')):
    value = decimal.Decimal(value)
    q = decimal.Decimal(10) ** -places

    integral, exp = str(value.quantize(q)).split('.')
    return u'{} {}'.format(
        num2text(int(integral), int_units),
        num2text(int(exp), exp_units))

Пример использования данного решения в функции текущего времени:

def f_ctime():
    now = datetime.datetime.now()
    text = "Сейч+ас "
    male_units = ((u'час', u'часа', u'часов'), 'm')
    text += digit_to_text.num2text(int(now.hour), male_units) + '.'
    male_units = ((u'минута', u'минуты', u'минут'), 'm')
    text += digit_to_text.num2text(int(now.minute), male_units) + '.'
    tts.speak(text)

По предыдущей статье, наверное, многие помнят, что на аудио плате присутствует сигнал управления усилителем (mute) и светодиод для индикации активности умной колонки. Для управления данными штуками скорректируем код следующим образом:

import os
import torch
import sounddevice as sd
import time
mic = 2                                   # адрес аудиоустройства микрофона
on_mic  = f'amixer -c {mic} set Mic cap'  # команда отключения глушилки
off_mic = f'amixer -c {mic} set Mic nocap'# команда на глушение микрофона

os.system("gpio mode 18 out")             # устанавливаем режим пина разрешения усилителя
os.system("gpio mode 16 out")             # устанавливаем режим пина светодиодного индикатора
os.system("gpio write 18 0")              # Отключаем усилитель чтобы не шумел

current_directory = os.getcwd()
path = 'model_tts'
isExist = os.path.exists(path)

if not isExist:
    os.makedirs(path)

local_file_ru = 'model_tts/4_ru_model.pt'
sample_rate = 24000  # 8000, 24000, 48000 - частота дискретизации генерируемого аудиопотока
speaker = 'kseniya'  # aidar, baya, kseniya, xenia, random - модель голоса
put_accent = True
put_yo = False
device = torch.device('cpu')  # cpu или gpu
torch.set_num_threads(8)  # количество задействованных потоков CPU

if not os.path.isfile(local_file_ru):
    torch.hub.download_url_to_file('https://models.silero.ai/models/tts/ru/v4_ru.pt', local_file_ru)

model = torch.package.PackageImporter(local_file_ru).load_pickle("tts_models", "model")

torch._C._jit_set_profiling_mode(False)
torch.set_grad_enabled(False)
model.to(device)
sd.default.device = 4  # аудиоустройство для вывода


def speak(text: str):
    audio = model.apply_tts(text=text + "..",
                            speaker=speaker,
                            sample_rate=sample_rate,
                            put_accent=put_accent,
                            put_yo=put_yo)
    os.system(off_mic)             # глушим микрофон
    os.system("gpio write 18 1")   # разрешающий сигнал для усилителя
    os.system("gpio write 16 1")   # зажикаем светодиод индикатора
    sd.play(audio, sample_rate)
    time.sleep((len(audio) / sample_rate) + 0.5)
    sd.stop()
    os.system(on_mic)              # отключаем глушилку микрофона
    os.system("gpio write 18 0")   # отключаем усилитель
    os.system("gpio write 16 0")   # отключаем индикатор
    del audio                       # освобождаем память


if __name__ == "__main__":
    speak("Это тестовый синтез речи")

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

Зови её по имени


Как вы могли догадаться, для работы с голосовым ассистентом необходимо использовать фразу для активации. В своей умной колонке для этих целей я решил использовать имя Альфа. На выбор данного имени очень повлиял сериал от Apple «Экстраполяции», где была корпорация «Альфа» с вездесущим одноименным голосовым ассистентом. Ниже показан пример активации:


Для начала нам нужно создать кортеж из вариаций произношения имени:

sys_alias= ('альфа', 'альф', 'альфа',  'альфу', 'альфи')

Далее давайте поговорим об алгоритме активации. По сути, алгоритм прост и его можно выразить следующим описанием: из потока текстовых данных определяем наличие имени Альфа -> При наличии имени активируем следующий этап: определение команды -> и далее, выполнение команды. Как только наш ассистент обнаружит своё имя, он издаст звуковой сигнал совместно со световой индикацией и будет ожидать последующей фразы с командой. Как это выглядит в коде:

функция распознавания имени
def name_recognize(name: str):
    words = name.split()
    stat = False
    for item in sys_alias:
        similarity = fuzz.ratio(item, words[0])
        if similarity > 70:
           stat = True

    return stat

Данная функция также использует нечеткое сравнение строк и после совпадения имени, возвращает логическое значение True. Так как на вход функции могут поступать текстовые данные в виде фразы, то нам необходимо выполнить преобразование строки в кортеж слов с помощью метода name.split() и для сравнения использовать нулевой элемент созданного кортежа. Ниже пример кода обработки текстовых данных, для активации и направления текста фразы для определения команд:

def response(voice: str):
    if glob_var.read_bool_wake_up():                  # этап второй, распознавание команды
        command_processing(recognize_command(voice))  # распознавание и выполнение команды
        glob_var.set_bool_wake_up(False)              # после выполнения команды, перехохим в режим распознования имени
    glob_var.set_bool_wake_up(name_recognize(voice))  # проверяем наличие имени в потоке
    if glob_var.read_bool_wake_up():                  # если имя обнаружено, воспроизводим звуковой сигнал
        tts.play_wakeup_sound('notification.wav')

Как можно заметить, для облегчения работы с глобальными данными, был создан скрипт с именем glob_var:

hears    = False
gpt_bool = False
wake_up  = False
voice    = ''
volset   = 0

def set_bool_mic(bools):
    global hears
    hears = bools
    print(hears)

def read_bool_mic():
    global hears
    return hears
    
def set_bool_gpt(bools):
    global gpt_bool
    gpt_bool = bools
    print(gpt_bool)

def read_bool_gpt():
    global gpt_bool
    return gpt_bool

def set_bool_wake_up(bools):
    global wake_up
    wake_up = bools
    print(wake_up)

def read_bool_wake_up():
    global wake_up
    return wake_up

def set_volset(vol):
    global volset
    volset = vol
    print(vol)

def read_volset():
    global volset
    return volset

def set_voice(voices):
    global voice
    voice = voices
    print(voices)

def read_voice():
    global voice
    return voice

И для воспроизведения звукового сигнала при обнаружении имени, используется следующая функция:


file_path_n = current_directory + '/sound/notification.wav'  # Извлечение данных и частоты дискретизации из файла
data_n, fs_n = sf.read(file_path_n, dtype='float32') 
file_path_logo = current_directory + '/sound/start_logo.wav'  # Извлечение данных и частоты дискретизации из файла
data_logo, fs_logo = sf.read(file_path_logo, dtype='float32') 

def play_wakeup_sound(sound: str):
    global data_logo, fs_logo
    global data_n, fs_n
    if sound.__eq__('notification.wav'):
       data = data_n
       fs   = fs_n
    else:
       data = data_logo
       fs   = fs_logo
    sd.play(data, fs)
    os.system("gpio write 18 1")
    os.system("gpio write 16 1")
    sd.wait()                                  # Ждем окончания воспроизведения файла
    os.system("gpio write 18 0")
    sd.stop() 

куда в качестве аргумента передается имя файла для воспроизведения. Данная функция также используется для воспроизведения приветственного аудио файла при запуске системы. Ниже на видео показан первоначальный запуск голосового ассистента.


Также в нашем проекте реализована функция регулировки громкости на программном уровне, но это уже другая история. Демонстрацию реализации вы можете видеть ниже:


Итоги


В этой статье я попытался очень кратко и доходчиво описать основное «ядро» своей DIY умной колонки без фантиков и плюшек. Надеюсь, статья не утомила и будет вам полезна. Есть предложения, идеи, замечания? Добро пожаловать в комментарии. Благодарю за внимание и всем бобра!

Ссылки к статье





Читайте также:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

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


  1. Alyoshka1976
    18.06.2024 08:21
    +1

    Я сделал похожую систему с распознаванием и генерацией голоса, но на старом ноутбуке под Linux - одного ключевого слова оказалось маловато, частые ложные срабатывания, ожидание классического "СЛУШАЙ ИМЯ_АССИСТЕНТА" работает лучше.


    1. CyberexTech Автор
      18.06.2024 08:21

      Ложных срабатываний не наблюдал, были события когда в стороннем источнике звука (просмотр ютуб или фильма) присутствовало имя Альфа или что-то похожее(Алиса например), тогда да, срабатывало. В крайнем случае, можно увеличить порог процента схожести строки, чтобы избежать ложных срабатываний.


      1. Alyoshka1976
        18.06.2024 08:21

        Именно такие срабатывания я и обозвал "ложными", ютуб-тестирование их хорошо провоцирует.


        1. CyberexTech Автор
          18.06.2024 08:21

          Очень редкие события, пару раз за год использования.


          1. grumegargler
            18.06.2024 08:21

            Интересно, у нас другой опыт. Разрабатывали для бизнеса голосового помощника (на raspberry pi 5), и быстро пришли к тому, что вся эта затея будет иметь смысл только при условии максимально качественного распознавания как ключевого слова, так и всей речи. К сожалению бесплатные решения, пришлось заменить на коммерческие. Тот же porcupine например, существенно лучше показывает себя в зашумленных помещениях (цех, гараж), да и в офисе откликается в 9 случаев из 10, в то время, как всё остальное, что пробовали, в лучшем случае 7-8 из 10.


            1. CyberexTech Автор
              18.06.2024 08:21

              Тут нужно понимать, что в статье речь идет о DIY проекте и каких-то супер возможностях, как в коммерческих продуктах, речи не идет. Само собой, что в коммерческих проектах не стоит применять описанные в статье решения, так же стоит учитывать, что при коммерческой разработке бюджет "шире" и команда больше.


              1. grumegargler
                18.06.2024 08:21
                +1

                Несомненно. Я оставил свой комментарий лишь по той причине, что после прочтения вашей по настоящему полезной статьи, у неискушенного читателя может сложиться обманчивое впечатление, что всё просто, всё работает. Отчасти это так, но и нюансы есть, которые могут выйти на первый план, если задача будет немного отличаться. Воспринимайте это просто как дополнение, а не контраргументы.


      1. Alyoshka1976
        18.06.2024 08:21
        +1

        P.S. В качестве сценариев, которых нет у Вас в списке, можно добавить отправку заметок в Телеграм (на имя созданного там бота) (хотя ассистент в моей системе ограничен тем, что преобразует в текст только те слова, которые есть в словаре STT, но бывает удобно) и установка таймера (на команду "три", например, ждет три минуты и проигрывает мелодию).


        1. CyberexTech Автор
          18.06.2024 08:21
          +1

          Да, здесь большие возможности по расширению функционала. Можно и в Гугл календарь добавлять заметки и тд/тп/


  1. chipsetx86
    18.06.2024 08:21
    +2

    Отличная статья, молодец!


    1. CyberexTech Автор
      18.06.2024 08:21

      Спасибо за поддержку!


  1. uhf
    18.06.2024 08:21

    Где бы готовое железо взять? Может прошиваются уже какие-нибудь серийные колонки?


    1. CyberexTech Автор
      18.06.2024 08:21

      Это мой эксперимент, цель которого проверить возможность разработки голосового ассистента для умного дома на дешевом железе. В предыдущей статье я описал аппаратную реализацию: https://habr.com/ru/articles/772080/

      В теории должно работать на любых устройствах на базе Linux, главное чтобы хватило производительности системных ресурсов.


      1. uhf
        18.06.2024 08:21
        +1

        Спасибо, я уже прочел вашу предыдущую статью. Мне просто не очень хочется "колхозить" корпус, травить плату, и т.д

        Вот только что идея появилась: взять какую-нибудь Яндекс-Алису, вынуть из нее все кишки, вставить esp8266 для кнопок и светодиодов, bt-модуль для динамика и микрофона, ну и всё. Всю обработку делать на другом одноплатнике, или мини-пк, или на домашнем сервере, тут уже как нравится. Зато ограничений не будет, хоть LLM там крути на видеокарте. Надо будет попробовать.


        1. CyberexTech Автор
          18.06.2024 08:21

          Можно заказать печать корпуса и изготовление платы, благо сейчас это не проблема. И никакого "колхоза".


  1. Olegun
    18.06.2024 08:21

    Как дела с безопасностью? Слушается только хозяина или всех? Телевизор? Радио?


    1. CyberexTech Автор
      18.06.2024 08:21

      Что вы имеете ввиду под безопасностью? Если речь идет о приватности, то все процессы выполняются локально. Я не ставил цели распределение ролей с помощью идентификации голоса. Слушает всех, кто произнесет её имя.


  1. Pol1mus
    18.06.2024 08:21

    Вместо silero попробуй piper tts, работает лучше и быстрее, цифры читать умеет.


    1. CyberexTech Автор
      18.06.2024 08:21

      Там модели не очень на мое восприятие (


  1. Pol1mus
    18.06.2024 08:21

    Если задача выживать в бункере не стоит то всё сильно упрощается. Бесплатное распознавание голоса есть у гугла и.. гугла(gemini), работает почти идеально. Бесплатный tts есть у гугла и микрософта, без заморочек с цифрами и сокращениями, и вообще без заморочек.

    Бесплатные большие ллм у groq и google gemini, у них заморочки только с проксями.

    Бесплатные маленькие ллм - openrouter, там дают ллама3-8б итп мелочевку.


    1. smart_alex
      18.06.2024 08:21
      +8

      Тут вся фишка в том, чтобы отвязаться от гуглов, яндексов и прочих подобных.


    1. CyberexTech Автор
      18.06.2024 08:21
      +3

      Приватность и изоляция от интернет зависимых сервисов.


  1. hMartin
    18.06.2024 08:21

    Я натыкался на такое DIY решение: https://heywillow.io/

    Чувак пилит открытую замену Алексе, но вроде она неразговорчивая, команды выполняет, а TTS нет


    1. CyberexTech Автор
      18.06.2024 08:21
      +1

      Насколько я понял, аппаратная часть его проекта построена на базе esp32, сомнительное решение, ну Ок. Такие вещи лучше строить на базе железа, где есть NPU или графическое ядро. В моем проекте есть возможность использовать API Vulkan встроенного графического ядра, что обеспечивает значительное ускорение работы моделей.


      1. WNeZRoS
        18.06.2024 08:21

        У вас получилось запустить Vulkan на Orange Pi Zero 2W? Как именно?
        В Mesa есть рабочее ускорение только для OpenGL, а Vulkan через llvmpipe драйвер это не ускорение, а замедление.


        1. CyberexTech Автор
          18.06.2024 08:21

          Я еще не пробовал, но драйвер установлен. Если у вас работает OpenGL, то можно и его использовать. А так на CPU нормально работает.


  1. uvlad7
    18.06.2024 08:21

    Для распознавания можно указать список возможных слов или фраз:

    model = Model(model_name="vosk-model-en-us-0.22-lgraph")
    
    trigger_words = ["please enter the number you hear", "please type the numbers you hear"]
    numbers = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "zero"]
    
    # You can also specify the possible word or phrase list as JSON list,
    # the order doesn't have to be strict
    rec = KaldiRecognizer(model,
        wf.getframerate(),
        f'[{", ".join(map(lambda s: json.dumps(s), trigger_words + numbers))}, "[unk]"]')
    
    while True:
        data = wf.readframes(4000)
        if len(data) == 0:
            break
        if rec.AcceptWaveform(data):
            print(json.loads(rec.Result())["text"], end=', ')
    
    print(json.loads(rec.FinalResult())["text"])
    

    Так можно избавиться от необходимости подбирать похожие команды


  1. boingo-00
    18.06.2024 08:21

    А что насчет RHVoice? На телефоне стоит, голос немного "роботический", но читает нормально