Создание аналога PLAUD Note из диктофона кулона и Raspberry Pi 4

Пост опубликован в блогах iXBT.com, его автор не имеет отношения к редакции iXBT.com

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


Я так и не выработал привычку записывать что-то в блокноте, с переменным успехом записываю что-то в календарь или в заметки в ТГ, но это не то, нужен список задач, отдельно записанные рассуждения или записи в дневник. Давно наблюдаю за устройствами Plaud, но цена на них кусается, да и тянет меня на создание таких сервисов своими руками.

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

В качестве хардварной составляющей были выбраны.

1. Диктофон-кулон с озона.

2. Raspberry Pi 4.


Распознавание речи: Yandex Speech Kit

Обработка текста и генерация команд для трекера: Deepseek API

Трекер: YouGile

Ну и написание скриптов, ибо сам я такое не сделаю: Gemini

Ниже схема работы сервиса.


[ Диктофон (ON/OFF) ]
         │  (Запись в /RECORD)

         ▼

[ USB-подключение к Малинке ]

         ▼

[ watcher.py (каждые 3 сек) ] ──(монтирование /mnt/recorder)──┐

         ▼                                                    │

(Перенос через shutil.move)                                   │

         ▼                                                    │

[ Размонтирование / Зарядка ]                                 │

         ▼                                                    │

[ processor.py ] 

         ├──► [ Yandex SpeechKit ] (Текст)

         │         ▼

         ├──► [ DeepSeek API ] (JSON: проект, тип, дедлайн)

         ▼

[ YouGile API ] ──► Создание карточки + Архивация аудио

В итоге вся реализация заняла два часа, с условием того, что все сервисы я использовал ранее и у меня был настроен SpeechKit и Deepseek api с пополненным счётом(кстати вполне себе удобно пополняется через Alipay).

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

В итоге получаем готовый сервис, записанные задачи, которые я могу при наличии свободного времени просматривать и разгребать. Теперь главное не забывать подключать диктофон раз в деньь на работе к малинке. Посмотрим, изменит ли это мою жизнь, если решение приживётся, добавлю туда отправку записей в ТГ и создание событий в гугл календаре.

Все скрипты ниже, не все они оптимальны, тот же опрос факта подключения флешки раз в 3 секунды с точки зрения автоматизации дикий костыль, но на текущий момент задачи это выполняет. Буду рад дополнениям, идеям, да и критике пожалуй)

Подготовка окружения

# Обновляем пакеты и ставим системные утилиты (ffmpeg нужен, если в будущем будете конвертировать форматы, uhubctl — для питания USB)
sudo apt update && sudo apt install -y 
    python3-pip 
    python3-venv 
    ffmpeg 
    uhubctl 
    git 
    lsblk

#Создание структуры папок проекта
mkdir -p $HOME/voice-assistant/scripts
mkdir -p $HOME/voice-assistant/inbox/raw
mkdir -p $HOME/voice-assistant/archive/audio
mkdir -p $HOME/voice-assistant/logs

#Создание и настройка виртуального окружения (venv)

cd $HOME/voice-assistant
python3 -m venv venv
$HOME/voice-assistant/venv/bin/pip install --upgrade pip
$HOME/voice-assistant/venv/bin/pip install requests python-dotenv


#Создание файла конфигурации .env
cat << 'EOF' > $HOME/voice-assistant/.env
YOUGILE_API_TOKEN=
YANDEX_STT_API_KEY=
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1

YOUGILE_COLUMN_HOME_TASKS=
YOUGILE_COLUMN_HOME_IDEAS=
YOUGILE_COLUMN_HOME_THOUGHTS=
YOUGILE_COLUMN_WORK_TASKS=
YOUGILE_COLUMN_WORK_IDEAS=
YOUGILE_COLUMN_WORK_THOUGHTS=
EOF

# Защищаем файл, чтобы секреты не светились
chmod 600 $HOME/voice-assistant/.env

Шаблон файла переменных окружения (.env)

Создайте в корне проекта ($HOME/voice-assistant/.env) файл со следующим содержимым и подставьте свои реальные данные:

# --- API Ключи ---
# Токен бота или аккаунта YouGile (получается в настройках компании/пользователя)
YOUGILE_API_TOKEN=your_yougile_api_token_here

# API-ключ Yandex SpeechKit (для распознавания речи в текст)
YANDEX_STT_API_KEY=your_yandex_speech_key_here

# API-ключ DeepSeek (или совместимого OpenAI-провайдера)
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1

# --- ID колонок / досок YouGile ---
# Идентификаторы досок и колонок, куда бот будет раскладывать карточки в зависимости от типа и проекта
YOUGILE_COLUMN_HOME_TASKS=uuid_column_home_tasks_here
YOUGILE_COLUMN_HOME_IDEAS=uuid_column_home_ideas_here
YOUGILE_COLUMN_HOME_THOUGHTS=uuid_column_home_thoughts_here

YOUGILE_COLUMN_WORK_TASKS=uuid_column_work_tasks_here
YOUGILE_COLUMN_WORK_IDEAS=uuid_column_work_ideas_here
YOUGILE_COLUMN_WORK_THOUGHTS=uuid_column_work_thoughts_here

Фоновый наблюдатель ($HOME/voice-assistant/scripts/watcher.py)

Демон, который крутится от root, мониторит шину USB, монтирует диктофон, безопасно переносит файлы через shutil. move и вызывает обработчик:

import os
import time
import shutil
import subprocess

MOUNT_POINT = "/mnt/recorder"
BASE_DIR = "/home/rurmanov/voice-assistant"
INBOX_RAW = os.path.join(BASE_DIR, "inbox/raw")
LOG_FILE = os.path.join(BASE_DIR, "logs/usb_ingest.log")
PROCESSOR_SCRIPT = os.path.join(BASE_DIR, "scripts/processor.py")
PYTHON_BIN = os.path.join(BASE_DIR, "venv/bin/python")

def log(msg):
    ts = time.strftime("%Y-%m-%d %H:%M:%S")
    line = f"{ts} [WATCHER] {msg}"
    print(line)
    os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(line + "n")

def find_recorder_device():
    try:
        output = subprocess.check_output(["lsblk", "-o", "NAME,LABEL,FSTYPE", "-n", "-r"], text=True)
        for line in output.splitlines():
            parts = line.split()
            if len(parts) >= 2 and parts[1] == "VOICE":
                return f"/dev/{parts[0]}"
    except Exception:
        pass
    return None

log("Стабильный наблюдатель запущен (режим root)...")

while True:
    try:
        dev = find_recorder_device()
        if dev:
            log(f"Обнаружен диктофон на {dev}")
            subprocess.run(["umount", dev], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            
            os.makedirs(MOUNT_POINT, exist_ok=True)
            res = subprocess.run(["mount", "-o", "rw,uid=1000,gid=1000,iocharset=utf8", dev, MOUNT_POINT])
            
            count = 0
            if res.returncode == 0:
                rec_dir = os.path.join(MOUNT_POINT, "RECORD")
                if os.path.isdir(rec_dir):
                    files = [f for f in os.listdir(rec_dir) if f.lower().endswith(('.wav', '.mp3'))]
                    count = len(files)
                    log(f"Найдено новых записей в RECORD: {count}")
                    
                    for f in files:
                        src = os.path.join(rec_dir, f)
                        dst = os.path.join(INBOX_RAW, f)
                        try:
                            shutil.move(src, dst)
                            log(f"Успешно перенесен файл: {f} -> inbox/raw/")
                        except Exception as ex:
                            log(f"Ошибка переноса файла {f}: {ex}")
                    
                    if count > 0:
                        subprocess.run(["sync"])
                
                subprocess.run(["umount", MOUNT_POINT], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                log("Диктофон размонтирован.")
                
                if count > 0:
                    log("Запуск процессора обработки от имени пользователя rurmanov...")
                    proc_res = subprocess.run(["sudo", "-u", "rurmanov", PYTHON_BIN, PROCESSOR_SCRIPT], capture_output=True, text=True)
                    log(f"Результат процессора: код {proc_res.returncode}")
                    if proc_res.stdout:
                        log(f"Вывод процессора: {proc_res.stdout.strip()}")
                    if proc_res.stderr:
                        log(f"Ошибки процессора: {proc_res.stderr.strip()}")
            
            while find_recorder_device() is not None:
                time.sleep(3)
            log("Диктофон отключен пользователем.")
            
    except Exception as e:
        log(f"Критическая ошибка в цикле наблюдателя: {e}")
        
    time.sleep(3)

Systemd-служба (/etc/systemd/system/voice-watcher.service)

Конфигурация демона автозапуска для Linux:

[Unit]
Description=USB Voice Recorder Background Watcher
After=network.target local-fs.target

[Service]
Type=simple
User=root
ExecStart=/home/rurmanov/voice-assistant/venv/bin/python /home/rurmanov/voice-assistant/scripts/watcher.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Команды для активации службы:

sudo systemctl daemon-reload
sudo systemctl enable --now voice-watcher.service

Код скрипта обработки ($HOME/voice-assistant/scripts/processor.py)

import os
import time
import shutil
import logging
import requests
from dotenv import load_dotenv

# Загружаем окружение
load_dotenv(dotenv_path="/home/rurmanov/voice-assistant/.env")

BASE_DIR = "/home/rurmanov/voice-assistant"
INBOX_DIR = os.path.join(BASE_DIR, "inbox/raw")
ARCHIVE_DIR = os.path.join(BASE_DIR, "archive/audio")
LOG_FILE = os.path.join(BASE_DIR, "logs/processor.log")

# Настройка логирования
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    encoding="utf-8"
)

# Читаем ключи и настройки
YOUGILE_TOKEN = os.getenv("YOUGILE_API_TOKEN")
YANDEX_KEY = os.getenv("YANDEX_STT_API_KEY")
DEEPSEEK_KEY = os.getenv("DEEPSEEK_API_KEY")
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")

# Маппинг колонок YouGile из .env
COLUMN_MAPPING = {
    "home": {
        "tasks": os.getenv("YOUGILE_COLUMN_HOME_TASKS"),
        "ideas": os.getenv("YOUGILE_COLUMN_HOME_IDEAS"),
        "thoughts": os.getenv("YOUGILE_COLUMN_HOME_THOUGHTS"),
    },
    "work": {
        "tasks": os.getenv("YOUGILE_COLUMN_WORK_TASKS"),
        "ideas": os.getenv("YOUGILE_COLUMN_WORK_IDEAS"),
        "thoughts": os.getenv("YOUGILE_COLUMN_WORK_THOUGHTS"),
    }
}

def transcribe_yandex(audio_path):
    """Отправка аудио в Yandex SpeechKit STT"""
    url = f"https://stt.api.cloud.yandex.net/speech/v1/stt:recognize?lang=ru-RU"
    headers = {"Authorization": f"Api-Key {YANDEX_KEY}"}
    
    with open(audio_path, "rb") as f:
        audio_data = f.read()
        
    response = requests.post(url, headers=headers, data=audio_data)
    if response.status_code == 200:
        result = response.json()
        text = result.get("result", "").strip()
        logging.info(f"Распознанный текст: «{text}»")
        return text
    else:
        logging.error(f"Ошибка Yandex STT: {response.status_code} - {response.text}")
        return None

def analyze_with_deepseek(text):
    """Анализ текста через DeepSeek LLM с возвратом структурированного JSON"""
    import json
    url = f"{DEEPSEEK_BASE_URL}/chat/completions"
    headers = {
        "Authorization": f"Bearer {DEEPSEEK_KEY}",
        "Content-Type": "application/json"
    }
    
    prompt = f"""
Ты — умный ассистент для разбора голосовых заметок. Проанализируй текст и разбей его на одну или несколько задач/идей.
Верни строго валидный JSON-массив объектов без текста вокруг (без markdown-оформления ```json ... ```), следующей структуры:
[
  {{
    "project": "home" или "work",
    "type": "tasks" или "ideas" или "thoughts",
    "title": "Краткая суть задачи",
    "color": "task-red" или null,
    "due": "YYYY-MM-DD" или null
  }}
]

Текст заметки: "{text}"
"""

    payload = {
        "model": "deepseek-chat",
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.1
    }
    
    try:
        response = requests.post(url, headers=headers, json=payload, timeout=30)
        if response.status_code == 200:
            content = response.json()["choices"][0]["message"]["content"].strip()
            # Убираем markdown обертку если LLM её всё же добавила
            if content.startswith("```"):
                content = content.split("```")[1]
                if content.startswith("json"):
                    content = content[4:]
            return json.loads(content.strip())
        else:
            logging.error(f"Ошибка DeepSeek API: {response.text}")
            return []
    except Exception as e:
        logging.error(f"Ошибка парсинга ответа DeepSeek: {e}")
        return []

def create_yougile_card(task_data):
    """Создание карточки в YouGile через REST API"""
    project = task_data.get("project", "home")
    task_type = task_data.get("type", "thoughts")
    
    column_id = COLUMN_MAPPING.get(project, {}).get(task_type)
    if not column_id:
        logging.warning(f"Не найден ID колонки для project={project}, type={task_type}. Используем дефолтную.")
        column_id = COLUMN_MAPPING["home"]["thoughts"]
        
    url = "https://ru.yougile.com-api-v2proxy or official/api-v2/tasks" # (или ваш актуальный эндпоинт YouGile API v2)
    url = "https://api.yougile.com/api-v2/tasks"
    
    headers = {
        "Authorization": f"Bearer {YOUGILE_TOKEN}",
        "Content-Type": "application/json"
    }
    
    payload = {
        "columnId": column_id,
        "title": task_data.get("title", "Заметка без названия")
    }
    
    if task_data.get("color"):
        payload["color"] = task_data["color"]
    if task_data.get("due"):
        payload["deadline"] = {"date": task_data["due"]}
        
    response = requests.post(url, headers=headers, json=payload)
    if response.status_code in [200, 201]:
        res_json = response.json()
        card_id = res_json.get("id")
        logging.info(f"Создана карточка YouGile [{project}/{task_type}]: {task_data['title']} (ID: {card_id})")
    else:
        logging.error(f"Ошибка создания карточки в YouGile: {response.status_code} - {response.text}")

def main():
    files = [f for f in os.listdir(INBOX_DIR) if f.lower().endswith(('.wav', '.mp3', '.WAV'))]
    if not files:
        logging.info("Новых аудиофайлов в inbox/raw не найдено.")
        print("Новых аудиофайлов в inbox/raw не найдено.")
        return

    for filename in sorted(files):
        file_path = os.path.join(INBOX_DIR, filename)
        logging.info(f"Начинаю обработку файла: {filename}")
        print(f"Обработка: {filename}")
        
        # 1. Распознавание
        text = transcribe_yandex(file_path)
        if not text:
            continue
            
        # 2. Анализ нейронкой
        tasks = analyze_with_deepseek(text)
        
        # Если LLM ничего не развернула в список, создаем просто сырую заметку
        if not tasks:
            tasks = [{"project": "home", "type": "thoughts", "title": text, "color": None, "due": None}]
            
        # 3. Пушим в YouGile
        for task in tasks:
            create_yougile_card(task)
            
        # 4. Архивация исходника
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        archive_name = f"{timestamp}_{filename}"
        archive_path = os.path.join(ARCHIVE_DIR, archive_name)
        
        shutil.move(file_path, archive_path)
        logging.info(f"Файл {filename} успешно обработан и перемещён в {archive_path}")

if __name__ == "__main__":
    main()

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

Новости

Публикации