Голосовой ввод в 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-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 — и вперёд. Спасибо, возможно кто-то уже ищет решение, добро пожаловать.