From b2aae16a25c7c2a624f08bf32e92c77eefd9871c Mon Sep 17 00:00:00 2001 From: Evgeniy Date: Mon, 17 Aug 2026 00:17:58 +0300 Subject: [PATCH 01/39] =?UTF-8?q?chore(repo):=20=D0=A3=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BE=D1=87=D0=B8=D1=82=D1=8C=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B0=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D1=84=D0=B0=D0=BA=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скрыть временные проекты, служебный граф кодовой базы и локальные вспомогательные файлы. Ignore temporary projects, the local code graph, and workspace-only helper files. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b9276bc..f15d973 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ dist/ *.log .claude/ *.autosave.kadr +scripts/__pycache__/ +.codebase-memory/ +projects/ +scripts/build_editable_quiz.mjs +VOICEOVER_STUDIO.md From f60a63c771b3838cb17bbbde582d732470f0a462 Mon Sep 17 00:00:00 2001 From: Evgeniy Date: Mon, 17 Aug 2026 00:18:08 +0300 Subject: [PATCH 02/39] =?UTF-8?q?fix(transcribe):=20=D0=98=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разрешить задавать Python для faster-whisper через конфигурацию или переменную окружения и документировать воспроизводимую локальную установку. Allow selecting the faster-whisper Python through configuration or an environment variable and document a reproducible local setup. --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++ electron/transcribe.ts | 24 ++++++++++++++++++--- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 157dee9..2cd4a1d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,54 @@ Claude/npm нужен прокси — создайте `~/.config/kadr/claude-e { "env": { "HTTPS_PROXY": "http://127.0.0.1:1080", "NO_PROXY": "127.0.0.1,localhost" } } ``` +## Локальная транскрибация + +Распознавание речи и авто-субтитры используют локальный +[`faster-whisper`](https://github.com/SYSTRAN/faster-whisper). На macOS Kadr +работает в CPU-режиме `int8`; CUDA не требуется. Модель (`large-v3` по +умолчанию) скачается при первом распознавании и останется в кэше Hugging Face. + +Не устанавливайте пакет случайному `pip`: Kadr должен запускать тот же Python, +в котором он установлен. Надёжный вариант — отдельное окружение: + +```bash +TRANSCRIBE_VENV="$HOME/.local/share/kadr/transcribe-venv" +python3 -m venv "$TRANSCRIBE_VENV" +"$TRANSCRIBE_VENV/bin/python" -m pip install --upgrade pip +"$TRANSCRIBE_VENV/bin/python" -m pip install --upgrade faster-whisper +"$TRANSCRIBE_VENV/bin/python" -c "from faster_whisper import WhisperModel; print('faster-whisper: OK')" +``` + +Затем создайте `~/.config/kadr/transcribe.json`, обязательно указав абсолютный +путь (переменные вроде `$HOME` внутри JSON не раскрываются): + +```json +{ + "pythonPath": "/Users/имя/.local/share/kadr/transcribe-venv/bin/python" +} +``` + +На Windows путь внутри окружения обычно заканчивается на +`Scripts\\python.exe`. Вместо файла конфигурации можно передать переменную +окружения `KADR_TRANSCRIBE_PYTHON`. После настройки повторите транскрибацию; +перезапуск Kadr не требуется. Для обработки аудио также должен быть доступен +`ffmpeg`. + +Если удобнее поручить установку агенту, скопируйте ему этот промпт: + +```text +Ты работаешь в локальном репозитории Kadr. Полностью настрой локальную +транскрибацию: прочитай README.md, electron/transcribe.ts и +scripts/transcribe.py; создай отдельный Python 3.10–3.12 venv вне git; установи +в него актуальный faster-whisper; проверь импорт WhisperModel; создай +~/.config/kadr/transcribe.json с абсолютным pythonPath; проверь ffmpeg. Затем +запусти scripts/transcribe.py этим Python на коротком аудиофайле, дождись +корректного JSONL done и выполни npm run typecheck и npm run build. Не +ограничивайся инструкциями и не устанавливай зависимости в случайный системный +Python. Не удаляй существующие окружения, модели и пользовательские файлы. В +финале сообщи путь к Python, результат smoke-теста и готовность Kadr. +``` + ## Как устроена ИИ-интеграция Kadr поднимает локальный мост в рендерер и отдаёт Claude MCP-сервер с diff --git a/electron/transcribe.ts b/electron/transcribe.ts index 53affae..8c1a76e 100644 --- a/electron/transcribe.ts +++ b/electron/transcribe.ts @@ -6,12 +6,22 @@ import { app, ipcMain, BrowserWindow } from 'electron' import { spawn, ChildProcess } from 'child_process' import { promises as fs } from 'fs' import { join } from 'path' -import { tmpdir } from 'os' +import { homedir, tmpdir } from 'os' import { ExportMuxer } from './ffmpeg' import type { TranscribeRequest, TranscribeResult, TranscribeSegment } from '@shared/types' let current: { muxer: ExportMuxer | null; py: ChildProcess | null; cancelled: boolean } | null = null +async function transcribePython(): Promise { + const configPath = join(homedir(), '.config', 'kadr', 'transcribe.json') + try { + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { pythonPath?: string } + return process.env.KADR_TRANSCRIBE_PYTHON || config.pythonPath || 'python3' + } catch { + return process.env.KADR_TRANSCRIBE_PYTHON || 'python3' + } +} + async function run(win: BrowserWindow, req: TranscribeRequest): Promise { if (current) throw new Error('transcription already running') const job = { muxer: null as ExportMuxer | null, py: null as ChildProcess | null, cancelled: false } @@ -47,8 +57,9 @@ async function run(win: BrowserWindow, req: TranscribeRequest): Promise((resolve, reject) => { - const py = spawn('python3', [ + const py = spawn(python, [ join(app.getAppPath(), 'scripts', 'transcribe.py'), '--audio', wav, '--model', req.model, @@ -78,11 +89,18 @@ async function run(win: BrowserWindow, req: TranscribeRequest): Promise { err += c }) - py.on('error', reject) + py.on('error', (error) => reject(new Error( + `Не удалось запустить Python для транскрибации (${python}): ${error.message}. ` + + 'См. раздел «Локальная транскрибация» в README.md' + ))) py.on('close', (code) => { job.py = null if (job.cancelled) reject(new Error('cancelled')) else if (code === 0) resolve() + else if (err.includes("No module named 'faster_whisper'")) reject(new Error( + `В Python ${python} не установлен faster-whisper. ` + + 'Настройте окружение по разделу «Локальная транскрибация» в README.md' + )) else reject(new Error(err.slice(0, 800) || `transcribe.py exited ${code}`)) }) }) From 653457b997b16def742d9c01231be83a83030d2a Mon Sep 17 00:00:00 2001 From: Evgeniy Date: Mon, 17 Aug 2026 00:19:04 +0300 Subject: [PATCH 03/39] =?UTF-8?q?feat(voiceover):=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20=D0=BE?= =?UTF-8?q?=D0=B7=D0=B2=D1=83=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавить локальный TTS-воркер, проверку готовности бэкенда и редактор версий озвучки с прослушиванием, повторной генерацией и выбором активного дубля. Add a local TTS worker, backend readiness checks, and a voice-take editor with preview, regeneration, history, and active-version selection. --- README.md | 71 ++++++- electron/main.ts | 27 ++- electron/preload.ts | 9 + electron/voiceover.ts | 252 ++++++++++++++++++++++++ scripts/voiceover_worker.py | 141 ++++++++++++++ shared/types.ts | 75 +++++++ src/App.tsx | 2 + src/components/Timeline.tsx | 50 ++++- src/components/VoiceoverStudio.tsx | 303 +++++++++++++++++++++++++++++ src/styles.css | 274 ++++++++++++++++++++++++++ 10 files changed, 1201 insertions(+), 3 deletions(-) create mode 100644 electron/voiceover.ts create mode 100644 scripts/voiceover_worker.py create mode 100644 src/components/VoiceoverStudio.tsx diff --git a/README.md b/README.md index 2cd4a1d..6c4e864 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ Kadr — многодорожечный видеоредактор (Electron + R |---|---|---| | Node.js ≥ 20 | всё | | | ffmpeg + ffprobe | импорт, аудиомикс, экспорт | любая свежая сборка в PATH | -| python3 + [faster-whisper](https://github.com/SYSTRAN/faster-whisper) | распознавание речи, авто-субтитры | `pip install faster-whisper`; модели скачаются при первом запуске | +| Python 3.10–3.12 + [faster-whisper](https://github.com/SYSTRAN/faster-whisper) | распознавание речи, авто-субтитры | отдельное окружение и конфиг описаны ниже | +| локальный TTS | история дублей и переозвучка | опционально; установка описана ниже, модель занимает около 4–5 ГБ | | [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) | панель 🤖 | опционально; использует ваш существующий вход | | сеть (один раз) | workspace Remotion-фрагментов | `~/kadr-fragments`, ~150 МБ | @@ -157,6 +158,74 @@ Python. Не удаляй существующие окружения, моде финале сообщи путь к Python, результат smoke-теста и готовность Kadr. ``` +## Локальная переозвучка + +Пункт «Переозвучить…» работает только с локальным TTS-бэкендом. Kadr проверяет +Python, зависимости, модель и ffmpeg; до успешной проверки команда отключена. +История и прослушивание уже существующих дублей при этом остаются доступны. + +После установки Kadr читает локальную конфигурацию из +`~/.config/kadr/tts.json`: + +```json +{ + "pythonPath": "/абсолютный/путь/к/tts-venv/bin/python", + "modelPath": "/абсолютный/путь/к/Qwen3-TTS-VoiceDesign", + "ffmpegPath": "ffmpeg" +} +``` + +Ниже — готовый промпт. Скопируйте его целиком в Codex, Claude Code или другому +агенту, запущенному в папке репозитория Kadr. Агент должен не просто дать +инструкцию, а самостоятельно установить и проверить бэкенд. + +```text +Ты работаешь в локальном репозитории Kadr. Установи и полностью настрой локальный +TTS для функции «Переозвучить…». Не ограничивайся инструкцией: выполни установку, +создай конфигурацию, проведи smoke-тест генерации и оставь Kadr в рабочем состоянии. + +Контракт Kadr: +- основной воркер: scripts/voiceover_worker.py; +- Kadr запускает его как: scripts/voiceover_worker.py --model ; +- воркер общается через JSONL в stdin/stdout, сначала печатает {"type":"ready"}, + затем принимает задания и возвращает progress/done/error; +- готовый WAV должен быть PCM s16le, 48 кГц, stereo; мастеринг выполняется через + ffmpeg; облачные TTS и API-ключи не использовать; +- настройки хранятся в ~/.config/kadr/tts.json с полями pythonPath, modelPath и + ffmpegPath. Не прописывай локальные абсолютные пути в git-файлах проекта. + +Порядок работы: +1. Прочитай README.md, electron/voiceover.ts и scripts/voiceover_worker.py. Проверь + ОС, архитектуру, доступную память, Python и ffmpeg. +2. На macOS Apple Silicon используй проверенный стек: Python 3.12 в отдельном + venv, mlx-audio (актуальная совместимая версия; ориентир 0.4.8) и модель + mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-bf16 с Hugging Face. Модель + скачай полностью в локальную папку вне git и проверь наличие config.json и + model.safetensors. +3. На другой платформе подбери локальный Qwen3-TTS VoiceDesign-бэкенд для этой + платформы и при необходимости адаптируй только реализацию воркера, сохранив + описанный JSONL-контракт и формат выходного WAV. Также обнови проверку + зависимостей в electron/voiceover.ts. Не делай вид, что MLX работает не на + Apple Silicon. +4. Создай ~/.config/kadr/tts.json с абсолютными путями. Проверь, что выбранный + Python импортирует все модули воркера, а ffmpeg запускается. +5. Запусти воркер отдельно, дождись события ready, отправь короткую русскую фразу + тем же JSON-заданием, которое отправляет Kadr, и дождись done. Через ffprobe + проверь: файл читается, длительность больше 0.5 секунды, 48 кГц, два канала. + Убедись, что это не тишина. +6. Запусти npm run typecheck и npm run build. Затем запусти Kadr, вызови + voiceoverStatus и убедись, что ready=true, а пункт «Переозвучить…» активен. + Сделай один реальный тестовый дубль из интерфейса или через тот же IPC-контракт + и проверь его воспроизведение. +7. Не удаляй существующие модели, окружения, медиа и пользовательские изменения. + Не коммить многогигабайтную модель или venv. Если установка требует изменить + код для текущей платформы — внеси изменения, проверь их и перечисли в отчёте. + +Не останавливайся после частичного успеха. В финале сообщи: какой бэкенд и модель +установлены, пути из tts.json, результат smoke-теста, статус typecheck/build и +подтверждение, что Kadr показывает TTS как готовый. +``` + ## Как устроена ИИ-интеграция Kadr поднимает локальный мост в рендерер и отдаёт Claude MCP-сервер с diff --git a/electron/main.ts b/electron/main.ts index 4124282..778907b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,5 @@ import { app, BrowserWindow, ipcMain, dialog, protocol, net, clipboard } from 'electron' -import { join, dirname, basename } from 'path' +import { join, dirname, basename, extname } from 'path' import { promises as fs, createReadStream, statSync, existsSync, appendFileSync } from 'fs' import { tmpdir } from 'os' import { createHash } from 'crypto' @@ -8,6 +8,7 @@ import { probeMedia, makeProxy, makeDecoded, makeReversed, measureLoudness, Expo import { registerClaudeIpc } from './claude' import { registerTranscribeIpc } from './transcribe' import { registerFragmentIpc } from './fragments' +import { registerVoiceoverIpc } from './voiceover' import type { ExportJob, Project } from '@shared/types' // Streamed local media under a privileged scheme so the renderer can play @@ -137,9 +138,30 @@ function streamBody(stream: ReturnType): ReadableStream }) } +function mediaContentType(filePath: string): string { + switch (extname(filePath).toLowerCase()) { + case '.wav': return 'audio/wav' + case '.mp3': return 'audio/mpeg' + case '.m4a': return 'audio/mp4' + case '.aac': return 'audio/aac' + case '.flac': return 'audio/flac' + case '.ogg': return 'audio/ogg' + case '.mp4': return 'video/mp4' + case '.mov': return 'video/quicktime' + case '.webm': return 'video/webm' + case '.png': return 'image/png' + case '.jpg': + case '.jpeg': return 'image/jpeg' + case '.webp': return 'image/webp' + case '.gif': return 'image/gif' + default: return 'application/octet-stream' + } +} + function mediaResponse(filePath: string, rangeHeader: string | null): Response { const stat = statSync(filePath) const size = stat.size + const contentType = mediaContentType(filePath) const m = rangeHeader?.match(/bytes=(\d*)-(\d*)/) // CORS header keeps WebAudio (MediaElementSource) from silencing the stream if (m && (m[1] || m[2])) { @@ -148,6 +170,7 @@ function mediaResponse(filePath: string, rangeHeader: string | null): Response { return new Response(streamBody(createReadStream(filePath, { start, end })), { status: 206, headers: { + 'Content-Type': contentType, 'Content-Range': `bytes ${start}-${end}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': String(end - start + 1), @@ -158,6 +181,7 @@ function mediaResponse(filePath: string, rangeHeader: string | null): Response { return new Response(streamBody(createReadStream(filePath)), { status: 200, headers: { + 'Content-Type': contentType, 'Accept-Ranges': 'bytes', 'Content-Length': String(size), 'Access-Control-Allow-Origin': '*' @@ -182,6 +206,7 @@ app.whenReady().then(() => { registerClaudeIpc(() => win) registerTranscribeIpc(() => win) registerFragmentIpc(() => win) + registerVoiceoverIpc(() => win) createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/electron/preload.ts b/electron/preload.ts index a2a0fb4..ce6121e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -152,6 +152,15 @@ const api: KadrApi = { writeTextFile: (path, content) => ipcRenderer.invoke('file:write-text', path, content), statFile: (path) => ipcRenderer.invoke('file:stat', path), + voiceoverStatus: (settings) => ipcRenderer.invoke('voiceover:status', settings), + voiceoverGenerate: (req) => ipcRenderer.invoke('voiceover:generate', req), + voiceoverCancel: () => ipcRenderer.invoke('voiceover:cancel'), + onVoiceoverProgress: (cb) => { + const handler = (_e: unknown, p: Parameters[0]) => cb(p) + ipcRenderer.on('voiceover:progress', handler) + return () => ipcRenderer.removeListener('voiceover:progress', handler) + }, + claudeOpen: (cols, rows, cwd) => ipcRenderer.invoke('claude:open', cols, rows, cwd), claudeInput: (data) => ipcRenderer.send('claude:input', data), claudeResize: (cols, rows) => ipcRenderer.send('claude:resize', cols, rows), diff --git a/electron/voiceover.ts b/electron/voiceover.ts new file mode 100644 index 0000000..290419b --- /dev/null +++ b/electron/voiceover.ts @@ -0,0 +1,252 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'child_process' +import { promises as fs, existsSync } from 'fs' +import { basename, dirname, join } from 'path' +import { homedir } from 'os' +import { promisify } from 'util' +import type { + VoiceoverGenerateRequest, + VoiceoverGenerateResult, + VoiceoverProgress, + VoiceoverSettings, + VoiceoverStatus +} from '@shared/types' + +const execFileAsync = promisify(execFile) + +interface VoiceoverRuntime { + pythonPath: string + modelPath: string + ffmpegPath: string + configPath: string +} + +interface VoiceoverConfig { + pythonPath?: string + modelPath?: string + ffmpegPath?: string +} + +type WorkerMessage = + | { type: 'ready' } + | { type: 'progress'; id: string; stage: VoiceoverProgress['stage']; progress: number; message?: string } + | { type: 'done'; id: string; path: string; duration: number; seed: number } + | { type: 'error'; id: string; message: string } + +let worker: ChildProcessWithoutNullStreams | null = null +let workerReady: Promise | null = null +let workerRuntimeKey: string | null = null +let resolveReady: (() => void) | null = null +let rejectReady: ((err: Error) => void) | null = null +let stdoutBuffer = '' +let stderrTail = '' +let current: { + id: string + clipId: string + resolve: (result: VoiceoverGenerateResult) => void + reject: (err: Error) => void +} | null = null + +const safePart = (value: string) => value.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80) || 'voice' + +async function resolveRuntime(settings: VoiceoverSettings): Promise { + const configPath = join(homedir(), '.config', 'kadr', 'tts.json') + let config: VoiceoverConfig = {} + try { + config = JSON.parse(await fs.readFile(configPath, 'utf8')) as VoiceoverConfig + } catch { /* the local TTS config is optional */ } + return { + pythonPath: process.env.KADR_TTS_PYTHON || config.pythonPath || settings.pythonPath || '', + modelPath: process.env.KADR_TTS_MODEL || config.modelPath || settings.modelPath || '', + ffmpegPath: process.env.KADR_FFMPEG || config.ffmpegPath || 'ffmpeg', + configPath + } +} + +async function voiceoverStatus(settings: VoiceoverSettings): Promise { + const runtime = await resolveRuntime(settings) + const unavailable = (reason: string): VoiceoverStatus => ({ + ready: false, + reason, + pythonPath: runtime.pythonPath, + modelPath: runtime.modelPath, + configPath: runtime.configPath + }) + if (!existsSync(runtime.pythonPath)) return unavailable('Не найден Python для TTS') + if (!existsSync(runtime.modelPath)) return unavailable('Не найдена локальная модель TTS') + if (!existsSync(join(runtime.modelPath, 'config.json'))) return unavailable('Папка модели TTS заполнена не полностью') + const script = join(app.getAppPath(), 'scripts', 'voiceover_worker.py') + if (!existsSync(script)) return unavailable('Не найден TTS-воркер Kadr') + try { + await execFileAsync(runtime.pythonPath, [ + '-c', + 'import mlx.core, numpy; from mlx_audio.tts.utils import load_model' + ], { timeout: 20_000 }) + } catch { + return unavailable('В Python не установлены зависимости TTS') + } + try { + await execFileAsync(runtime.ffmpegPath, ['-version'], { timeout: 10_000 }) + } catch { + return unavailable('Не найден ffmpeg для финализации озвучки') + } + return { + ready: true, + pythonPath: runtime.pythonPath, + modelPath: runtime.modelPath, + configPath: runtime.configPath + } +} + +function sendProgress(getWin: () => BrowserWindow | null, progress: VoiceoverProgress) { + getWin()?.webContents.send('voiceover:progress', progress) +} + +function stopWorker(reason = 'Генерация отменена') { + if (current) { + current.reject(new Error(reason)) + current = null + } + rejectReady?.(new Error(reason)) + resolveReady = null + rejectReady = null + workerReady = null + workerRuntimeKey = null + stdoutBuffer = '' + try { worker?.kill('SIGKILL') } catch { /* already gone */ } + worker = null +} + +function handleMessage(getWin: () => BrowserWindow | null, msg: WorkerMessage) { + if (msg.type === 'ready') { + resolveReady?.() + resolveReady = null + rejectReady = null + return + } + if (!current || msg.id !== current.id) return + if (msg.type === 'progress') { + sendProgress(getWin, { + clipId: current.clipId, + stage: msg.stage, + progress: msg.progress, + message: msg.message + }) + } else if (msg.type === 'done') { + const done = current + current = null + sendProgress(getWin, { clipId: done.clipId, stage: 'done', progress: 1 }) + done.resolve({ path: msg.path, duration: msg.duration, seed: msg.seed }) + } else if (msg.type === 'error') { + const failed = current + current = null + sendProgress(getWin, { + clipId: failed.clipId, + stage: 'error', + progress: 0, + message: msg.message + }) + failed.reject(new Error(msg.message)) + } +} + +function ensureWorker( + getWin: () => BrowserWindow | null, + runtime: VoiceoverRuntime +): Promise { + const runtimeKey = JSON.stringify([ + runtime.pythonPath, + runtime.modelPath, + runtime.ffmpegPath + ]) + if (worker && workerReady && workerRuntimeKey === runtimeKey) return workerReady + if (worker) stopWorker('Конфигурация TTS изменилась') + + const script = join(app.getAppPath(), 'scripts', 'voiceover_worker.py') + stderrTail = '' + stdoutBuffer = '' + workerReady = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + workerRuntimeKey = runtimeKey + worker = spawn(runtime.pythonPath, [script, '--model', runtime.modelPath], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PYTHONUNBUFFERED: '1', KADR_FFMPEG: runtime.ffmpegPath } + }) + worker.stdout.on('data', (chunk) => { + stdoutBuffer += String(chunk) + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + try { handleMessage(getWin, JSON.parse(line) as WorkerMessage) } + catch { /* model libraries occasionally print to stdout; ignore it */ } + } + }) + worker.stderr.on('data', (chunk) => { + stderrTail = (stderrTail + String(chunk)).slice(-4000) + }) + worker.on('error', (err) => stopWorker(err.message)) + worker.on('close', (code) => { + const detail = stderrTail.trim().slice(-1200) + stopWorker(detail || `TTS-процесс завершился с кодом ${code}`) + }) + return workerReady +} + +async function outputPath(req: VoiceoverGenerateRequest): Promise { + const root = req.projectPath + ? join(dirname(req.projectPath), `${basename(req.projectPath, '.kadr')}.media`, 'voiceover') + : join(app.getPath('userData'), 'voiceover') + const dir = join(root, safePart(req.clipId)) + await fs.mkdir(dir, { recursive: true }) + return join(dir, `v${Math.max(1, Math.floor(req.version))}.wav`) +} + +async function generate( + getWin: () => BrowserWindow | null, + req: VoiceoverGenerateRequest +): Promise { + if (current) throw new Error('Дождитесь завершения текущей генерации') + const text = req.text.trim() + if (!text) throw new Error('Введите текст для озвучки') + const id = `${safePart(req.clipId)}-${Date.now()}` + const status = await voiceoverStatus(req.settings) + if (!status.ready) throw new Error(`${status.reason}. Настройте TTS по инструкции в README.md`) + const runtime = await resolveRuntime(req.settings) + sendProgress(getWin, { clipId: req.clipId, stage: 'loading', progress: 0.04 }) + await ensureWorker(getWin, runtime) + const out = await outputPath(req) + sendProgress(getWin, { clipId: req.clipId, stage: 'generating', progress: 0.15 }) + + return new Promise((resolve, reject) => { + current = { id, clipId: req.clipId, resolve, reject } + worker!.stdin.write(JSON.stringify({ + id, + text, + outputPath: out, + settings: req.settings + }) + '\n') + }) +} + +export function registerVoiceoverIpc(getWin: () => BrowserWindow | null) { + ipcMain.handle('voiceover:status', (_e, settings: VoiceoverSettings) => + voiceoverStatus(settings) + ) + ipcMain.handle('voiceover:generate', (_e, req: VoiceoverGenerateRequest) => + generate(getWin, req) + ) + ipcMain.handle('voiceover:cancel', () => { + const clipId = current?.clipId + stopWorker() + if (clipId) sendProgress(getWin, { + clipId, + stage: 'error', + progress: 0, + message: 'Генерация отменена' + }) + }) + app.on('before-quit', () => stopWorker('Kadr закрывается')) +} diff --git a/scripts/voiceover_worker.py b/scripts/voiceover_worker.py new file mode 100644 index 0000000..bcad481 --- /dev/null +++ b/scripts/voiceover_worker.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Warm local Qwen3-TTS worker for Kadr's private voice-over studio.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import subprocess +import sys +import tempfile +import wave +from pathlib import Path + +import mlx.core as mx +import numpy as np +from mlx_audio.audio_io import write as audio_write +from mlx_audio.tts.utils import load_model + + +def emit(payload: dict) -> None: + print(json.dumps(payload, ensure_ascii=False), flush=True) + + +def trim_to_speech(audio: np.ndarray, sample_rate: int) -> np.ndarray: + mono = np.asarray(audio, dtype=np.float32).reshape(-1) + frame = max(1, int(sample_rate * 0.05)) + hop = max(1, int(sample_rate * 0.01)) + if len(mono) < frame: + raise ValueError("Аудио короче одного анализируемого кадра") + rms = np.array([ + np.sqrt(np.mean(mono[pos:pos + frame] ** 2) + 1e-12) + for pos in range(0, len(mono) - frame + 1, hop) + ]) + active = np.flatnonzero(rms >= 0.002) + if active.size == 0: + raise ValueError("Модель вернула только тишину") + start = max(0, int(active[0] * hop) - int(sample_rate * 0.10)) + end = min(len(mono), int(active[-1] * hop + frame) + int(sample_rate * 0.16)) + trimmed = mono[start:end].copy() + fade = min(int(sample_rate * 0.012), len(trimmed) // 4) + if fade > 1: + trimmed[:fade] *= np.linspace(0.0, 1.0, fade, dtype=np.float32) + trimmed[-fade:] *= np.linspace(1.0, 0.0, fade, dtype=np.float32) + return trimmed + + +def wav_duration(path: Path) -> float: + with wave.open(str(path), "rb") as source: + return source.getnframes() / source.getframerate() + + +def generate(model, request: dict) -> None: + job_id = str(request["id"]) + text = str(request["text"]).strip() + output = Path(request["outputPath"]) + settings = request["settings"] + if not text: + raise ValueError("Пустой текст") + output.parent.mkdir(parents=True, exist_ok=True) + emit({"type": "progress", "id": job_id, "stage": "generating", "progress": 0.22}) + + accepted = None + failures: list[str] = [] + base_seed = int(settings.get("seed", 20260816)) + for attempt in range(3): + seed = base_seed + attempt + mx.random.seed(seed) + try: + result = next(model.generate( + text=text, + instruct=str(settings["voicePrompt"]), + lang_code=str(settings.get("language", "Russian")), + temperature=float(settings.get("temperature", 0.45)), + top_k=int(settings.get("topK", 30)), + top_p=float(settings.get("topP", 0.82)), + repetition_penalty=float(settings.get("repetitionPenalty", 1.08)), + max_tokens=int(settings.get("maxTokens", 700)), + verbose=False, + )) + segment = trim_to_speech(np.array(result.audio), int(model.sample_rate)) + duration = len(segment) / int(model.sample_rate) + minimum = max(0.45, len(text) / 34.0) + maximum = max(7.0, len(text) / 6.0) + if not minimum <= duration <= maximum: + raise ValueError(f"Неправдоподобная длительность {duration:.2f} с") + accepted = (segment, seed) + break + except Exception as exc: # retry protects against rare truncated takes + failures.append(str(exc)) + if accepted is None: + raise RuntimeError("; ".join(failures)) + + segment, used_seed = accepted + emit({"type": "progress", "id": job_id, "stage": "mastering", "progress": 0.82}) + with tempfile.TemporaryDirectory(prefix="kadr-voice-") as temp_dir: + raw = Path(temp_dir) / "raw.wav" + mastered = Path(temp_dir) / "master.wav" + audio_write(str(raw), segment, int(model.sample_rate), format="wav") + subprocess.run([ + os.environ.get("KADR_FFMPEG", "ffmpeg"), "-y", "-hide_banner", "-loglevel", "error", + "-i", str(raw), + "-af", ( + "highpass=f=70," + f"loudnorm=I={float(settings.get('loudnessLufs', -16))}:" + f"TP={float(settings.get('truePeakDb', -1.5))}:LRA=7" + ), + "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(mastered) + ], check=True) + pending = output.with_suffix(".part.wav") + pending.write_bytes(mastered.read_bytes()) + pending.replace(output) + + emit({ + "type": "done", + "id": job_id, + "path": str(output), + "duration": round(wav_duration(output), 3), + "seed": used_seed, + }) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--model", required=True) + args = parser.parse_args() + model = load_model(model_path=args.model) + emit({"type": "ready"}) + for line in sys.stdin: + if not line.strip(): + continue + request = json.loads(line) + try: + generate(model, request) + except Exception as exc: + emit({"type": "error", "id": str(request.get("id", "")), "message": str(exc)}) + + +if __name__ == "__main__": + main() diff --git a/shared/types.ts b/shared/types.ts index da5b28e..4d8ff71 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -125,6 +125,72 @@ export interface TextStyle { background: string // '' = none } +// --------------------------------------------------------------------------- +// Local voice-over studio + +/** Parameters are persisted with the clip so every later take uses the same + * voice/model recipe. Only the spoken text changes between generations. */ +export interface VoiceoverSettings { + modelPath: string + pythonPath: string + voicePrompt: string + language: string + temperature: number + topK: number + topP: number + repetitionPenalty: number + maxTokens: number + seed: number + loudnessLufs: number + truePeakDb: number +} + +export interface VoiceoverVersion { + id: string + number: number + text: string + path: string + assetId: string + duration: number + createdAt: string + settings: VoiceoverSettings +} + +export interface VoiceoverHistory { + activeVersionId?: string + versions: VoiceoverVersion[] + settings: VoiceoverSettings +} + +export interface VoiceoverGenerateRequest { + clipId: string + projectPath: string | null + version: number + text: string + settings: VoiceoverSettings +} + +export interface VoiceoverGenerateResult { + path: string + duration: number + seed: number +} + +export interface VoiceoverStatus { + ready: boolean + reason?: string + pythonPath?: string + modelPath?: string + configPath: string +} + +export interface VoiceoverProgress { + clipId: string + stage: 'loading' | 'generating' | 'mastering' | 'done' | 'error' + progress: number + message?: string +} + export interface Clip { id: string /** source asset; text clips have no asset */ @@ -137,6 +203,8 @@ export interface Clip { fragmentMeta?: FragmentSpec text?: string textStyle?: TextStyle + /** Local TTS takes and the currently selected take for narration clips. */ + voiceover?: VoiceoverHistory /** position on the timeline */ start: number duration: number @@ -475,6 +543,13 @@ export interface KadrApi { /** mtime in ms, or null when missing — used to pick up external edits */ statFile(path: string): Promise + /** Generate one local Qwen voice-over take. The model process stays warm + between requests; generated files are stored next to the .kadr project. */ + voiceoverStatus(settings: VoiceoverSettings): Promise + voiceoverGenerate(req: VoiceoverGenerateRequest): Promise + voiceoverCancel(): Promise + onVoiceoverProgress(cb: (p: VoiceoverProgress) => void): () => void + /** Embedded Claude Code terminal session (PTY in main + MCP bridge). */ claudeOpen(cols: number, rows: number, cwd: string | null): Promise<{ ok: boolean; port?: number; error?: string }> diff --git a/src/App.tsx b/src/App.tsx index bbf44e1..c8d5cf9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ExportDialog } from './components/ExportDialog' import { ClaudePanel } from './components/ClaudePanel' import { TranscribeDialog, SubtitlePanel } from './components/TextTools' import { CaptionsDialog } from './components/CaptionsDialog' +import { VoiceoverStudio } from './components/VoiceoverStudio' import { useEditor, newProject } from './state/store' import { dropPayload, dropUsable, importDrop, importFiles } from './engine/mediaImport' import { useT, type TKey } from './i18n' @@ -275,6 +276,7 @@ export default function App() { + {claudeOpen && setClaudeOpen(false)} />} ) diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 7c1f2e7..4711702 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { flushSync, createPortal } from 'react-dom' -import type { Clip, MediaAsset, Track } from '@shared/types' +import type { Clip, MediaAsset, Track, VoiceoverStatus } from '@shared/types' import { useEditor, useSettings, projectDuration, snapPoints, findClip, withLinked, MAX_ZOOM } from '@/state/store' @@ -14,6 +14,7 @@ import { dropPayload, dragHasMedia, dropUsable, importDrop } from '@/engine/medi import { useTextUi } from './TextTools' import { useCaptionsUi } from './CaptionsDialog' +import { useVoiceoverUi } from './VoiceoverStudio' /** Transcribe the selected range (Shift-drag on the ruler) into SRT/TXT. */ function TranscribeRangeButton() { const t = useT() @@ -313,6 +314,27 @@ export function Timeline({ height }: { height: number }) { function TrackMenu({ menu, onClose }: { menu: MenuState; onClose: () => void }) { const t = useT() const selCount = useEditor((s) => s.selection.length) + const voiceSettings = useEditor((s) => { + if (!menu.clipId) return null + return findClip(s.project, menu.clipId)?.clip.voiceover?.settings ?? null + }) + const [voiceStatus, setVoiceStatus] = useState(null) + useEffect(() => { + let current = true + setVoiceStatus(null) + if (voiceSettings) { + void window.kadr.voiceoverStatus(voiceSettings).then((status) => { + if (current) setVoiceStatus(status) + }).catch(() => { + if (current) setVoiceStatus({ + ready: false, + reason: 'Не удалось проверить TTS', + configPath: '~/.config/kadr/tts.json' + }) + }) + } + return () => { current = false } + }, [menu.clipId, voiceSettings?.modelPath, voiceSettings?.pythonPath]) const transCur = useEditor((s) => { if (menu.kind !== 'transition' || !menu.clipId) return null const f = findClip(s.project, menu.clipId) @@ -505,6 +527,32 @@ function TrackMenu({ menu, onClose }: { menu: MenuState; onClose: () => void }) st.select(withLinked(st.project, [menu.clipId!])) st.deleteSelection() onClose() + {(() => { + const f = findClip(useEditor.getState().project, menu.clipId!) + if (f?.track.kind !== 'audio' || !f.clip.voiceover) return null + const ready = voiceStatus?.ready === true + return ( + <> + + {voiceStatus && !ready && ( +
+ {voiceStatus.reason}. Как включить — раздел «Локальная переозвучка» в README.md. +
+ )} + + ) + })()} }} > {t('clipDelete')} diff --git a/src/components/VoiceoverStudio.tsx b/src/components/VoiceoverStudio.tsx new file mode 100644 index 0000000..1bab165 --- /dev/null +++ b/src/components/VoiceoverStudio.tsx @@ -0,0 +1,303 @@ +import { useEffect, useMemo, useState } from 'react' +import { create } from 'zustand' +import type { + MediaAsset, + VoiceoverProgress, + VoiceoverSettings, + VoiceoverStatus, + VoiceoverVersion +} from '@shared/types' +import { findClip, useEditor } from '@/state/store' + +const VOICE_PROMPT = + 'A friendly adult Russian narrator with a warm, calm, confident voice and a light smile. ' + + 'Measured pace, clear standard Russian diction, medium-low pitch and a natural dynamic range. ' + + 'Sound engaging without shouting, rushing, sales energy or theatrical overacting. ' + + 'Finish every sentence completely and leave a natural final cadence.' + +export const DEFAULT_VOICEOVER_SETTINGS: VoiceoverSettings = { + modelPath: '', + pythonPath: '', + voicePrompt: VOICE_PROMPT, + language: 'Russian', + temperature: 0.45, + topK: 30, + topP: 0.82, + repetitionPenalty: 1.08, + maxTokens: 700, + seed: 20260816, + loudnessLufs: -16, + truePeakDb: -1.5 +} + +export const useVoiceoverUi = create<{ + clipId: string | null + open(clipId: string): void + close(): void +}>((set) => ({ + clipId: null, + open: (clipId) => set({ clipId }), + close: () => set({ clipId: null }) +})) + +const versionAsset = ( + id: string, + path: string, + name: string, + probed: Omit +): MediaAsset => ({ ...probed, id, path, name, kind: 'audio' }) + +function VoicePlayer({ version, label }: { version: VoiceoverVersion; label: string }) { + const [failed, setFailed] = useState(false) + + useEffect(() => setFailed(false), [version.id, version.path]) + + return ( +
+
+ ) +} + +export function VoiceoverStudio() { + const clipId = useVoiceoverUi((s) => s.clipId) + const project = useEditor((s) => s.project) + const projectPath = useEditor((s) => s.projectPath) + const found = clipId ? findClip(project, clipId) : null + const clip = found?.clip + const history = clip?.voiceover + const versions = history?.versions ?? [] + const active = versions.find((v) => v.id === history?.activeVersionId) + const settings = history?.settings ?? DEFAULT_VOICEOVER_SETTINGS + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + const [progress, setProgress] = useState(null) + const [error, setError] = useState('') + const [ttsStatus, setTtsStatus] = useState(null) + + useEffect(() => { + if (!clipId || !clip) return + setDraft(active?.text ?? versions.at(-1)?.text ?? clip.label ?? '') + setProgress(null) + setError('') + }, [clipId]) + + useEffect(() => window.kadr.onVoiceoverProgress((p) => { + if (p.clipId === clipId) setProgress(p) + }), [clipId]) + + useEffect(() => { + let current = true + setTtsStatus(null) + if (!clipId) return () => { current = false } + void window.kadr.voiceoverStatus(settings).then((status) => { + if (current) setTtsStatus(status) + }).catch(() => { + if (current) setTtsStatus({ + ready: false, + reason: 'Не удалось проверить TTS', + configPath: '~/.config/kadr/tts.json' + }) + }) + return () => { current = false } + }, [clipId, settings.modelPath, settings.pythonPath]) + + useEffect(() => { + if (!clipId) return + const key = (e: KeyboardEvent) => { + if (e.code === 'Escape' && !busy) useVoiceoverUi.getState().close() + } + window.addEventListener('keydown', key) + return () => window.removeEventListener('keydown', key) + }, [clipId, busy]) + + const nextVersion = useMemo( + () => Math.max(0, ...versions.map((v) => v.number)) + 1, + [versions] + ) + + if (!clipId || !clip) return null + + const generate = async () => { + const text = draft.trim() + if (!text || busy || !ttsStatus?.ready) return + setBusy(true) + setError('') + try { + const result = await window.kadr.voiceoverGenerate({ + clipId, + projectPath, + version: nextVersion, + text, + settings + }) + const probe = await window.kadr.probeMedia(result.path) + const assetId = `voice-${clipId}-v${nextVersion}` + const takeSettings = { ...settings, seed: result.seed } + const version: VoiceoverVersion = { + id: `take-${clipId}-${nextVersion}`, + number: nextVersion, + text, + path: result.path, + assetId, + duration: result.duration, + createdAt: new Date().toISOString(), + settings: takeSettings + } + const st = useEditor.getState() + st.pushHistory('hVoiceGeneration') + st.addAsset(versionAsset( + assetId, + result.path, + `${clip.label ?? 'Озвучка'} · V${nextVersion}`, + probe.asset + )) + const latest = findClip(useEditor.getState().project, clipId)?.clip + st.updateClip(clipId, { + voiceover: { + activeVersionId: latest?.voiceover?.activeVersionId, + versions: [...(latest?.voiceover?.versions ?? []), version], + settings: takeSettings + } + }) + } catch (err) { + setError(String(err instanceof Error ? err.message : err)) + } finally { + setBusy(false) + } + } + + const applyVersion = (version: VoiceoverVersion) => { + const st = useEditor.getState() + const current = findClip(st.project, clipId)?.clip + if (!current || current.voiceover?.activeVersionId === version.id) return + st.pushHistory('hVoiceVersion') + const base = (current.label ?? 'Озвучка').replace(/\s*[·•-]\s*V\d+$/i, '') + st.updateClip(clipId, { + assetId: version.assetId, + inPoint: 0, + duration: Math.max(0.05, version.duration), + label: `${base} · V${version.number}`, + voiceover: { + settings: version.settings, + versions: current.voiceover?.versions ?? [version], + activeVersionId: version.id + } + }) + } + + const percent = Math.round((progress?.progress ?? 0) * 100) + const stage = progress?.stage === 'loading' + ? 'Загружаю голосовую модель' + : progress?.stage === 'mastering' + ? 'Выравниваю громкость и финализирую' + : 'Генерирую новый дубль' + + return ( +
!busy && useVoiceoverUi.getState().close()}> +
e.stopPropagation()}> +
+
+ ЛОКАЛЬНАЯ ОЗВУЧКА / {clip.label ?? 'КЛИП'} +

Новый дубль

+
+ +
+ +
+
+ +