Автор: Дмитрий Сосунов совместно с 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-claude
cd /путь/к/проектам/whisper-claude
python3 -m venv venv
source venv/bin/activate
pip install flask flask-cors rumps
mkdir -p extension/icons

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

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


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

from flask import Flask, request, jsonify
from flask_cors import CORS
import subprocess
import tempfile
import os
import threading

app = Flask(__name__)
CORS(app)  # разрешаем запросы от Chrome расширения

WHISPER_PATH = "/opt/homebrew/bin/whisper"
WHISPER_MODEL = "small"  # tiny/base/small/medium/large
PORT = 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 rumps
import subprocess
import threading
import os
import time
import urllib.request

BASE_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 False

def 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:
        pass

class 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, zlib

def 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/bash
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
source /путь/к/проекту/whisper-claude/venv/bin/activate
python3 /путь/к/проекту/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 — и вперёд. Спасибо, возможно кто-то уже ищет решение, добро пожаловать.