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 @@
-> Локальная диктовка и редактура текста для macOS.
+> Локальная диктовка и расшифровка аудио/видео для macOS.
[](https://github.com/mitimaicode/VoiceSwitch/actions/workflows/ci.yml)
[](https://github.com/mitimaicode/VoiceSwitch/releases)
@@ -12,7 +12,9 @@
[](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())