diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0898697..b2c6c07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, "agent/**"] pull_request: branches: [main] @@ -20,7 +20,8 @@ jobs: - name: Validate scripts run: | zsh -n scripts/*.sh Resources/install_runtime.sh - python3 -m py_compile worker/asr_worker.py worker/text_worker.py scripts/collect_github_stats.py + python3 -m py_compile worker/asr_worker.py worker/media_worker.py worker/text_worker.py scripts/collect_github_stats.py + python3 -m unittest discover -s tests -v VOICESWITCH_SETUP_VALIDATE_ONLY=1 \ Resources/install_runtime.sh \ "$RUNNER_TEMP/VoiceSwitch-Runtime" \ @@ -47,6 +48,17 @@ jobs: printf '%s\n' "$output" | grep -q '__VOICESWITCH_SETUP_ERROR__' grep -q 'Устанавливаю изолированный Python 3.12' "$runtime_root/install.log" + - name: Validate clean GigaAM dependency install + run: | + VOICESWITCH_SETUP_DEPENDENCIES_ONLY=1 \ + VOICESWITCH_RETRY_DELAY_SECONDS=0 \ + Resources/install_runtime.sh \ + "$RUNNER_TEMP/VoiceSwitch-GigaAM-Dependencies" \ + worker/asr_worker.py \ + worker/text_worker.py \ + gigaam + test ! -f "$RUNNER_TEMP/VoiceSwitch-GigaAM-Dependencies/install-complete.txt" + - name: Build VoiceSwitch run: swift build -c release @@ -56,3 +68,25 @@ jobs: ./scripts/build_app.sh "$RUNNER_TEMP/VoiceSwitch" codesign --verify --deep --strict "$RUNNER_TEMP/VoiceSwitch/VoiceSwitch.app" plutil -lint "$RUNNER_TEMP/VoiceSwitch/VoiceSwitch.app/Contents/Info.plist" + test -f "$RUNNER_TEMP/VoiceSwitch/VoiceSwitch.app/Contents/Resources/media_worker.py" + + - name: Package pull request test build + if: github.event_name == 'pull_request' + run: | + ditto -c -k --sequesterRsrc --keepParent \ + "$RUNNER_TEMP/VoiceSwitch/VoiceSwitch.app" \ + "$RUNNER_TEMP/VoiceSwitch-v0.4.0-beta-test.zip" + cd "$RUNNER_TEMP" + shasum -a 256 VoiceSwitch-v0.4.0-beta-test.zip \ + > VoiceSwitch-v0.4.0-beta-test.zip.sha256 + + - name: Upload pull request test build + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: VoiceSwitch-v0.4.0-beta-macos-arm64-test + path: | + ${{ runner.temp }}/VoiceSwitch-v0.4.0-beta-test.zip + ${{ runner.temp }}/VoiceSwitch-v0.4.0-beta-test.zip.sha256 + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a80a0ba..afaf979 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,30 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Validate release source + run: | + zsh -n scripts/*.sh Resources/install_runtime.sh + python3 -m py_compile worker/asr_worker.py worker/media_worker.py worker/text_worker.py + python3 -m unittest discover -s tests -v + bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist) + tag_version="${GITHUB_REF_NAME#v}" + test "$bundle_version" = "${tag_version%%-*}" + + - name: Validate clean GigaAM dependency install + run: | + VOICESWITCH_SETUP_DEPENDENCIES_ONLY=1 \ + VOICESWITCH_RETRY_DELAY_SECONDS=0 \ + Resources/install_runtime.sh \ + "$RUNNER_TEMP/VoiceSwitch-GigaAM-Dependencies" \ + worker/asr_worker.py \ + worker/text_worker.py \ + gigaam + - name: Build release archive run: | chmod +x scripts/*.sh Resources/install_runtime.sh ./scripts/package_release.sh "${GITHUB_REF_NAME#v}" + test -f "dist/VoiceSwitch-${GITHUB_REF_NAME#v}-macos-arm64/VoiceSwitch.app/Contents/Resources/media_worker.py" - name: Publish GitHub release env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c03567..bcbc8fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,8 @@ ```zsh swift build zsh -n scripts/*.sh Resources/install_runtime.sh -python3 -m py_compile worker/asr_worker.py worker/text_worker.py scripts/collect_github_stats.py +python3 -m py_compile worker/asr_worker.py worker/media_worker.py worker/text_worker.py scripts/collect_github_stats.py +python3 -m unittest discover -s tests -v ``` Для изменения распознавания желательно приложить обезличенный набор тестовых diff --git a/INSTALL.md b/INSTALL.md index 5b32d20..6c99db4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -80,6 +80,22 @@ Mac. Вернитесь в VoiceSwitch и нажмите **Проверить с буфере обмена, поэтому при проблеме его можно вставить вручную через `Command + V`. +### 7. Проверка аудио или видео + +1. В меню VoiceSwitch переключите **Диктовка** на **Файл**. +2. Оставьте GigaAM либо выберите установленный Whisper/Qwen. +3. Нажмите **Выбрать аудио или видео…** и укажите локальный файл. +4. После завершения нажмите **Показать файлы**. + +VoiceSwitch распознаёт только аудиодорожку и сохраняет `transcript.txt`, +`transcript.srt`, `transcript.vtt` и `transcript.json` в локальной папке +Application Support. Кадры видео не анализируются. Метки SRT/VTT приблизительные: +это границы блоков распознавания, а не точное время каждого слова. + +Если остановить длинную задачу, уже завершённые блоки останутся в checkpoint. +Повторно выберите тот же неизменённый файл, тот же движок и контекст, чтобы +продолжить. Apple SpeechAnalyzer в файловом режиме пока недоступен. + ### Если загрузка моделей прервалась Не удаляйте папку Runtime. Нажмите **Продолжить установку** в меню @@ -157,13 +173,21 @@ tccutil reset Accessibility io.github.mitimaicode.VoiceSwitch ~/Library/Application Support/VoiceSwitch ``` +Готовые расшифровки файлов находятся в подпапке `Transcripts`. VoiceSwitch +удаляет временный нормализованный WAV, но сохраняет TXT/SRT/VTT/JSON и +checkpoint для продолжения. Удалить конкретную расшифровку можно обычным +удалением её папки через Finder. + Чтобы полностью удалить VoiceSwitch, удалите приложение и эту папку. Записи -речи не архивируются: временный WAV удаляется после распознавания. +диктовки не архивируются: временный WAV удаляется после распознавания. ## Известные ограничения beta - только Apple Silicon и macOS 14+; - Apple SpeechAnalyzer доступен только на macOS 26+; +- импорт видео распознаёт только аудиодорожку и не захватывает системный звук; +- SRT/VTT используют примерные границы блоков, без word-level timestamps; +- файловый режим поддерживает GigaAM, Whisper и Qwen, но пока не Apple; - рекомендуемая установка требует около 2,5 ГБ; полный комплект — около 10 ГБ; - приложение пока не подписано сертификатом Apple Developer ID; - автоматическое обновление ещё не реализовано. diff --git a/README.md b/README.md index 2d12882..0250ee1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ VoiceSwitch — локальная диктовка для macOS

-> Локальная диктовка и редактура текста для macOS. +> Локальная диктовка и расшифровка аудио/видео для macOS. [![CI](https://github.com/mitimaicode/VoiceSwitch/actions/workflows/ci.yml/badge.svg)](https://github.com/mitimaicode/VoiceSwitch/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/v/release/mitimaicode/VoiceSwitch?include_prereleases)](https://github.com/mitimaicode/VoiceSwitch/releases) @@ -12,7 +12,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) VoiceSwitch записывает речь по глобальной горячей клавише, распознаёт её -полностью локально и вставляет текст в активное приложение. +полностью локально и вставляет текст в активное приложение. Отдельный режим +«Файл» расшифровывает аудиодорожку локального аудио или видео, умеет продолжить +прерванную задачу и сохраняет TXT, SRT, VTT и JSON.

@@ -31,6 +33,8 @@ VoiceSwitch записывает речь по глобальной горяче - **Qwen3-4B (MLX)** — локально исправляет или сокращает расшифровку; - режимы текста **Дословно**, **Исправить** и **Кратко**; - `fn + ⌥ Option` — начать или остановить запись; +- локальный импорт аудио и видео с прогрессом, отменой и продолжением; +- экспорт TXT, SRT, VTT и JSON; временные метки субтитров — по минутным блокам; - HUD показывает запись, расшифровку, локальную редактуру и результат; - аудио и текст не отправляются во внешние API; - журнал сравнения помогает выбрать модель под собственную речь. @@ -88,6 +92,8 @@ VoiceSwitch записывает речь по глобальной горяче ## Использование +### Диктовка + 1. Выберите GigaAM, Whisper, Qwen или Apple в меню VoiceSwitch. 2. Выберите режим текста: **Дословно**, **Исправить** или **Кратко**. 3. Нажмите `fn + ⌥ Option`, чтобы начать запись. @@ -106,6 +112,25 @@ VoiceSwitch записывает речь по глобальной горяче компактное сообщение для переписки. Если редактор вернёт ошибку, VoiceSwitch автоматически вставит исходную расшифровку. +### Аудио и видеофайлы + +1. Переключите источник с **Диктовка** на **Файл**. +2. Выберите GigaAM, Whisper или Qwen и нажмите **Выбрать аудио или видео…**. +3. Дождитесь завершения либо остановите задачу. При повторном выборе того же + неизменённого файла VoiceSwitch продолжит с сохранённого блока. +4. Нажмите **Показать файлы**, чтобы открыть TXT, SRT, VTT и JSON, или + **Скопировать текст**. + +VoiceSwitch извлекает и распознаёт только звуковую дорожку: кадры видео не +анализируются. Длинный материал делится на блоки по 60 секунд с перекрытием +1,5 секунды; повторяющиеся слова на стыках удаляются. Поэтому метки SRT/VTT +показывают примерные границы блоков, а не точное время каждого слова. + +Файловый режим пока не использует Apple SpeechAnalyzer и не применяет режимы +«Исправить»/«Кратко»: изменение длинного текста нарушило бы соответствие +субтитрам. Обработка выполняется последовательно, чтобы не держать две копии +тяжёлой модели в памяти. + Модель загружается лениво. Если выбранный движок ещё не установлен, VoiceSwitch покажет его размер и предложит загрузить только этот компонент. При переключении предыдущая загруженная модель @@ -126,6 +151,12 @@ Runtime и веса моделей: ~/Library/Application Support/VoiceSwitch/comparison.jsonl ``` +Расшифровки импортированных файлов: + +```text +~/Library/Application Support/VoiceSwitch/Transcripts +``` + В журнале сохраняются модель, длительность записи, время распознавания, RTF, определённый язык, исходная расшифровка, результат локальной редактуры, её режим и ваша оценка. Журнал остаётся на Mac. Временные аудиофайлы удаляются @@ -187,7 +218,7 @@ chmod +x scripts/*.sh Resources/install_runtime.sh Создание компактного release-архива без весов моделей: ```zsh -./scripts/package_release.sh 0.3.3-beta +./scripts/package_release.sh 0.4.0-beta ``` Публичный release-скрипт по умолчанию использует ad-hoc подпись. Для стабильной @@ -229,11 +260,13 @@ VoiceSwitch не связан с авторами перечисленных п

English summary -VoiceSwitch is a local macOS dictation menu-bar app for Apple Silicon. It -switches between GigaAM, Whisper Large V3 Turbo, Qwen3-ASR 1.7B, and Apple -SpeechAnalyzer. A local Qwen3-4B model can clean up or shorten the transcript. -Press `fn + Option` to start or stop recording. Audio and transcripts stay on -the Mac. Apple SpeechAnalyzer requires macOS 26 or newer. +VoiceSwitch is a local macOS dictation and media-transcription menu-bar app for +Apple Silicon. It switches between GigaAM, Whisper Large V3 Turbo, Qwen3-ASR +1.7B, and Apple SpeechAnalyzer. Press `fn + Option` to dictate, or import a +local audio/video file and export TXT, SRT, VTT, and JSON. Video import +transcribes the audio track only; subtitle timestamps are coarse chunk +boundaries. Audio and transcripts stay on the Mac. Apple SpeechAnalyzer +requires macOS 26 or newer and is currently limited to dictation mode. See [INSTALL.md](INSTALL.md) for installation details and use GitHub Issues or Discussions for feedback. diff --git a/RELEASE_NOTES_v0.4.0-beta.md b/RELEASE_NOTES_v0.4.0-beta.md new file mode 100644 index 0000000..0b2968f --- /dev/null +++ b/RELEASE_NOTES_v0.4.0-beta.md @@ -0,0 +1,48 @@ +# VoiceSwitch v0.4.0-beta + +Локальная расшифровка теперь работает не только с микрофоном, но и с аудио- +или видеофайлами. VoiceSwitch извлекает звуковую дорожку на Mac, обрабатывает +длинный материал частями и сохраняет результат без отправки во внешние API. + +## Что нового + +- Новый источник **Файл** рядом с обычной диктовкой. +- Импорт локального аудио или видео для GigaAM, Whisper и Qwen3-ASR. +- Обработка блоками по 60 секунд с перекрытием 1,5 секунды и удалением + повторяющихся слов на стыках. +- Видимый прогресс и безопасная остановка длинной задачи. +- Атомарный checkpoint после каждого блока: повторный выбор того же файла, + движка и контекста продолжает незавершённую работу. +- Локальный экспорт `transcript.txt`, `transcript.srt`, `transcript.vtt` и + `transcript.json`. +- Файловая задача запускается отдельно от worker диктовки, чтобы освободить + память перед загрузкой большой модели. +- Исправлена чистая установка GigaAM из + [issue #6](https://github.com/mitimaicode/VoiceSwitch/issues/6): `torch` и + `torchaudio` теперь устанавливаются явно в проверенных версиях. +- CI проверяет алгоритм блоков, дедупликацию, checkpoint, экспорт и выполняет + чистую установку/import зависимостей GigaAM без загрузки веса модели; + release workflow повторяет эти проверки до сборки файлов. + +## Важные ограничения + +- Из видео распознаётся только аудиодорожка. VoiceSwitch не анализирует кадры + и не захватывает системный звук. +- Временные метки SRT/VTT соответствуют примерным границам блоков, а не + отдельным словам. +- Apple SpeechAnalyzer пока работает только в режиме диктовки. +- Режимы «Исправить» и «Кратко» не применяются к файловой расшифровке, чтобы + текст не расходился с субтитрами. +- Это beta для Apple Silicon и macOS 14+. Публичная сборка подписана ad-hoc и + не нотарифицирована Apple. + +## Обновление + +Завершите VoiceSwitch, перенесите новую версию из DMG в «Программы» с заменой +и откройте её через правый клик → **Открыть**. Runtime v4, модели, настройки и +старый журнал остаются в `~/Library/Application Support/VoiceSwitch`, поэтому +повторная загрузка работающих моделей не требуется. + +Если прежняя установка GigaAM остановилась на ошибке `No module named +'torch'`, нажмите **Продолжить установку** — новый установщик восстановит +окружение и использует уже загруженные данные. diff --git a/Resources/Info.plist b/Resources/Info.plist index 4a2fdff..521a576 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.3.3 + 0.4.0 CFBundleVersion - 12 + 13 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Resources/install_runtime.sh b/Resources/install_runtime.sh index 6921d8a..6191970 100755 --- a/Resources/install_runtime.sh +++ b/Resources/install_runtime.sh @@ -19,6 +19,8 @@ COMPONENT_MARKERS_ROOT="${RUNTIME_ROOT}/components" UV_VERSION="0.11.32" GIGAAM_COMMIT="559d88d6b72541412743929f633a6ae7c9950b85" GIGAAM_ARCHIVE="https://github.com/salute-developers/GigaAM/archive/${GIGAAM_COMMIT}.zip" +TORCH_VERSION="2.13.0" +TORCHAUDIO_VERSION="2.11.0" STATUS_MARKER="__VOICESWITCH_SETUP__" ERROR_MARKER="__VOICESWITCH_SETUP_ERROR__" @@ -48,6 +50,8 @@ write_install_marker() { print -r -- "selective_install=1" print -r -- "uv_version=${UV_VERSION}" print -r -- "gigaam_commit=${GIGAAM_COMMIT}" + print -r -- "torch_version=${TORCH_VERSION}" + print -r -- "torchaudio_version=${TORCHAUDIO_VERSION}" local marker for marker in "${COMPONENT_MARKERS_ROOT}"/*.ready(N); do print -r -- "component=${marker:t:r}" @@ -245,12 +249,15 @@ ln -sf "${FFMPEG_EXECUTABLE}" "${BIN_ROOT}/ffmpeg" || \ fail_step "Настраиваю ffmpeg" if component_requested gigaam; then + # Начиная с torchaudio 2.11 пакет больше не объявляет torch своей + # зависимостью. Ставим обе проверенные версии явно (GitHub issue #6). run_with_retries \ "Устанавливаю движок GigaAM…" \ 3 \ "${UV_EXECUTABLE}" pip install \ --python "${PYTHON}" \ - torchaudio \ + "torch==${TORCH_VERSION}" \ + "torchaudio==${TORCHAUDIO_VERSION}" \ "${GIGAAM_ARCHIVE}" || fail_step "Устанавливаю движок GigaAM" status "Проверяю зависимости GigaAM…" @@ -259,6 +266,11 @@ if component_requested gigaam; then "Не удалось загрузить зависимости GigaAM (torch/torchaudio). Нажмите «Продолжить установку», чтобы восстановить окружение. Журнал: ${LOG_FILE}" fi + if [[ "${VOICESWITCH_SETUP_DEPENDENCIES_ONLY:-0}" == "1" ]]; then + status "Чистая проверка зависимостей GigaAM завершена." + exit 0 + fi + run_with_retries \ "Загружаю GigaAM v3 E2E RNNT…" \ 3 \ diff --git "a/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" "b/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" index eefb7ef..4566dd3 100644 --- "a/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" +++ "b/Resources/\320\237\320\225\320\240\320\222\320\253\320\231 \320\227\320\220\320\237\320\243\320\241\320\232.txt" @@ -11,6 +11,10 @@ VoiceSwitch — первый запуск 7. Поставьте курсор в поле ввода и дважды используйте fn + Option: один раз для старта записи, второй — для остановки. +Для локальной расшифровки аудио или видео переключите в меню «Диктовка» на +«Файл». Обрабатывается только аудиодорожка; готовые TXT/SRT/VTT/JSON можно +открыть кнопкой «Показать файлы». + Это бесплатная beta без платной подписи Apple. Действие «правый клик → Открыть» требуется только при первом запуске новой копии приложения. diff --git a/Sources/VoiceSwitch/AppState.swift b/Sources/VoiceSwitch/AppState.swift index c8e3e36..9296725 100644 --- a/Sources/VoiceSwitch/AppState.swift +++ b/Sources/VoiceSwitch/AppState.swift @@ -1,10 +1,23 @@ import AppKit import AVFoundation import Combine +import CryptoKit import Foundation import SwiftUI +import UniformTypeIdentifiers final class AppState: ObservableObject { + @Published var inputMode: InputMode { + didSet { + UserDefaults.standard.set(inputMode.rawValue, forKey: "inputMode") + if inputMode == .mediaFile, selectedEngine == .apple { + selectedEngine = .gigaam + } + status = inputMode == .mediaFile + ? "Выберите аудио или видео — распознаётся только звуковая дорожка." + : "Готово к записи" + } + } @Published var selectedEngine: ASREngine { didSet { UserDefaults.standard.set(selectedEngine.rawValue, forKey: "selectedEngine") @@ -14,7 +27,9 @@ final class AppState: ObservableObject { selectedInstallComponents.insert(component) status = "Для \(selectedEngine.shortTitle) установите модель \(component.title)." } else { - status = "Выбрана \(selectedEngine.shortTitle). Модель подготовится при первой записи." + status = inputMode == .mediaFile + ? "Выбрана \(selectedEngine.shortTitle). Можно выбрать аудио или видео." + : "Выбрана \(selectedEngine.shortTitle). Модель подготовится при первой записи." } } } @@ -49,13 +64,19 @@ final class AppState: ObservableObject { } } @Published private(set) var isRecording = false + @Published private(set) var isChoosingMediaFile = false @Published private(set) var isTranscribing = false + @Published private(set) var isTranscribingMedia = false @Published private(set) var isProcessingText = false @Published private(set) var status = "Готово к записи" @Published private(set) var lastText = "" @Published private(set) var lastMetrics = "" @Published private(set) var lastOutputMode: TextProcessingMode = .verbatim @Published private(set) var lastRating: String? + @Published private(set) var mediaProgress: Double? + @Published private(set) var mediaStatus = "" + @Published private(set) var mediaSourceName = "" + @Published private(set) var lastMediaOutputDirectory: URL? @Published private(set) var permissionsVersion = 0 @Published private(set) var runtimeReady = RuntimePaths.isRuntimeReady @Published private(set) var installedComponents = RuntimePaths.installedComponents @@ -76,6 +97,7 @@ final class AppState: ObservableObject { private let audioRecorder = AudioRecorder() private let hotKey = GlobalHotKey() private let asrService = ASRService() + private let mediaTranscriptionService = MediaTranscriptionService() private let textProcessingService = TextProcessingService() private let runtimeInstaller = RuntimeInstaller() private let appTracker = FrontmostApplicationTracker() @@ -83,12 +105,22 @@ final class AppState: ObservableObject { private var targetPID: pid_t? private var recordingSource = "button" private var lastResult: TranscriptionResult? + private var lastMediaResult: MediaTranscriptionResult? + private var mediaSecurityScopedURL: URL? private var uiTestWindow: NSWindow? init() { - let savedEngine = UserDefaults.standard.string(forKey: "selectedEngine") - .flatMap(ASREngine.init(rawValue:)) - selectedEngine = savedEngine ?? .gigaam + let resolvedInputMode = UserDefaults.standard.string(forKey: "inputMode") + .flatMap(InputMode.init(rawValue:)) + ?? .dictation + let storedEngine = UserDefaults.standard.string(forKey: "selectedEngine") + .flatMap(ASREngine.init(rawValue:)) ?? .gigaam + let resolvedEngine: ASREngine = + resolvedInputMode == .mediaFile && storedEngine == .apple + ? .gigaam + : storedEngine + inputMode = resolvedInputMode + selectedEngine = resolvedEngine let savedTextMode = UserDefaults.standard.string(forKey: "textProcessingMode") .flatMap(TextProcessingMode.init(rawValue:)) textProcessingMode = savedTextMode ?? .verbatim @@ -128,11 +160,18 @@ final class AppState: ObservableObject { guard let self, !self.isRecording, !self.isTranscribing, + !self.isTranscribingMedia, !self.isProcessingText else { return } self.status = message } + mediaTranscriptionService.onProgress = { [weak self] progress in + guard let self, self.isTranscribingMedia else { return } + self.mediaProgress = self.overallMediaProgress(progress) + self.mediaStatus = progress.message + self.status = progress.message + } textProcessingService.onWorkerEvent = { [weak self] message in guard let self, !self.isRecording, !self.isTranscribing else { return } self.status = message @@ -179,9 +218,11 @@ final class AppState: ObservableObject { let engineReady = selectedEngine.runtimeComponent.map { installedComponents.contains($0) } ?? true - let editorReady = textProcessingMode.runtimeComponent.map { - installedComponents.contains($0) - } ?? true + let editorReady = inputMode == .mediaFile + ? true + : textProcessingMode.runtimeComponent.map { + installedComponents.contains($0) + } ?? true return engineReady && editorReady } @@ -190,7 +231,8 @@ final class AppState: ObservableObject { if let component = selectedEngine.runtimeComponent { required.insert(component) } - if let component = textProcessingMode.runtimeComponent { + if inputMode == .dictation, + let component = textProcessingMode.runtimeComponent { required.insert(component) } return required.subtracting(installedComponents) @@ -206,7 +248,15 @@ final class AppState: ObservableObject { } var isBusy: Bool { - isTranscribing || isProcessingText + isChoosingMediaFile || isTranscribing || isTranscribingMedia || isProcessingText + } + + var canRateLastResult: Bool { + lastResult != nil && lastMediaResult == nil + } + + var hasMediaResult: Bool { + lastMediaResult != nil } func toggleRecording() { @@ -214,6 +264,17 @@ final class AppState: ObservableObject { } private func toggleRecording(source: String) { + guard !isChoosingMediaFile else { + status = "Сначала закройте окно выбора файла." + return + } + guard !isTranscribingMedia else { + status = "Сначала остановите расшифровку файла." + return + } + if source == "hotkey", inputMode == .mediaFile { + inputMode = .dictation + } if isRecording { stopRecording() } else { @@ -231,6 +292,10 @@ final class AppState: ObservableObject { recordingSource = source targetPID = appTracker.lastExternalPID + lastMediaResult = nil + lastMediaOutputDirectory = nil + lastText = "" + lastMetrics = "" switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: @@ -240,8 +305,10 @@ final class AppState: ObservableObject { Permissions.requestMicrophone { [weak self] granted in guard let self else { return } self.refreshPermissions() - if granted { + if granted, self.inputMode == .dictation, !self.isBusy { self.beginRecording() + } else if granted { + self.status = "Запись не начата: приложение уже занято." } else { self.status = VoiceSwitchError.microphoneDenied.localizedDescription } @@ -321,6 +388,56 @@ final class AppState: ObservableObject { } } + func chooseMediaFile() { + guard !isRecording, !isBusy else { return } + guard selectedEngine != .apple else { + status = "Для файлов выберите GigaAM, Whisper или Qwen." + return + } + guard selectedEngineReady else { + status = "Сначала установите выбранную модель." + return + } + + let panel = NSOpenPanel() + panel.title = "Расшифровать аудио или видео" + panel.message = "VoiceSwitch обработает только аудиодорожку и сохранит результат локально." + panel.prompt = "Расшифровать" + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.audio, .movie] + + NSApplication.shared.activate(ignoringOtherApps: true) + isChoosingMediaFile = true + let response = panel.runModal() + isChoosingMediaFile = false + guard response == .OK, let sourceURL = panel.url else { return } + guard inputMode == .mediaFile, !isRecording, !isBusy else { + status = "Не удалось начать задачу: приложение уже занято." + return + } + startMediaTranscription(sourceURL: sourceURL) + } + + func cancelMediaTranscription() { + guard isTranscribingMedia else { return } + mediaStatus = "Останавливаю задачу…" + status = mediaStatus + mediaTranscriptionService.cancel() + } + + func copyLastMediaText() { + guard let result = lastMediaResult else { return } + TextInjector.copy(result.text) + status = "Расшифровка скопирована." + } + + func openLastMediaOutputFolder() { + guard let directory = lastMediaOutputDirectory else { return } + NSWorkspace.shared.open(directory) + } + func prewarmSelectedEngine() { guard !isRecording, !isBusy else { return } guard selectedEngineReady else { @@ -482,6 +599,7 @@ final class AppState: ObservableObject { func quit() { hudController.hideImmediately() runtimeInstaller.cancel() + mediaTranscriptionService.shutdown() asrService.shutdown() textProcessingService.shutdown() NSApplication.shared.terminate(nil) @@ -498,6 +616,120 @@ final class AppState: ObservableObject { uiTestWindow = window } + private func startMediaTranscription(sourceURL: URL) { + let didAccess = sourceURL.startAccessingSecurityScopedResource() + mediaSecurityScopedURL = didAccess ? sourceURL : nil + do { + let engine = selectedEngine + let prompt = engine.supportsContext ? recognitionContext : "" + let outputDirectory = try mediaOutputDirectory( + for: sourceURL, + engine: engine, + prompt: prompt + ) + + // Файловая задача загружает собственную копию модели. Освобождаем + // worker диктовки, чтобы не удваивать расход памяти на Apple Silicon. + asrService.shutdown() + + isTranscribingMedia = true + mediaProgress = nil + mediaSourceName = sourceURL.lastPathComponent + mediaStatus = "Подготавливаю \(sourceURL.lastPathComponent)…" + status = mediaStatus + lastResult = nil + lastMediaResult = nil + lastRating = nil + lastMediaOutputDirectory = outputDirectory + lastText = "" + lastMetrics = "" + + mediaTranscriptionService.transcribe( + sourceURL: sourceURL, + outputDirectory: outputDirectory, + engine: engine, + prompt: prompt + ) { [weak self] result in + guard let self else { return } + self.releaseMediaSecurityScope() + self.isTranscribingMedia = false + + switch result { + case .success(let transcription): + self.lastMediaResult = transcription + self.lastMediaOutputDirectory = transcription.outputDirectory + self.lastText = transcription.text + self.lastOutputMode = .verbatim + self.lastMetrics = self.mediaMetrics(for: transcription) + self.mediaProgress = 1 + let hasSpeech = !transcription.text + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + if hasSpeech { + self.mediaStatus = "Готово — сохранены TXT, SRT, VTT и JSON." + } else { + self.mediaStatus = "Обработка завершена, но речь не обнаружена. Файлы результата сохранены." + } + self.status = self.mediaStatus + self.hudController.showSuccess("Файл расшифрован") + case .failure(let error): + self.mediaProgress = nil + self.mediaStatus = error.localizedDescription + self.status = error.localizedDescription + if let voiceSwitchError = error as? VoiceSwitchError, + case .mediaCancelled = voiceSwitchError { + self.hudController.showFailure("Задача остановлена") + } else { + self.hudController.showFailure("Ошибка обработки файла") + } + } + } + } catch { + releaseMediaSecurityScope() + status = error.localizedDescription + mediaStatus = error.localizedDescription + hudController.showFailure("Не удалось начать обработку") + } + } + + private func mediaOutputDirectory( + for sourceURL: URL, + engine: ASREngine, + prompt: String + ) throws -> URL { + let attributes = try FileManager.default.attributesOfItem( + atPath: sourceURL.path + ) + let size = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + let modified = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let promptDigest = SHA256.hash(data: Data(prompt.utf8)) + .map { String(format: "%02x", $0) } + .joined() + let identity = "\(sourceURL.standardizedFileURL.path)|\(size)|\(modified)|\(engine.rawValue)|\(promptDigest)" + let digest = SHA256.hash(data: Data(identity.utf8)) + .prefix(6) + .map { String(format: "%02x", $0) } + .joined() + let baseName = sourceURL.deletingPathExtension().lastPathComponent + let safeName = baseName + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + let folderName = "\(safeName.isEmpty ? "media" : safeName)-\(engine.rawValue)-\(digest)" + let directory = RuntimePaths.transcriptsRoot + .appendingPathComponent(folderName, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory + } + + private func releaseMediaSecurityScope() { + mediaSecurityScopedURL?.stopAccessingSecurityScopedResource() + mediaSecurityScopedURL = nil + } + private func process( transcription: TranscriptionResult, mode: TextProcessingMode, @@ -602,6 +834,35 @@ final class AppState: ObservableObject { ) } + private func mediaMetrics(for transcription: MediaTranscriptionResult) -> String { + let factor = transcription.mediaDuration > 0 + ? transcription.latency / transcription.mediaDuration + : 0 + return String( + format: "%.1f мин медиа → %.1f мин ASR · %d блоков · RTF %.2f", + transcription.mediaDuration / 60, + transcription.latency / 60, + transcription.segmentCount, + factor + ) + } + + private func overallMediaProgress(_ progress: MediaProgress) -> Double? { + guard let fraction = progress.fraction else { return nil } + switch progress.phase { + case .preparing: + return 0 + case .normalizing: + return fraction * 0.1 + case .loading: + return 0.1 + fraction * 0.1 + case .transcribing: + return 0.2 + fraction * 0.75 + case .exporting: + return 0.95 + fraction * 0.05 + } + } + private func beginRecording() { do { _ = try audioRecorder.start() @@ -615,6 +876,7 @@ final class AppState: ObservableObject { } deinit { + mediaTranscriptionService.shutdown() hotKey.stop() } } diff --git a/Sources/VoiceSwitch/ContentView.swift b/Sources/VoiceSwitch/ContentView.swift index da00a21..c826854 100644 --- a/Sources/VoiceSwitch/ContentView.swift +++ b/Sources/VoiceSwitch/ContentView.swift @@ -21,18 +21,27 @@ struct ContentView: View { } private var mainContent: some View { - VStack(alignment: .leading, spacing: 14) { - header - if state.shouldShowRuntimeSetup { - runtimeSetupSection + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + if state.shouldShowRuntimeSetup { + runtimeSetupSection + } + inputModePicker + enginePicker + if state.inputMode == .dictation { + textModePicker + recordSection + } else { + MediaImportView(state: state) + } + resultSection + settingsSection + footer } - enginePicker - textModePicker - recordSection - resultSection - settingsSection - footer + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxHeight: 720) } @ViewBuilder @@ -358,7 +367,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 2) { Text("VoiceSwitch") .font(.headline) - Text("Локальная диктовка и редактура текста") + Text("Локальная диктовка и расшифровка файлов") .font(.caption) .foregroundStyle(.secondary) } @@ -373,7 +382,7 @@ struct ContentView: View { .foregroundStyle(.secondary) Picker("Модель", selection: $state.selectedEngine) { - ForEach(ASREngine.allCases) { engine in + ForEach(availableEngines) { engine in Text(engine.shortTitle).tag(engine) } } @@ -386,6 +395,16 @@ struct ContentView: View { } } + private var inputModePicker: some View { + Picker("Источник", selection: $state.inputMode) { + ForEach(InputMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(state.isRecording || state.isBusy || state.isInstallingRuntime) + } + private var textModePicker: some View { VStack(alignment: .leading, spacing: 7) { Text("Обработка текста") @@ -450,7 +469,7 @@ struct ContentView: View { Text("Последний текст") .font(.caption) .foregroundStyle(.secondary) - Text(state.lastOutputMode.title) + Text(state.hasMediaResult ? "Файл" : state.lastOutputMode.title) .font(.caption2.weight(.medium)) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -474,17 +493,26 @@ struct ContentView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) HStack(spacing: 8) { - Text("Оценка:") - .font(.caption2) - .foregroundStyle(.secondary) - Button("Хорошо") { - state.rateLastResult("accurate") - } - Button("Нужно исправить") { - state.rateLastResult("errors") + if state.hasMediaResult { + Button("Скопировать текст") { + state.copyLastMediaText() + } + Button("Показать файлы") { + state.openLastMediaOutputFolder() + } + } else if state.canRateLastResult { + Text("Оценка:") + .font(.caption2) + .foregroundStyle(.secondary) + Button("Хорошо") { + state.rateLastResult("accurate") + } + Button("Нужно исправить") { + state.rateLastResult("errors") + } } Spacer() - if state.lastRating != nil { + if state.lastRating != nil && state.canRateLastResult { Label("Сохранено", systemImage: "checkmark") .font(.caption2) .foregroundStyle(.green) @@ -499,8 +527,10 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 9) { Divider() - Toggle("Автоматически вставлять текст в активное приложение", isOn: $state.autoPaste) - .font(.caption) + if state.inputMode == .dictation { + Toggle("Автоматически вставлять текст в активное приложение", isOn: $state.autoPaste) + .font(.caption) + } if state.selectedEngine.supportsContext { TextField( @@ -511,23 +541,27 @@ struct ContentView: View { .font(.caption) } - VStack(alignment: .leading, spacing: 3) { - Label(state.microphoneStatus, systemImage: "mic") - Label( - state.accessibilityStatus, - systemImage: state.accessibilityAuthorized ? "checkmark.shield" : "exclamationmark.shield" - ) + if state.inputMode == .dictation { + VStack(alignment: .leading, spacing: 3) { + Label(state.microphoneStatus, systemImage: "mic") + Label( + state.accessibilityStatus, + systemImage: state.accessibilityAuthorized ? "checkmark.shield" : "exclamationmark.shield" + ) + } + .font(.caption2) + .foregroundStyle(.secondary) } - .font(.caption2) - .foregroundStyle(.secondary) HStack { Button("Подготовить модель") { state.prewarmSelectedEngine() } .disabled(!state.selectedEngineReady || state.isBusy || state.isInstallingRuntime) - Button("Разрешить доступ") { - state.requestAccessibility() + if state.inputMode == .dictation { + Button("Разрешить доступ") { + state.requestAccessibility() + } } Spacer() Button("Журнал тестов") { @@ -540,7 +574,11 @@ struct ContentView: View { private var footer: some View { HStack { - Text("fn + ⌥ — начать или остановить запись") + Text( + state.inputMode == .dictation + ? "fn + ⌥ — начать или остановить запись" + : "Все файлы остаются на этом Mac" + ) .font(.caption2) .foregroundStyle(.tertiary) Spacer() @@ -559,6 +597,12 @@ struct ContentView: View { return names.isEmpty ? "системный режим Apple" : names.joined(separator: ", ") } + private var availableEngines: [ASREngine] { + state.inputMode == .mediaFile + ? ASREngine.allCases.filter { $0 != .apple } + : ASREngine.allCases + } + private func onboardingFeature(_ icon: String, _ title: String) -> some View { Label(title, systemImage: icon) .font(.callout) diff --git a/Sources/VoiceSwitch/MediaImportView.swift b/Sources/VoiceSwitch/MediaImportView.swift new file mode 100644 index 0000000..67f0239 --- /dev/null +++ b/Sources/VoiceSwitch/MediaImportView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct MediaImportView: View { + @ObservedObject var state: AppState + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 9) { + Image(systemName: "film.stack") + .foregroundStyle(.indigo) + .frame(width: 20) + VStack(alignment: .leading, spacing: 3) { + Text("Локальная расшифровка файла") + .font(.subheadline.weight(.semibold)) + Text("Аудиодорожка обрабатывается на Mac блоками по 60 секунд. Изображение видео не анализируется.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if state.isTranscribingMedia { + if let progress = state.mediaProgress { + ProgressView(value: progress) + } else { + ProgressView() + .controlSize(.small) + } + + if !state.mediaSourceName.isEmpty { + Text(state.mediaSourceName) + .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + } + Text(state.mediaStatus) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + + Button("Остановить задачу") { + state.cancelMediaTranscription() + } + .controlSize(.small) + } else { + Button { + state.chooseMediaFile() + } label: { + HStack { + Image(systemName: "doc.badge.plus") + Text("Выбрать аудио или видео…") + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .tint(.indigo) + .disabled(!state.selectedEngineReady || state.selectedEngine == .apple) + + if !state.mediaStatus.isEmpty { + Text(state.mediaStatus) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + if state.lastMediaOutputDirectory != nil && + (!state.hasMediaResult || state.lastText.isEmpty) { + Button("Показать папку задачи") { + state.openLastMediaOutputFolder() + } + .controlSize(.small) + } + + if state.selectedEngine == .apple { + Text("Для файлов выберите GigaAM, Whisper или Qwen. Apple пока работает только с диктовкой.") + .font(.caption2) + .foregroundStyle(.orange) + } else { + Text("Результат: обычный текст и субтитры SRT/VTT с временными метками по блокам.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .padding(11) + .background(Color.indigo.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Sources/VoiceSwitch/MediaTranscriptionService.swift b/Sources/VoiceSwitch/MediaTranscriptionService.swift new file mode 100644 index 0000000..589f4ee --- /dev/null +++ b/Sources/VoiceSwitch/MediaTranscriptionService.swift @@ -0,0 +1,426 @@ +import Darwin +import Foundation + +final class MediaTranscriptionService { + typealias Completion = (Result) -> Void + + private let queue = DispatchQueue(label: "VoiceSwitch.MediaTranscriptionService") + private var process: Process? + private var inputHandle: FileHandle? + private var outputPipe: Pipe? + private var outputBuffer = Data() + private var completion: Completion? + private var pendingTerminalResult: Result? + private var activeEngine: ASREngine? + private var activeRequestID: String? + private var activeGeneration: UUID? + private var activeOutputDirectory: URL? + private var cancellationRequested = false + private var outputReachedEOF = false + private var terminationStatus: Int32? + + var onProgress: ((MediaProgress) -> Void)? + + func transcribe( + sourceURL: URL, + outputDirectory: URL, + engine: ASREngine, + prompt: String, + completion: @escaping Completion + ) { + queue.async { [weak self] in + guard let self else { return } + guard self.completion == nil else { + self.completeOnMain( + completion, + with: .failure( + VoiceSwitchError.workerFailed( + "Уже выполняется расшифровка другого файла." + ) + ) + ) + return + } + + do { + try self.start( + sourceURL: sourceURL, + outputDirectory: outputDirectory, + engine: engine, + prompt: prompt, + completion: completion + ) + } catch { + if let process = self.process, process.isRunning { + process.terminate() + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + } + self.removeActiveWorkDirectory() + self.completion = nil + self.clearProcessReferences() + self.completeOnMain(completion, with: .failure(error)) + } + } + } + + func cancel() { + queue.async { [weak self] in + guard let self, self.completion != nil else { return } + self.cancellationRequested = true + if let process = self.process, process.isRunning { + process.terminate() + self.queue.asyncAfter(deadline: .now() + 2) { + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + } + } + } + } + } + + func shutdown() { + queue.sync { + guard completion != nil else { return } + cancellationRequested = true + if let process, process.isRunning { + process.terminate() + let deadline = Date().addingTimeInterval(0.25) + while process.isRunning, Date() < deadline { + usleep(20_000) + } + if process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + } + removeActiveWorkDirectory() + finish(.failure(VoiceSwitchError.mediaCancelled)) + } + } + + private func start( + sourceURL: URL, + outputDirectory: URL, + engine: ASREngine, + prompt: String, + completion: @escaping Completion + ) throws { + guard engine != .apple else { + throw VoiceSwitchError.unsupportedMedia( + "Apple SpeechAnalyzer пока доступен только для диктовки." + ) + } + if let component = engine.runtimeComponent, + !RuntimePaths.isInstalled(component) { + throw VoiceSwitchError.runtimeMissing( + "Модель \(component.title) ещё не установлена." + ) + } + + let python = RuntimePaths.pythonExecutable + let worker = RuntimePaths.mediaWorkerScript + let ffmpeg = RuntimePaths.ffmpegExecutable + guard FileManager.default.isExecutableFile(atPath: python.path) else { + throw VoiceSwitchError.runtimeMissing( + "Локальное Python-окружение не установлено." + ) + } + guard FileManager.default.fileExists(atPath: worker.path) else { + throw VoiceSwitchError.runtimeMissing( + "Не найден модуль расшифровки файлов: \(worker.path)" + ) + } + guard FileManager.default.isExecutableFile(atPath: ffmpeg.path) else { + throw VoiceSwitchError.runtimeMissing( + "Локальный ffmpeg не установлен. Продолжите установку моделей." + ) + } + guard FileManager.default.isReadableFile(atPath: sourceURL.path) else { + throw VoiceSwitchError.unsupportedMedia("файл недоступен для чтения.") + } + + try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + + let requestID = UUID().uuidString + let generation = UUID() + let newProcess = Process() + let newInput = Pipe() + let newOutput = Pipe() + newProcess.executableURL = python + newProcess.arguments = [worker.path] + newProcess.standardInput = newInput + newProcess.standardOutput = newOutput + newProcess.standardError = newOutput + + var environment = ProcessInfo.processInfo.environment + environment["PYTHONUNBUFFERED"] = "1" + environment["HF_HOME"] = RuntimePaths.modelCache + .appendingPathComponent("huggingface", isDirectory: true).path + environment["TOKENIZERS_PARALLELISM"] = "false" + environment["PYTHONPATH"] = RuntimePaths.pythonPackages.path + let runtimeBin = RuntimePaths.runtimeRoot + .appendingPathComponent("bin", isDirectory: true).path + environment["PATH"] = "\(runtimeBin):\(environment["PATH"] ?? "/usr/bin:/bin")" + newProcess.environment = environment + + newOutput.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.queue.async { + guard let self, + self.activeGeneration == generation else { + return + } + if data.isEmpty { + self.consumeBufferedTail() + self.outputReachedEOF = true + self.finishTerminatedProcessIfReady() + } else { + self.consume(data) + } + } + } + + newProcess.terminationHandler = { [weak self, weak newProcess] terminated in + self?.queue.async { + guard let self, + self.process === newProcess else { + return + } + self.terminationStatus = terminated.terminationStatus + self.finishTerminatedProcessIfReady() + } + } + + self.completion = completion + activeEngine = engine + activeRequestID = requestID + activeGeneration = generation + activeOutputDirectory = outputDirectory + cancellationRequested = false + outputReachedEOF = false + terminationStatus = nil + pendingTerminalResult = nil + outputBuffer.removeAll(keepingCapacity: true) + + try newProcess.run() + process = newProcess + inputHandle = newInput.fileHandleForWriting + outputPipe = newOutput + + let payload: [String: Any] = [ + "id": requestID, + "input": sourceURL.path, + "output_dir": outputDirectory.path, + "cache": RuntimePaths.modelCache.path, + "engine": engine.rawValue, + "prompt": prompt, + "chunk_seconds": 60.0, + "overlap_seconds": 1.5 + ] + var data = try JSONSerialization.data(withJSONObject: payload) + data.append(0x0A) + try inputHandle?.write(contentsOf: data) + try inputHandle?.close() + inputHandle = nil + } + + private func consume(_ data: Data) { + outputBuffer.append(data) + + while let newline = outputBuffer.firstIndex(of: 0x0A) { + let lineData = outputBuffer[..) { + guard let completion else { return } + self.completion = nil + clearProcessReferences() + completeOnMain(completion, with: result) + } + + private func completeOnMain( + _ completion: @escaping Completion, + with result: Result + ) { + DispatchQueue.main.async { + completion(result) + } + } + + private func clearProcessReferences() { + outputPipe?.fileHandleForReading.readabilityHandler = nil + try? inputHandle?.close() + process = nil + inputHandle = nil + outputPipe = nil + activeEngine = nil + activeRequestID = nil + activeGeneration = nil + activeOutputDirectory = nil + pendingTerminalResult = nil + outputReachedEOF = false + terminationStatus = nil + outputBuffer.removeAll(keepingCapacity: true) + } + + private func removeActiveWorkDirectory() { + guard let activeOutputDirectory else { return } + let workDirectory = activeOutputDirectory + .appendingPathComponent(".work", isDirectory: true) + try? FileManager.default.removeItem(at: workDirectory) + } + + deinit { + outputPipe?.fileHandleForReading.readabilityHandler = nil + if let process, process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + } + removeActiveWorkDirectory() + } +} diff --git a/Sources/VoiceSwitch/Models.swift b/Sources/VoiceSwitch/Models.swift index bcab32b..e16d31b 100644 --- a/Sources/VoiceSwitch/Models.swift +++ b/Sources/VoiceSwitch/Models.swift @@ -120,6 +120,22 @@ enum OnboardingStep { case ready } +enum InputMode: String, CaseIterable, Identifiable { + case dictation + case mediaFile + + var id: String { rawValue } + + var title: String { + switch self { + case .dictation: + return "Диктовка" + case .mediaFile: + return "Файл" + } + } +} + enum ASREngine: String, CaseIterable, Identifiable, Codable { case gigaam case whisper @@ -272,12 +288,58 @@ struct TextProcessingResult { let latency: Double } +enum MediaJobPhase: String { + case preparing + case normalizing + case loading + case transcribing + case exporting + + var title: String { + switch self { + case .preparing: + return "Подготавливаю файл…" + case .normalizing: + return "Извлекаю аудиодорожку…" + case .loading: + return "Загружаю модель…" + case .transcribing: + return "Распознаю речь…" + case .exporting: + return "Сохраняю результаты…" + } + } +} + +struct MediaProgress { + let phase: MediaJobPhase + let message: String + let completedChunks: Int + let totalChunks: Int + let fraction: Double? +} + +struct MediaTranscriptionResult { + let requestID: String + let engine: ASREngine + let sourceName: String + let text: String + let latency: Double + let mediaDuration: Double + let segmentCount: Int + let outputDirectory: URL + let files: [String: URL] +} + enum VoiceSwitchError: LocalizedError { case runtimeMissing(String) case workerFailed(String) case invalidResponse case microphoneDenied case recordingFailed(String) + case mediaCancelled + case noAudio + case unsupportedMedia(String) var errorDescription: String? { switch self { @@ -291,6 +353,12 @@ enum VoiceSwitchError: LocalizedError { return "Нет доступа к микрофону." case .recordingFailed(let message): return "Не удалось записать звук: \(message)" + case .mediaCancelled: + return "Расшифровка файла остановлена. Готовые блоки будут использованы при повторном запуске." + case .noAudio: + return "В выбранном файле не найдена аудиодорожка." + case .unsupportedMedia(let message): + return "Не удалось обработать файл: \(message)" } } } diff --git a/Sources/VoiceSwitch/RuntimePaths.swift b/Sources/VoiceSwitch/RuntimePaths.swift index 7f899b0..c3d8435 100644 --- a/Sources/VoiceSwitch/RuntimePaths.swift +++ b/Sources/VoiceSwitch/RuntimePaths.swift @@ -80,6 +80,28 @@ enum RuntimePaths { return applicationSupportRoot.appendingPathComponent("text_worker.py") } + static var mediaWorkerScript: URL { + if let custom = ProcessInfo.processInfo.environment["VOICESWITCH_MEDIA_WORKER"] { + return URL(fileURLWithPath: custom) + } + + if let bundled = Bundle.main.url(forResource: "media_worker", withExtension: "py") { + return bundled + } + + return applicationSupportRoot.appendingPathComponent("media_worker.py") + } + + static var ffmpegExecutable: URL { + runtimeRoot + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent("ffmpeg") + } + + static var transcriptsRoot: URL { + applicationSupportRoot.appendingPathComponent("Transcripts", isDirectory: true) + } + static var installerScript: URL? { Bundle.main.url(forResource: "install_runtime", withExtension: "sh") } diff --git a/docs/releases/v0.4.0-beta/ARTICLE_EVIDENCE.md b/docs/releases/v0.4.0-beta/ARTICLE_EVIDENCE.md new file mode 100644 index 0000000..88e5a48 --- /dev/null +++ b/docs/releases/v0.4.0-beta/ARTICLE_EVIDENCE.md @@ -0,0 +1,39 @@ +# Факты для публикаций о VoiceSwitch v0.4.0-beta + +Заполняется только результатами реального теста на Mac. Пустые поля нельзя +заменять предположениями в релизе или статьях. + +## Среда + +- Модель Mac: +- Объём памяти: +- Версия macOS: +- Commit релиз-кандидата: +- Движок и версии зависимостей: + +## Контрольные материалы + +| Материал | Формат | Длительность | Язык/условия | Разрешено публично | +|---|---|---:|---|---| +| | | | | | + +## Результаты + +| Движок | Материал | Время ASR | RTF | Пиковая память | Продолжение после отмены | Замечания | +|---|---|---:|---:|---:|---|---| +| GigaAM | | | | | | | +| Whisper | | | | | | | +| Qwen3-ASR | | | | | | | + +## Проверенные утверждения + +- [ ] Аудио и результаты не уходили во внешние API после загрузки моделей. +- [ ] Видео использовалось только как контейнер аудиодорожки. +- [ ] Checkpoint действительно пропустил уже завершённые блоки. +- [ ] Дедупликация убрала точные повторы минимум из трёх слов. +- [ ] SRT/VTT имеют примерные, а не word-level таймкоды. +- [ ] Чистая установка GigaAM исправляет GitHub issue #6. + +## Ошибки и неожиданные результаты + +Записать здесь все сбои, даже если они не попадут в статью. diff --git a/docs/releases/v0.4.0-beta/MAC_TEST_CHECKLIST.md b/docs/releases/v0.4.0-beta/MAC_TEST_CHECKLIST.md new file mode 100644 index 0000000..88e4999 --- /dev/null +++ b/docs/releases/v0.4.0-beta/MAC_TEST_CHECKLIST.md @@ -0,0 +1,79 @@ +# Проверка VoiceSwitch v0.4.0-beta на Mac + +Этот список обязателен перед тегом и публичными статьями. Сервер проверяет +алгоритмы и исходники, но не может доказать работу SwiftUI, MLX, Gatekeeper, +микрофона и реальных моделей macOS. + +## 1. Сборка и упаковка + +- [ ] CI GitHub завершился успешно на `macos-26`. +- [ ] В `VoiceSwitch.app` указаны версия `0.4.0` и build `13`. +- [ ] Внутри приложения есть `asr_worker.py`, `media_worker.py`, + `text_worker.py` и `install_runtime.sh`. +- [ ] Проверены ad-hoc подпись приложения, DMG, ZIP и обе SHA-256. +- [ ] Запускается копия `/Applications/VoiceSwitch.app`, а не приложение из + DMG, `Downloads` или старой папки. + +## 2. Исправление установки GigaAM + +На чистом тестовом профиле без готового Runtime: + +- [ ] Установить комплект «Русская диктовка». +- [ ] Убедиться, что этап проверки `torch/torchaudio` проходит без + `ModuleNotFoundError`. +- [ ] Распознать короткую русскую фразу через GigaAM. +- [ ] Прервать установку один раз и проверить кнопку «Продолжить установку». + +На обычном профиле с Runtime v4 от v0.3.3: + +- [ ] Заменить только приложение; модели не должны загружаться заново. +- [ ] Проверить существующие GigaAM, Whisper и Qwen, если они установлены. + +## 3. Регрессия диктовки + +- [ ] `fn + Option` начинает и останавливает запись. +- [ ] GigaAM распознаёт короткую и длинную русскую фразу. +- [ ] Whisper распознаёт смешанную русско-английскую фразу. +- [ ] Qwen3-ASR распознаёт короткий файл без зависания интерфейса. +- [ ] Apple SpeechAnalyzer работает на macOS 26+ либо показывает понятное + ограничение на более старой системе. +- [ ] «Дословно», «Исправить» и «Кратко» работают как в v0.3.3. +- [ ] Автовставка проверена в нативном приложении и в Electron/WebView; + без Accessibility текст остаётся в буфере. + +## 4. Новый режим «Файл» + +Для каждого установленного движка GigaAM, Whisper и Qwen: + +- [ ] WAV длительностью 10–30 секунд. +- [ ] MP3 или M4A длительностью 10–30 секунд. +- [ ] MP4/MOV с речью; расшифровывается именно аудиодорожка. +- [ ] Файл длиннее 125 секунд создаёт минимум три блока и доходит до 100%. +- [ ] Кириллица и пробелы в имени и пути не мешают обработке. +- [ ] Кнопка остановки завершает процесс; повторный выбор того же файла + продолжает задачу, а не начинает с первого блока. +- [ ] Изменение контекста распознавания не переиспользует старый checkpoint. +- [ ] Файл без аудиодорожки даёт понятное сообщение. +- [ ] Повреждённый файл даёт понятное сообщение и не закрывает приложение. + +## 5. Экспорт и приватность + +- [ ] Созданы `transcript.txt`, `.srt`, `.vtt` и `.json`. +- [ ] Текст не повторяется на границах минутных блоков. +- [ ] Cue в SRT/VTT идут по порядку и не перекрываются. +- [ ] JSON помечает таймкоды как `coarse_chunk_boundaries`. +- [ ] В JSON и checkpoint нет полного исходного пути и текста контекстной + подсказки. +- [ ] Кнопки «Скопировать текст» и «Показать файлы» работают. +- [ ] Временные `normalized.wav` и `chunk-*.wav` не остаются после успеха. +- [ ] После первой загрузки моделей обработка работает без сети. + +## 6. Нагрузка и финальная приёмка + +- [ ] Во время файловой задачи нет второй оставшейся копии worker диктовки. +- [ ] После отмены не остаются процессы Python/ffmpeg и память освобождается. +- [ ] Проверен минимум один файл длительностью 30–60 минут. +- [ ] Результат, время ASR, модель Mac, macOS и пиковая память записаны в + `ARTICLE_EVIDENCE.md` рядом с этим файлом. +- [ ] После загрузки DMG браузером пройден реальный сценарий Gatekeeper. +- [ ] Иван подтвердил итоговую сборку до создания тега `v0.4.0-beta`. diff --git a/scripts/build_app.sh b/scripts/build_app.sh index 79cca92..39146ec 100755 --- a/scripts/build_app.sh +++ b/scripts/build_app.sh @@ -72,6 +72,7 @@ mkdir -p \ cp "${PROJECT_ROOT}/.build/release/VoiceSwitch" "${CONTENTS}/MacOS/VoiceSwitch" cp "${PROJECT_ROOT}/Resources/Info.plist" "${CONTENTS}/Info.plist" cp "${PROJECT_ROOT}/worker/asr_worker.py" "${CONTENTS}/Resources/asr_worker.py" +cp "${PROJECT_ROOT}/worker/media_worker.py" "${CONTENTS}/Resources/media_worker.py" cp "${PROJECT_ROOT}/worker/text_worker.py" "${CONTENTS}/Resources/text_worker.py" cp "${PROJECT_ROOT}/Resources/install_runtime.sh" "${CONTENTS}/Resources/install_runtime.sh" cp "${PROJECT_ROOT}/README.md" "${OUTPUT_ROOT}/README.md" diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh index 8b46c31..a0e1da8 100755 --- a/scripts/run_dev.sh +++ b/scripts/run_dev.sh @@ -6,6 +6,7 @@ PROJECT_ROOT=${SCRIPT_DIR:h} export VOICESWITCH_RUNTIME="${PROJECT_ROOT}/Runtime" export VOICESWITCH_WORKER="${PROJECT_ROOT}/worker/asr_worker.py" +export VOICESWITCH_MEDIA_WORKER="${PROJECT_ROOT}/worker/media_worker.py" export VOICESWITCH_TEXT_WORKER="${PROJECT_ROOT}/worker/text_worker.py" export VOICESWITCH_PYTHON="${PROJECT_ROOT}/Runtime/venv/bin/python3" diff --git a/tests/test_installer_contract.py b/tests/test_installer_contract.py new file mode 100644 index 0000000..11c7768 --- /dev/null +++ b/tests/test_installer_contract.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class InstallerContractTests(unittest.TestCase): + def test_gigaam_installs_explicit_compatible_torch_pair(self) -> None: + script = (PROJECT_ROOT / "Resources" / "install_runtime.sh").read_text() + + self.assertIn('TORCH_VERSION="2.13.0"', script) + self.assertIn('TORCHAUDIO_VERSION="2.11.0"', script) + self.assertIn('"torch==${TORCH_VERSION}"', script) + self.assertIn('"torchaudio==${TORCHAUDIO_VERSION}"', script) + self.assertIn("VOICESWITCH_SETUP_DEPENDENCIES_ONLY", script) + + def test_swift_and_installer_expect_same_runtime_version(self) -> None: + script = (PROJECT_ROOT / "Resources" / "install_runtime.sh").read_text() + runtime_paths = ( + PROJECT_ROOT / "Sources" / "VoiceSwitch" / "RuntimePaths.swift" + ).read_text() + + marker_match = re.search(r'print -r -- "runtime_version=(\d+)"', script) + swift_match = re.search(r"expectedRuntimeVersion = (\d+)", runtime_paths) + self.assertIsNotNone(marker_match) + self.assertIsNotNone(swift_match) + self.assertEqual(marker_match.group(1), swift_match.group(1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_media_worker.py b/tests/test_media_worker.py new file mode 100644 index 0000000..c86b97f --- /dev/null +++ b/tests/test_media_worker.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +import wave +from pathlib import Path +from unittest import mock + + +WORKER_DIR = Path(__file__).resolve().parents[1] / "worker" +sys.path.insert(0, str(WORKER_DIR)) + +import media_worker # noqa: E402 + + +class DedupeTests(unittest.TestCase): + def test_removes_longest_normalized_overlap(self) -> None: + previous = "Сначала было важное начало. Потом — три нужных слова!" + current = "ТРИ нужных слова, а затем новый фрагмент." + self.assertEqual( + media_worker.deduplicate_prefix(previous, current), + "а затем новый фрагмент.", + ) + + def test_keeps_overlap_shorter_than_three_words(self) -> None: + self.assertEqual( + media_worker.deduplicate_prefix("один два", "Один, два, три"), + "Один, два, три", + ) + + def test_fully_duplicated_text_becomes_empty(self) -> None: + self.assertEqual( + media_worker.deduplicate_prefix("раз два три", "Раз, два, три!"), + "", + ) + + +class ChunkBoundaryTests(unittest.TestCase): + def test_overlapping_ranges_cover_the_tail(self) -> None: + self.assertEqual( + media_worker.chunk_boundaries(1_300, 10, 60.0, 1.5), + [(0, 600), (585, 1_185), (1_170, 1_300)], + ) + + def test_short_audio_is_one_chunk(self) -> None: + self.assertEqual( + media_worker.chunk_boundaries(50, 10, 60.0, 1.5), + [(0, 50)], + ) + + def test_invalid_overlap_is_rejected(self) -> None: + with self.assertRaises(ValueError): + media_worker.chunk_boundaries(100, 10, 10.0, 10.0) + + +class TimestampTests(unittest.TestCase): + def test_formats_srt_and_vtt(self) -> None: + self.assertEqual(media_worker.format_timestamp(3_661.2344), "01:01:01,234") + self.assertEqual( + media_worker.format_timestamp(3_661.2346, decimal="."), + "01:01:01.235", + ) + + def test_rounding_carries_to_next_second(self) -> None: + self.assertEqual(media_worker.format_timestamp(59.9996), "00:01:00,000") + + +class PersistenceTests(unittest.TestCase): + def test_checkpoint_round_trip_and_fingerprint_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary_name: + root = Path(temporary_name) + source = root / "sample.wav" + source.write_bytes(b"audio") + fingerprint = media_worker.build_fingerprint( + source, + engine="whisper", + chunk_seconds=60.0, + overlap_seconds=1.5, + prompt="словарь", + ) + checkpoint = root / media_worker.CHECKPOINT_NAME + segments = [ + { + "index": 0, + "start": 0.0, + "end": 10.0, + "text": "готовый текст", + "language": "ru", + "latency": 1.0, + } + ] + media_worker.write_checkpoint( + checkpoint, + fingerprint=fingerprint, + completed_chunks=1, + segments=segments, + duration=10.0, + ) + loaded = media_worker.load_checkpoint(checkpoint, fingerprint) + self.assertIsNotNone(loaded) + self.assertEqual(loaded["completed_chunks"], 1) + checkpoint_text = checkpoint.read_text(encoding="utf-8") + self.assertNotIn(str(source.resolve()), checkpoint_text) + changed_prompt = media_worker.build_fingerprint( + source, + engine="whisper", + chunk_seconds=60.0, + overlap_seconds=1.5, + prompt="другой словарь", + ) + self.assertNotEqual(fingerprint["digest"], changed_prompt["digest"]) + changed = dict(fingerprint, digest="different") + self.assertIsNone(media_worker.load_checkpoint(checkpoint, changed)) + self.assertEqual(list(root.glob(".transcript.checkpoint.json.*.tmp")), []) + + def test_exports_all_formats_without_full_source_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary_name: + root = Path(temporary_name) + source_dir = root / "private" / "folder" + source_dir.mkdir(parents=True) + source = source_dir / "видео.mp4" + source.write_bytes(b"media") + output = root / "result" + segments = [ + { + "index": 0, + "start": 0.0, + "end": 2.5, + "text": "Первый фрагмент", + "language": "ru", + "latency": 0.2, + }, + { + "index": 1, + "start": 2.0, + "end": 5.0, + "text": "Второй фрагмент", + "language": "ru", + "latency": 0.3, + }, + ] + paths = media_worker.export_transcript( + output, + source=source, + engine="gigaam", + duration=5.0, + chunk_seconds=2.5, + overlap_seconds=0.5, + segments=segments, + ) + self.assertEqual(set(paths), {"text", "srt", "vtt", "json"}) + self.assertTrue(all(path.is_file() for path in paths.values())) + transcript = json.loads(paths["json"].read_text(encoding="utf-8")) + self.assertEqual(transcript["source"]["name"], "видео.mp4") + self.assertNotIn(str(source.resolve()), paths["json"].read_text(encoding="utf-8")) + self.assertIn("00:00:00,000 --> 00:00:02,500", paths["srt"].read_text()) + self.assertTrue(paths["vtt"].read_text().startswith("WEBVTT\n")) + self.assertEqual(list(output.glob(".*.tmp")), []) + + def test_process_request_resumes_completed_job_without_loading_model(self) -> None: + with tempfile.TemporaryDirectory() as temporary_name: + root = Path(temporary_name) + source = root / "sample.wav" + with wave.open(str(source), "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(16_000) + wav_file.writeframes(b"\x00\x00" * 33_600) + + class FakeRecognizer: + texts = iter( + ( + "один два три четыре", + "два три четыре пять", + "три четыре пять шесть", + ) + ) + + def __init__(self, _engine: str, _cache: Path): + pass + + def load(self) -> None: + pass + + def transcribe(self, _audio: Path, _prompt: str) -> tuple[str, str]: + return next(self.texts), "ru" + + request = { + "id": "job-1", + "input": str(source), + "output_dir": str(root / "output"), + "cache": str(root / "cache"), + "engine": "whisper", + "prompt": "", + "chunk_seconds": 1.0, + "overlap_seconds": 0.1, + } + + def copy_normalized(input_path: Path, destination: Path) -> None: + destination.write_bytes(input_path.read_bytes()) + + with ( + mock.patch.object(media_worker, "configure_environment"), + mock.patch.object(media_worker, "normalize_media", copy_normalized), + mock.patch.object(media_worker, "Recognizer", FakeRecognizer), + mock.patch.object(media_worker, "emit_progress"), + ): + result = media_worker.process_request(request) + + self.assertEqual(result["text"], "один два три четыре\nпять\nшесть") + self.assertFalse((root / "output" / ".work").exists()) + transcript = json.loads( + (root / "output" / "transcript.json").read_text(encoding="utf-8") + ) + starts = [segment["start"] for segment in transcript["segments"]] + ends = [segment["end"] for segment in transcript["segments"]] + self.assertTrue(all(start >= end for start, end in zip(starts[1:], ends))) + + class UnexpectedRecognizer: + def __init__(self, _engine: str, _cache: Path): + raise AssertionError("completed checkpoint must skip model loading") + + with ( + mock.patch.object(media_worker, "configure_environment"), + mock.patch.object(media_worker, "normalize_media", copy_normalized), + mock.patch.object(media_worker, "Recognizer", UnexpectedRecognizer), + mock.patch.object(media_worker, "emit_progress"), + ): + resumed = media_worker.process_request(request) + self.assertEqual(resumed["text"], result["text"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/worker/media_worker.py b/worker/media_worker.py new file mode 100644 index 0000000..9420e5b --- /dev/null +++ b/worker/media_worker.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +"""One-shot local media transcription worker for VoiceSwitch. + +The worker accepts one JSON object on stdin and writes machine-readable events +with the VoiceSwitch protocol prefix. Model imports stay in ``asr_worker`` so +the helpers in this module remain testable with the Python standard library. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import traceback +import wave +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Generator, Iterable, Sequence + +from asr_worker import PROTOCOL_PREFIX, Recognizer, configure_environment + + +CHECKPOINT_VERSION = 1 +TRANSCRIPT_VERSION = 1 +CHECKPOINT_NAME = "transcript.checkpoint.json" +WORD_PATTERN = re.compile(r"[^\W_]+(?:[\N{RIGHT SINGLE QUOTATION MARK}'-][^\W_]+)*", re.UNICODE) + +_cancel_requested = False +_active_ffmpeg: subprocess.Popen[str] | None = None + + +class WorkerError(Exception): + """An error safe to show in the application UI.""" + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +class CancelledError(WorkerError): + def __init__(self) -> None: + super().__init__("cancelled", "Распознавание отменено.") + + +def emit(message_type: str, **payload: Any) -> None: + message = {"type": message_type, **payload} + print(PROTOCOL_PREFIX + json.dumps(message, ensure_ascii=False), flush=True) + + +def emit_progress( + request_id: str, + phase: str, + progress: float, + *, + message: str, + completed: int | None = None, + total: int | None = None, +) -> None: + payload: dict[str, Any] = { + "id": request_id, + "phase": phase, + "progress": max(0.0, min(1.0, float(progress))), + "fraction": max(0.0, min(1.0, float(progress))), + "message": message, + } + if completed is not None: + payload["completed"] = completed + if total is not None: + payload["total"] = total + emit("progress", **payload) + + +def _handle_termination(_signum: int, _frame: Any) -> None: + global _cancel_requested + _cancel_requested = True + process = _active_ffmpeg + if process is not None and process.poll() is None: + process.terminate() + + +def check_cancelled() -> None: + if _cancel_requested: + raise CancelledError() + + +def normalized_words(text: str) -> list[str]: + """Return punctuation-independent, case-folded words for exact matching.""" + return [match.group(0).casefold() for match in WORD_PATTERN.finditer(text)] + + +def deduplicate_prefix( + previous_text: str, + current_text: str, + *, + minimum_words: int = 3, +) -> str: + """Remove an exact normalized suffix/prefix overlap from ``current_text``.""" + if minimum_words < 1: + raise ValueError("minimum_words must be positive") + + previous_words = normalized_words(previous_text) + current_matches = list(WORD_PATTERN.finditer(current_text)) + current_words = [match.group(0).casefold() for match in current_matches] + maximum = min(len(previous_words), len(current_words)) + + overlap = 0 + for size in range(maximum, minimum_words - 1, -1): + if previous_words[-size:] == current_words[:size]: + overlap = size + break + if overlap == 0: + return current_text.strip() + + cut = current_matches[overlap - 1].end() + remainder = current_text[cut:] + remainder = re.sub(r"^[\s,.;:!?\N{HORIZONTAL ELLIPSIS}\N{EM DASH}\N{EN DASH}-]+", "", remainder) + return remainder.strip() + + +def chunk_boundaries( + total_frames: int, + frame_rate: int, + chunk_seconds: float, + overlap_seconds: float, +) -> list[tuple[int, int]]: + """Calculate deterministic overlapping WAV frame ranges.""" + if total_frames < 0: + raise ValueError("total_frames must not be negative") + if frame_rate <= 0: + raise ValueError("frame_rate must be positive") + if chunk_seconds <= 0: + raise ValueError("chunk_seconds must be positive") + if overlap_seconds < 0 or overlap_seconds >= chunk_seconds: + raise ValueError("overlap_seconds must be non-negative and smaller than chunk_seconds") + if total_frames == 0: + return [] + + chunk_frames = max(1, round(frame_rate * chunk_seconds)) + overlap_frames = max(0, round(frame_rate * overlap_seconds)) + step_frames = chunk_frames - overlap_frames + ranges: list[tuple[int, int]] = [] + start = 0 + while start < total_frames: + end = min(total_frames, start + chunk_frames) + ranges.append((start, end)) + if end == total_frames: + break + start += step_frames + return ranges + + +def format_timestamp(seconds: float, *, decimal: str = ",") -> str: + """Format a non-negative timestamp for SRT (comma) or WebVTT (dot).""" + milliseconds = max(0, round(float(seconds) * 1000.0)) + hours, remainder = divmod(milliseconds, 3_600_000) + minutes, remainder = divmod(remainder, 60_000) + whole_seconds, millis = divmod(remainder, 1000) + return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d}{decimal}{millis:03d}" + + +def atomic_write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as output: + temporary = Path(output.name) + output.write(content) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def atomic_write_json(path: Path, value: Any) -> None: + atomic_write_text( + path, + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + ) + + +@contextmanager +def job_work_directory(output_dir: Path) -> Generator[Path, None, None]: + """Use a parent-visible work directory and clean leftovers from a hard kill.""" + work_directory = output_dir / ".work" + shutil.rmtree(work_directory, ignore_errors=True) + work_directory.mkdir(parents=True, exist_ok=True) + try: + yield work_directory + finally: + shutil.rmtree(work_directory, ignore_errors=True) + + +def build_fingerprint( + source: Path, + *, + engine: str, + chunk_seconds: float, + overlap_seconds: float, + prompt: str, +) -> dict[str, Any]: + """Build a resumable fingerprint without persisting the full source path.""" + stat = source.stat() + config = { + "chunk_seconds": float(chunk_seconds), + "overlap_seconds": float(overlap_seconds), + "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + } + private_identity = { + "source": str(source.resolve()), + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "engine": engine, + "config": config, + } + digest = hashlib.sha256( + json.dumps(private_identity, ensure_ascii=False, sort_keys=True).encode("utf-8") + ).hexdigest() + return { + "digest": digest, + "source": source.name, + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "engine": engine, + "config": config, + } + + +def load_checkpoint(path: Path, fingerprint: dict[str, Any]) -> dict[str, Any] | None: + try: + checkpoint = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + if checkpoint.get("version") != CHECKPOINT_VERSION: + return None + saved = checkpoint.get("fingerprint") + if not isinstance(saved, dict) or saved.get("digest") != fingerprint.get("digest"): + return None + completed = checkpoint.get("completed_chunks") + segments = checkpoint.get("segments") + if not isinstance(completed, int) or completed < 0 or not isinstance(segments, list): + return None + return checkpoint + + +def write_checkpoint( + path: Path, + *, + fingerprint: dict[str, Any], + completed_chunks: int, + segments: Sequence[dict[str, Any]], + duration: float, +) -> None: + atomic_write_json( + path, + { + "version": CHECKPOINT_VERSION, + "fingerprint": fingerprint, + "completed_chunks": completed_chunks, + "duration": duration, + "segments": list(segments), + }, + ) + + +def _render_srt(segments: Iterable[dict[str, Any]]) -> str: + blocks: list[str] = [] + for number, segment in enumerate(segments, start=1): + blocks.append( + "\n".join( + ( + str(number), + f"{format_timestamp(segment['start'])} --> {format_timestamp(segment['end'])}", + str(segment["text"]), + ) + ) + ) + return "\n\n".join(blocks) + ("\n" if blocks else "") + + +def _render_vtt(segments: Iterable[dict[str, Any]]) -> str: + blocks = ["WEBVTT"] + for segment in segments: + blocks.append( + "\n".join( + ( + f"{format_timestamp(segment['start'], decimal='.')} --> " + f"{format_timestamp(segment['end'], decimal='.')}", + str(segment["text"]), + ) + ) + ) + return "\n\n".join(blocks) + "\n" + + +def export_transcript( + output_dir: Path, + *, + source: Path, + engine: str, + duration: float, + chunk_seconds: float, + overlap_seconds: float, + segments: Sequence[dict[str, Any]], +) -> dict[str, Path]: + """Atomically write the four public transcript formats.""" + output_dir.mkdir(parents=True, exist_ok=True) + text = "\n".join(str(segment["text"]).strip() for segment in segments if segment.get("text")) + if text: + text += "\n" + + metadata = { + "version": TRANSCRIPT_VERSION, + "source": { + "name": source.name, + "size": source.stat().st_size, + "mtime_ns": source.stat().st_mtime_ns, + }, + "engine": engine, + "duration": duration, + "configuration": { + "chunk_seconds": chunk_seconds, + "overlap_seconds": overlap_seconds, + "timestamps": "coarse_chunk_boundaries", + }, + "text": text.rstrip("\n"), + "segments": list(segments), + } + paths = { + "text": output_dir / "transcript.txt", + "srt": output_dir / "transcript.srt", + "vtt": output_dir / "transcript.vtt", + "json": output_dir / "transcript.json", + } + atomic_write_text(paths["text"], text) + atomic_write_text(paths["srt"], _render_srt(segments)) + atomic_write_text(paths["vtt"], _render_vtt(segments)) + atomic_write_json(paths["json"], metadata) + return paths + + +def normalize_media(source: Path, destination: Path) -> None: + """Normalize the first audio stream to mono 16-bit PCM WAV at 16 kHz.""" + global _active_ffmpeg + ffmpeg = shutil.which("ffmpeg") + if ffmpeg is None: + raise WorkerError("ffmpeg_missing", "Не найден локальный ffmpeg.") + + command = [ + ffmpeg, + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(source), + "-map", + "0:a:0", + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "pcm_s16le", + str(destination), + ] + check_cancelled() + try: + _active_ffmpeg = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + _stdout, stderr = _active_ffmpeg.communicate() + return_code = _active_ffmpeg.returncode + except OSError as error: + raise WorkerError("ffmpeg_failed", "Не удалось запустить локальный ffmpeg.") from error + finally: + _active_ffmpeg = None + + check_cancelled() + if return_code != 0: + lowered = (stderr or "").casefold() + no_audio_markers = ( + "matches no streams", + "does not contain any stream", + "output file #0 does not contain any stream", + ) + if any(marker in lowered for marker in no_audio_markers): + raise WorkerError("no_audio", "В выбранном файле нет аудиодорожки.") + raise WorkerError("media_read_failed", "Не удалось прочитать аудиодорожку медиафайла.") + if not destination.exists() or destination.stat().st_size == 0: + raise WorkerError("no_audio", "В выбранном файле нет аудиодорожки.") + + +def inspect_wav(path: Path) -> tuple[wave._wave_params, int, int, float]: + try: + with wave.open(str(path), "rb") as wav_file: + parameters = wav_file.getparams() + frame_rate = wav_file.getframerate() + total_frames = wav_file.getnframes() + except (OSError, wave.Error) as error: + raise WorkerError("invalid_audio", "Не удалось подготовить аудиодорожку.") from error + if parameters.nchannels != 1 or parameters.sampwidth != 2 or frame_rate != 16000: + raise WorkerError("invalid_audio", "Аудиодорожка имеет неожиданный формат.") + duration = total_frames / float(frame_rate) if frame_rate else 0.0 + if total_frames == 0: + raise WorkerError("no_audio", "В выбранном файле нет аудиоданных.") + return parameters, frame_rate, total_frames, duration + + +def write_wav_chunk( + source: Path, + destination: Path, + *, + parameters: wave._wave_params, + start_frame: int, + end_frame: int, +) -> None: + with wave.open(str(source), "rb") as input_file: + input_file.setpos(start_frame) + frames = input_file.readframes(end_frame - start_frame) + with wave.open(str(destination), "wb") as output_file: + output_file.setparams(parameters) + output_file.writeframes(frames) + + +def _validated_request(request: Any) -> dict[str, Any]: + if not isinstance(request, dict): + raise WorkerError("invalid_request", "Ожидается JSON-объект с параметрами задачи.") + try: + request_id = str(request["id"]) + source = Path(str(request["input"])).expanduser() + output_dir = Path(str(request["output_dir"])).expanduser() + engine = str(request["engine"]) + cache = Path(str(request["cache"])).expanduser() + except (KeyError, TypeError, ValueError) as error: + raise WorkerError("invalid_request", "В запросе не хватает обязательных полей.") from error + if not request_id: + raise WorkerError("invalid_request", "Поле id не должно быть пустым.") + if engine not in {"gigaam", "whisper", "qwen"}: + raise WorkerError("invalid_engine", f"Неизвестный движок: {engine}") + if not source.is_file(): + raise WorkerError("input_missing", "Выбранный медиафайл не найден.") + + try: + chunk_seconds = float(request.get("chunk_seconds", 60.0)) + overlap_seconds = float(request.get("overlap_seconds", 1.5)) + except (TypeError, ValueError) as error: + raise WorkerError("invalid_request", "Размер блока и перекрытие должны быть числами.") from error + if chunk_seconds <= 0: + raise WorkerError("invalid_request", "Размер блока должен быть больше нуля.") + if overlap_seconds < 0 or overlap_seconds >= chunk_seconds: + raise WorkerError("invalid_request", "Перекрытие должно быть меньше размера блока.") + return { + "id": request_id, + "source": source, + "output_dir": output_dir, + "engine": engine, + "cache": cache, + "prompt": str(request.get("prompt") or ""), + "chunk_seconds": chunk_seconds, + "overlap_seconds": overlap_seconds, + } + + +def process_request(raw_request: Any) -> dict[str, Any]: + overall_started = time.perf_counter() + request = _validated_request(raw_request) + request_id = request["id"] + source: Path = request["source"] + output_dir: Path = request["output_dir"] + engine = request["engine"] + cache: Path = request["cache"] + prompt = request["prompt"] + chunk_seconds = request["chunk_seconds"] + overlap_seconds = request["overlap_seconds"] + + configure_environment(cache) + output_dir.mkdir(parents=True, exist_ok=True) + fingerprint = build_fingerprint( + source, + engine=engine, + chunk_seconds=chunk_seconds, + overlap_seconds=overlap_seconds, + prompt=prompt, + ) + checkpoint_path = output_dir / CHECKPOINT_NAME + + emit_progress(request_id, "normalizing", 0.0, message="Подготовка аудиодорожки…") + with job_work_directory(output_dir) as temporary_dir: + normalized = temporary_dir / "normalized.wav" + normalize_media(source, normalized) + parameters, frame_rate, total_frames, duration = inspect_wav(normalized) + ranges = chunk_boundaries( + total_frames, + frame_rate, + chunk_seconds, + overlap_seconds, + ) + emit_progress(request_id, "normalizing", 1.0, message="Аудиодорожка подготовлена.") + + checkpoint = load_checkpoint(checkpoint_path, fingerprint) + completed_chunks = 0 + segments: list[dict[str, Any]] = [] + if checkpoint is not None: + completed_chunks = min(int(checkpoint["completed_chunks"]), len(ranges)) + segments = [item for item in checkpoint["segments"] if isinstance(item, dict)] + + check_cancelled() + recognizer: Recognizer | None = None + if completed_chunks < len(ranges): + emit_progress(request_id, "loading", 0.0, message="Загрузка модели…") + recognizer = Recognizer(engine, cache) + recognizer.load() + emit_progress(request_id, "loading", 1.0, message="Модель готова.") + + emit_progress( + request_id, + "transcribing", + completed_chunks / max(1, len(ranges)), + message=( + f"Продолжение с блока {completed_chunks + 1}." + if completed_chunks < len(ranges) and completed_chunks > 0 + else "Распознавание аудиодорожки…" + ), + completed=completed_chunks, + total=len(ranges), + ) + + previous_text = " ".join( + str(segment.get("text", "")).strip() for segment in segments if segment.get("text") + ) + for index in range(completed_chunks, len(ranges)): + check_cancelled() + start_frame, end_frame = ranges[index] + chunk_path = temporary_dir / f"chunk-{index:06d}.wav" + write_wav_chunk( + normalized, + chunk_path, + parameters=parameters, + start_frame=start_frame, + end_frame=end_frame, + ) + emit_progress( + request_id, + "transcribing", + index / max(1, len(ranges)), + message=f"Распознавание блока {index + 1} из {len(ranges)}…", + completed=index, + total=len(ranges), + ) + started = time.perf_counter() + try: + assert recognizer is not None + raw_text, language = recognizer.transcribe(chunk_path, prompt) + finally: + chunk_path.unlink(missing_ok=True) + check_cancelled() + + clean_text = deduplicate_prefix(previous_text, raw_text) + if clean_text: + segment_start = start_frame / float(frame_rate) + if segments: + # Перекрытие нужно для контекста ASR, но не должно создавать + # одновременно показываемые cue в SRT/VTT. + segment_start = max(segment_start, float(segments[-1]["end"])) + segment = { + "index": index, + "start": segment_start, + "end": end_frame / float(frame_rate), + "text": clean_text, + "language": language, + "latency": time.perf_counter() - started, + } + segments.append(segment) + previous_text = f"{previous_text} {clean_text}".strip() + completed_chunks = index + 1 + write_checkpoint( + checkpoint_path, + fingerprint=fingerprint, + completed_chunks=completed_chunks, + segments=segments, + duration=duration, + ) + emit_progress( + request_id, + "transcribing", + completed_chunks / max(1, len(ranges)), + message=f"Готово блоков: {completed_chunks} из {len(ranges)}.", + completed=completed_chunks, + total=len(ranges), + ) + + check_cancelled() + emit_progress(request_id, "exporting", 0.0, message="Сохранение результата…") + outputs = export_transcript( + output_dir, + source=source, + engine=engine, + duration=duration, + chunk_seconds=chunk_seconds, + overlap_seconds=overlap_seconds, + segments=segments, + ) + emit_progress(request_id, "exporting", 1.0, message="Результат сохранён.") + + serialized_outputs = {name: str(path) for name, path in outputs.items()} + return { + "id": request_id, + "engine": engine, + "source_name": source.name, + "duration": duration, + "latency": time.perf_counter() - overall_started, + "text": "\n".join(str(segment["text"]) for segment in segments), + "segments": len(segments), + "segment_count": len(segments), + "output_dir": str(output_dir), + "outputs": serialized_outputs, + "files": serialized_outputs, + } + + +def main() -> int: + signal.signal(signal.SIGTERM, _handle_termination) + signal.signal(signal.SIGINT, _handle_termination) + request_id = "unknown" + engine: str | None = None + try: + raw = sys.stdin.read().strip() + if not raw: + raise WorkerError("invalid_request", "Не получен JSON-запрос.") + request = json.loads(raw) + if isinstance(request, dict): + request_id = str(request.get("id") or "unknown") + engine = str(request.get("engine")) if request.get("engine") is not None else None + result = process_request(request) + emit("result", **result) + return 0 + except CancelledError as error: + emit("error", id=request_id, engine=engine, code=error.code, message=str(error)) + return 130 + except WorkerError as error: + emit("error", id=request_id, engine=engine, code=error.code, message=str(error)) + return 1 + except json.JSONDecodeError: + emit( + "error", + id=request_id, + engine=engine, + code="invalid_json", + message="Не удалось прочитать JSON-запрос.", + ) + return 1 + except Exception as error: + emit( + "error", + id=request_id, + engine=engine, + code="internal_error", + message=f"Ошибка локального распознавания: {error}", + ) + traceback.print_exc(file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())