Автор: Дмитрий Сосунов совместно с 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-whisperffmpeg:
brew install ffmpeg(Whisper использует его для декодирования аудио)Python 3:
python3 --versionChrome браузер
Проверь что 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
Открой
chrome://extensionsВключи "Режим разработчика" (правый верхний угол)
Нажми "Загрузить распакованное расширение"
Выбери папку
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 |
| Whisper падает при запуске | Добавить |
2 |
| Chrome показывает предупреждение | Убрать из manifest.json |
3 | Mixed content в MV3 | Кнопка всегда "сервер не запущен" | Передавать аудио через background.js |
4 | PATH в LaunchAgent | ffmpeg не найден, распознавание падает | Прописать |
Как пользоваться каждый день
Mac загрузился → иконка 🤖 появилась в строке меню автоматически
Хочешь диктовать → кликни иконку → "▶ Запустить" → иконка стала 🎤
Перейди на claude.ai → нажми кнопку микрофона в правом нижнем углу → говори → нажми ещё раз
Текст вставился в поле — отправляй
Roadmap v2.0
Горячая клавиша вместо клика мышью
Автоотправка после распознавания
Поддержка ChatGPT, Gemini — достаточно добавить новый content script
Новые инструменты в меню — одна запись в список
TOOLS
Вывод
Весь проект — один вечер.
Удивляет конечно новая возможность с ИИ делать такие прикольные штуки. ДАже необычно, что сделал сам себе расширение в браузер:

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