Голосовой ввод в Claude на русском — бесплатно и офлайн за один вечер

от автора

Автор: Дмитрий Сосунов совместно с Claude
Уровень: для тех кто не программист, но не боится терминала
Время: один вечер
Результат: иконка в строке меню Mac → нажал Запустить → говоришь в Claude


Я менеджер, финансист без технического бэкграунда. Мне кайфово диктовать а не печатать, так и не смог я освоить слепой метод, печатаю глядя на клаву. Хочу диктовать. Но!

ПРОБЛЕМА: В Claude пока нет русского языка для диктовки.

Платные решения вроде Voicy работают, но у них лимиты, они требуют интернет и стоят денег. У меня уже был установлен Whisper — тот самый движок OpenAI который все платные решения используют под капотом. Я спросил Claude: можно ли сделать это самому? Оказалось — да, за один вечер.

Вот рассказ о том как мы это построили — включая все грабли на которые наступили по пути.


Что в итоге получилось

слева иконка, которую можно расширять разными сервисами

слева иконка, которую можно расширять разными сервисами
  • Иконка 🤖 в строке меню Mac — всегда под рукой

  • Кликнул → выбрал «Запустить» → иконка стала 🎤

  • Нажал кнопку на claude.ai — говоришь по-русски — текст вставляется в поле

  • Whisper работает локально — никаких запросов в облако, никаких лимитов

  • Архитектура расширяемая: захочешь добавить новый AI-инструмент — одна строка в конфиге

  • Бесплатно навсегда


Финальная архитектура

🤖 Menu Bar App (rumps)    └── ▶ Запустить Whisper → Claude            ↓    Python Flask сервер (localhost:5555)            ↓    Chrome Extension (кнопка 🎤 на claude.ai)            ↓ аудио в base64    Background Service Worker            ↓ POST запрос    /opt/homebrew/bin/whisper → текст → поле ввода Claude

Почему такая цепочка а не проще — объясню по ходу. Каждый элемент появился не случайно.


Что понадобится

  • macOS (на Linux аналогично с поправкой на пути)

  • Whisper: brew install openai-whisper

  • ffmpeg: brew install ffmpeg (Whisper использует его для декодирования аудио)

  • Python 3: python3 --version

  • Chrome браузер

Проверь что Whisper работает:

whisper --version

Должен появиться длинный список языков включая Russian. Если видишь список — всё готово.

Шаг 1 — Структура проекта

mkdir -p /путь/к/проектам/whisper-claudecd /путь/к/проектам/whisper-claudepython3 -m venv venvsource venv/bin/activatepip install flask flask-cors rumpsmkdir -p extension/icons

Почему виртуальная среда? macOS начиная с Ventura запрещает устанавливать пакеты глобально — это защита от поломки системного Python. Виртуальная среда решает это чисто.

rumps — Python библиотека для создания Menu Bar приложений на Mac. Позволяет сделать иконку в строке меню в 50 строках кода вместо сотен строк на Swift.


Шаг 2 — Python сервер (server.py)

from flask import Flask, request, jsonifyfrom flask_cors import CORSimport subprocessimport tempfileimport osimport threadingapp = Flask(__name__)CORS(app)  # разрешаем запросы от Chrome расширенияWHISPER_PATH = "/opt/homebrew/bin/whisper"WHISPER_MODEL = "small"  # tiny/base/small/medium/largePORT = 5555@app.route("/ping", methods=["GET"])def ping():    return jsonify({"status": "ok"})@app.route("/transcribe", methods=["POST"])def transcribe():    if "audio" not in request.files:        return jsonify({"error": "Аудио файл не найден"}), 400    audio_file = request.files["audio"]    with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tmp:        tmp_path = tmp.name        audio_file.save(tmp_path)    try:        result = subprocess.run(            [                WHISPER_PATH, tmp_path,                "--model", WHISPER_MODEL,                "--language", "ru",                "--output_format", "txt",                "--output_dir", tempfile.gettempdir(),                "--fp16", "False",  # обязательно для Mac Apple Silicon            ],            capture_output=True, text=True, timeout=60        )        txt_path = tmp_path.replace(".webm", ".txt")        if os.path.exists(txt_path):            with open(txt_path, "r", encoding="utf-8") as f:                text = f.read().strip()            os.unlink(txt_path)        else:            text = result.stdout.strip()        return jsonify({"text": text})    except subprocess.TimeoutExpired:        return jsonify({"error": "Timeout"}), 500    except Exception as e:        return jsonify({"error": str(e)}), 500    finally:        os.unlink(tmp_path)@app.route("/shutdown", methods=["POST"])def shutdown():    threading.Timer(0.5, lambda: os._exit(0)).start()    return jsonify({"status": "shutting down"})if __name__ == "__main__":    print(f"[Whisper] Запускаюсь на порту {PORT}, модель: {WHISPER_MODEL}")    app.run(host="127.0.0.1", port=PORT, debug=False)

Грабля #1: --fp16 False обязателен на Mac с Apple Silicon. Без него Whisper падает с ошибкой про float16.


Шаг 3 — Menu Bar приложение (menubar.py)

Это сердце системы. Одна иконка в строке меню управляет всеми AI-инструментами. Новый инструмент добавляется одной записью в список TOOLS.

import rumpsimport subprocessimport threadingimport osimport timeimport urllib.requestBASE_DIR = "/путь/к/проекту/whisper-claude"VENV_PYTHON = os.path.join(BASE_DIR, "venv/bin/python3")# ═══════════════════════════════════════# ДОБАВЛЯЙ НОВЫЕ ИНСТРУМЕНТЫ СЮДА# ═══════════════════════════════════════TOOLS = [    {        "name": "Whisper → Claude",        "script": os.path.join(BASE_DIR, "server.py"),        "port": 5555,        "description": "Голосовой ввод на русском",    },    # Следующий инструмент — просто добавь блок:    # {    #     "name": "Название",    #     "script": os.path.join(BASE_DIR, "other_tool.py"),    #     "port": 5556,    #     "description": "Описание",    # },]def is_port_alive(port, timeout=1):    try:        urllib.request.urlopen(f"http://127.0.0.1:{port}/ping", timeout=timeout)        return True    except:        return Falsedef stop_port(port):    try:        req = urllib.request.Request(            f"http://127.0.0.1:{port}/shutdown", method="POST"        )        urllib.request.urlopen(req, timeout=1)    except:        passclass ToolController:    def __init__(self, tool_config, app):        self.config = tool_config        self.app = app        self.process = None        self.header = rumps.MenuItem(            f"{tool_config['name']}  —  {tool_config['description']}"        )        self.header.set_callback(None)        self.toggle_btn = rumps.MenuItem("    ▶ Запустить", callback=self.toggle)        self.status_label = rumps.MenuItem("    Статус: ⏸ остановлен")        self.status_label.set_callback(None)        if is_port_alive(tool_config["port"]):            self._set_running()    def toggle(self, sender):        if is_port_alive(self.config["port"]):            self._stop()        else:            self._start()    def _start(self):        self.toggle_btn.title = "    ⏳ Запускается..."        self.status_label.title = "    Статус: ⏳ запускается..."        def run():            env = os.environ.copy()            env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:" + env.get("PATH", "")            self.process = subprocess.Popen(                [VENV_PYTHON, self.config["script"]],                env=env,                stdout=subprocess.DEVNULL,                stderr=subprocess.DEVNULL,            )            for _ in range(20):                time.sleep(0.5)                if is_port_alive(self.config["port"]):                    self._set_running()                    self.app.update_title()                    return            self.status_label.title = "    Статус: ❌ ошибка запуска"            self.toggle_btn.title = "    ▶ Запустить"        threading.Thread(target=run, daemon=True).start()    def _stop(self):        stop_port(self.config["port"])        if self.process:            self.process.terminate()            self.process = None        self._set_stopped()        self.app.update_title()    def _set_running(self):        self.toggle_btn.title = "    ⏹ Остановить"        self.status_label.title = "    Статус: ✅ работает"    def _set_stopped(self):        self.toggle_btn.title = "    ▶ Запустить"        self.status_label.title = "    Статус: ⏸ остановлен"    def check(self):        if is_port_alive(self.config["port"]):            self._set_running()        else:            if self.process:                self.process = None            self._set_stopped()    def is_running(self):        return is_port_alive(self.config["port"])class AILauncher(rumps.App):    def __init__(self):        super().__init__("🤖", quit_button=None)        self.controllers = [ToolController(t, self) for t in TOOLS]        menu_items = []        for i, ctrl in enumerate(self.controllers):            if i > 0:                menu_items.append(None)            menu_items.append(ctrl.header)            menu_items.append(ctrl.toggle_btn)            menu_items.append(ctrl.status_label)        menu_items.append(None)        menu_items.append(rumps.MenuItem("Выйти", callback=self.quit_app))        self.menu = menu_items        self.update_title()        self.timer = rumps.Timer(self.check_all, 5)        self.timer.start()    def update_title(self):        running = [c for c in self.controllers if c.is_running()]        self.title = "🎤" if running else "🤖"    def check_all(self, sender):        for ctrl in self.controllers:            ctrl.check()        self.update_title()    def quit_app(self, sender):        for ctrl in self.controllers:            if ctrl.is_running():                stop_port(ctrl.config["port"])            if ctrl.process:                ctrl.process.terminate()        rumps.quit_application()if __name__ == "__main__":    AILauncher().run()

Шаг 4 — Chrome расширение

extension/manifest.json

{  "manifest_version": 3,  "name": "Whisper для Claude",  "version": "1.0",  "description": "Голосовой ввод на русском через локальный Whisper",  "permissions": ["activeTab", "scripting"],  "host_permissions": [    "https://claude.ai/*",    "http://127.0.0.1:5555/*"  ],  "content_scripts": [    {      "matches": ["https://claude.ai/*"],      "js": ["content.js"],      "run_at": "document_end"    }  ],  "background": {    "service_worker": "background.js"  },  "action": {    "default_popup": "popup.html"  },  "icons": {    "16": "icons/icon16.png",    "48": "icons/icon48.png",    "128": "icons/icon128.png"  }}

Грабля #2: "microphone" в permissions — в MV3 это невалидное разрешение, Chrome выдаёт предупреждение. Убираем — микрофон запрашивается через navigator.mediaDevices.getUserMedia() прямо в коде.

extension/background.js

Грабля #3 — самая неочевидная: content script на https://claude.ai не может напрямую делать fetch к http://127.0.0.1 — браузер блокирует mixed content. Решение: аудио конвертируется в base64 в content.js, передаётся через chrome.runtime.sendMessage в background.js, и уже оттуда летит на сервер.

const SERVER_URL = "http://127.0.0.1:5555";chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {  if (message.action === "transcribe") {    handleTranscribe(message.audio).then(sendResponse);    return true; // асинхронный ответ  }});async function handleTranscribe(base64Audio) {  try {    const binaryStr = atob(base64Audio);    const bytes = new Uint8Array(binaryStr.length);    for (let i = 0; i < binaryStr.length; i++) {      bytes[i] = binaryStr.charCodeAt(i);    }    const audioBlob = new Blob([bytes], { type: "audio/webm" });    const formData = new FormData();    formData.append("audio", audioBlob, "recording.webm");    const response = await fetch(`${SERVER_URL}/transcribe`, {      method: "POST",      body: formData,    });    return await response.json();  } catch (err) {    return { error: "Сервер не запущен" };  }}

extension/content.js

let mediaRecorder = null;let audioChunks = [];let isRecording = false;let micButton = null;function createMicButton() {  if (document.getElementById("whisper-mic-btn")) return;  micButton = document.createElement("button");  micButton.id = "whisper-mic-btn";  micButton.innerHTML = "🎤";  Object.assign(micButton.style, {    position: "fixed", bottom: "100px", right: "30px",    width: "56px", height: "56px", borderRadius: "50%",    border: "none", background: "#6b7280", color: "white",    fontSize: "24px", cursor: "pointer", zIndex: "99999",    boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transition: "all 0.2s ease",  });  micButton.addEventListener("click", toggleRecording);  document.body.appendChild(micButton);}async function toggleRecording() {  isRecording ? stopRecording() : await startRecording();}async function startRecording() {  try {    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });    audioChunks = [];    mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });    mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };    mediaRecorder.onstop = sendAudioToBackground;    mediaRecorder.start();    isRecording = true;    micButton.innerHTML = "⏹";    micButton.style.background = "#ef4444";    showStatus("🔴 Говори... нажми ещё раз чтобы остановить", "recording");  } catch (err) {    showStatus("❌ Нет доступа к микрофону", "error");  }}function stopRecording() {  if (mediaRecorder && isRecording) {    mediaRecorder.stop();    mediaRecorder.stream.getTracks().forEach(t => t.stop());    isRecording = false;    micButton.innerHTML = "⏳";    micButton.style.background = "#f59e0b";    showStatus("⏳ Распознаю речь...", "processing");  }}async function sendAudioToBackground() {  const audioBlob = new Blob(audioChunks, { type: "audio/webm" });  const reader = new FileReader();  reader.onloadend = async () => {    const base64Audio = reader.result.split(",")[1];    try {      const response = await chrome.runtime.sendMessage({        action: "transcribe", audio: base64Audio,      });      if (response?.text?.trim()) {        insertTextIntoClaude(response.text.trim());        showStatus("✅ Готово!", "success");      } else {        showStatus("⚠️ Речь не распознана — попробуй ещё раз", "warning");      }    } catch (err) {      showStatus("❌ Запусти сервер через иконку в строке меню", "error");    }    setTimeout(() => {      micButton.innerHTML = "🎤";      micButton.style.background = "#6b7280";    }, 3000);  };  reader.readAsDataURL(audioBlob);}function insertTextIntoClaude(text) {  const selectors = ['[contenteditable="true"]', 'div[contenteditable]', 'textarea'];  let inputField = null;  for (const selector of selectors) {    for (const el of document.querySelectorAll(selector)) {      if (el.offsetParent !== null) { inputField = el; break; }    }    if (inputField) break;  }  if (!inputField) return;  inputField.focus();  const sep = (inputField.innerText || inputField.value || "").trim() ? " " : "";  if (inputField.tagName === "TEXTAREA") {    inputField.value += sep + text;    inputField.dispatchEvent(new Event("input", { bubbles: true }));  } else {    document.execCommand("insertText", false, sep + text);  }}let statusEl = null;function showStatus(msg, type) {  if (!statusEl) {    statusEl = document.createElement("div");    Object.assign(statusEl.style, {      position: "fixed", bottom: "165px", right: "20px",      padding: "8px 14px", borderRadius: "8px", fontSize: "13px",      fontFamily: "system-ui", zIndex: "99999", maxWidth: "280px",      boxShadow: "0 2px 8px rgba(0,0,0,0.2)", transition: "opacity 0.3s",    });    document.body.appendChild(statusEl);  }  const colors = {    recording: ["#fef2f2","#991b1b"], processing: ["#fffbeb","#92400e"],    success: ["#f0fdf4","#166534"], error: ["#fef2f2","#991b1b"],    warning: ["#fffbeb","#92400e"],  };  const [bg, color] = colors[type] || colors.processing;  Object.assign(statusEl.style, { background: bg, color, opacity: "1" });  statusEl.textContent = msg;}window.addEventListener("beforeunload", () => {  navigator.sendBeacon("http://127.0.0.1:5555/shutdown");});setTimeout(createMicButton, 2000);new MutationObserver(() => {  if (!document.getElementById("whisper-mic-btn")) setTimeout(createMicButton, 1000);}).observe(document.body, { childList: true, subtree: true });

Иконки

python3 -c "import struct, zlibdef make_png(size, color):    def chunk(name, data):        c = zlib.crc32(name + data) & 0xffffffff        return struct.pack('>I', len(data)) + name + data + struct.pack('>I', c)    raw = b''    for y in range(size):        raw += b'\x00'        for x in range(size):            raw += bytes(color)    ihdr = struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0)    return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', ihdr) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')for size in [16, 48, 128]:    with open(f'extension/icons/icon{size}.png', 'wb') as f:        f.write(make_png(size, [107, 114, 128]))print('Иконки созданы')"

Шаг 5 — Установка расширения в Chrome

  1. Открой chrome://extensions

  2. Включи «Режим разработчика» (правый верхний угол)

  3. Нажми «Загрузить распакованное расширение»

  4. Выбери папку extension внутри проекта


Шаг 6 — Автозапуск Menu Bar App

start_menubar.sh

#!/bin/bashexport PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"source /путь/к/проекту/whisper-claude/venv/bin/activatepython3 /путь/к/проекту/whisper-claude/menubar.py
chmod +x start_menubar.sh

~/Library/LaunchAgents/com.whisper.claude.plist

<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"  "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict>    <key>Label</key>    <string>com.whisper.claude</string>    <key>ProgramArguments</key>    <array>        <string>/bin/bash</string>        <string>/путь/к/проекту/whisper-claude/start_menubar.sh</string>    </array>    <key>RunAtLoad</key>    <true/>    <key>KeepAlive</key>    <true/>    <key>StandardOutPath</key>    <string>/tmp/whisper-claude.log</string>    <key>StandardErrorPath</key>    <string>/tmp/whisper-claude-error.log</string></dict></plist>
launchctl load ~/Library/LaunchAgents/com.whisper.claude.plist

Грабля #4: LaunchAgent запускается в урезанном окружении без стандартного PATH. ffmpeg не находится — Whisper падает с FileNotFoundError: ffmpeg. Решение: явно прописать /opt/homebrew/bin в start_menubar.sh.


Все грабли в одной таблице

#

Грабля

Симптом

Решение

1

--fp16 на Mac Apple Silicon

Whisper падает при запуске

Добавить --fp16 False

2

"microphone" в permissions MV3

Chrome показывает предупреждение

Убрать из manifest.json

3

Mixed content в MV3

Кнопка всегда «сервер не запущен»

Передавать аудио через background.js

4

PATH в LaunchAgent

ffmpeg не найден, распознавание падает

Прописать /opt/homebrew/bin в скрипте


Как пользоваться каждый день

  1. Mac загрузился → иконка 🤖 появилась в строке меню автоматически

  2. Хочешь диктовать → кликни иконку → «▶ Запустить» → иконка стала 🎤

  3. Перейди на claude.ai → нажми кнопку микрофона в правом нижнем углу → говори → нажми ещё раз

  4. Текст вставился в поле — отправляй


Roadmap v2.0

  • Горячая клавиша вместо клика мышью

  • Автоотправка после распознавания

  • Поддержка ChatGPT, Gemini — достаточно добавить новый content script

  • Новые инструменты в меню — одна запись в список TOOLS


Вывод

Весь проект — один вечер.

Удивляет конечно новая возможность с ИИ делать такие прикольные штуки. ДАже необычно, что сделал сам себе расширение в браузер:

сделано своими руками и клодом

сделано своими руками и клодом

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

Если у тебя уже стоит Whisper: бери код и делай. Если нет: brew install openai-whisper ffmpeg — и вперёд. Спасибо, возможно кто-то уже ищет решение, добро пожаловать.

ссылка на оригинал статьи https://habr.com/ru/articles/1023104/