From fb801bb447057154a96041801a65cf184b2c9ed1 Mon Sep 17 00:00:00 2001 From: lml Date: Fri, 20 Mar 2026 09:21:32 +0800 Subject: [PATCH] feat(whisperx): add Windows CUDA runtime flow and harden sidecar bootstrap - detect Windows CUDA and install the matching PyTorch runtime - surface actual GPU/CPU runtime info in the transcription UI - replace fake runtime progress with phase-based preparation states - force UTF-8 sidecar stdio and fail fast on decode errors - fix WhisperX install validation to avoid reinstalling on every restart --- assets/sidecar/whisperx_worker.py | 108 +++++++ .../transcription/transcription_bloc.dart | 66 +++- .../transcription/transcription_state.dart | 53 +++- lib/l10n/app_en.arb | 6 + lib/l10n/app_localizations.dart | 36 +++ lib/l10n/app_localizations_en.dart | 19 ++ lib/l10n/app_localizations_zh.dart | 18 ++ lib/l10n/app_zh.arb | 6 + lib/models/whisper_runtime_info.dart | 48 +++ lib/services/whisper/whisper_service.dart | 292 +++++++++++++++++- lib/services/whisper/whisperx_runtime.dart | 292 +++++++++++++++++- lib/services/whisper/whisperx_sidecar.dart | 197 ++++++++++-- lib/ui/widgets/transcription_panel.dart | 196 +++++++++++- 13 files changed, 1268 insertions(+), 69 deletions(-) create mode 100644 lib/models/whisper_runtime_info.dart diff --git a/assets/sidecar/whisperx_worker.py b/assets/sidecar/whisperx_worker.py index 48908cf..c51f977 100644 --- a/assets/sidecar/whisperx_worker.py +++ b/assets/sidecar/whisperx_worker.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import contextlib +import gc import json +import platform import re import sys import traceback @@ -14,6 +16,19 @@ import whisperx SAMPLE_RATE = 16000 ANSI_ESCAPE_RE = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]") + + +def configure_stdio() -> None: + # The Flutter side expects UTF-8 JSON lines on stdio. On Windows, Python can + # otherwise inherit a legacy code page for piped stdout/stderr, which breaks + # decoding when transcript text or library logs contain non-ASCII characters. + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + reconfigure(encoding="utf-8", errors="backslashreplace", line_buffering=True) + + +configure_stdio() JSON_STDOUT = sys.stdout @@ -160,6 +175,33 @@ class WhisperXWorker: self.models: Dict[Tuple[str, str, str, Optional[str]], Any] = {} self.align_models: Dict[Tuple[str, str], Tuple[Any, Dict[str, Any]]] = {} + def clear_device_resources(self, device: Optional[str] = None) -> None: + if device is None: + self.models.clear() + self.align_models.clear() + else: + self.models = { + key: value for key, value in self.models.items() if key[1] != device + } + self.align_models = { + key: value + for key, value in self.align_models.items() + if key[1] != device + } + + gc.collect() + + if device not in (None, "cuda"): + return + + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except Exception: + return + def get_model( self, model_name: str, @@ -171,6 +213,9 @@ class WhisperXWorker: if key in self.models: return self.models[key] + if device == "cuda": + self.clear_device_resources(device="cuda") + model = whisperx.load_model( model_name, device, @@ -185,6 +230,14 @@ class WhisperXWorker: if key in self.align_models: return self.align_models[key] + if device == "cuda": + self.align_models = { + cached_key: value + for cached_key, value in self.align_models.items() + if cached_key[1] != "cuda" + } + self.clear_device_resources(device="cuda") + model, metadata = whisperx.load_align_model( language_code=language, device=device, @@ -192,6 +245,49 @@ class WhisperXWorker: self.align_models[key] = (model, metadata) return model, metadata + def handle_probe_runtime(self, request_id: str) -> None: + payload: Dict[str, Any] = { + "platform": platform.system().lower(), + "python_version": sys.version.split()[0], + "whisperx_version": str(getattr(whisperx, "__version__", "")), + "cuda_available": False, + "cuda_device_count": 0, + "cuda_device_name": None, + "cuda_compute_types": [], + } + + try: + import torch + + payload["torch_version"] = str(getattr(torch, "__version__", "")) + payload["torch_cuda_version"] = getattr(torch.version, "cuda", None) + payload["cuda_available"] = bool(torch.cuda.is_available()) + if payload["cuda_available"]: + device_count = int(torch.cuda.device_count()) + payload["cuda_device_count"] = device_count + if device_count > 0: + payload["cuda_device_name"] = str(torch.cuda.get_device_name(0)) + except Exception as exc: # pylint: disable=broad-except + payload["torch_error"] = str(exc) + + try: + import ctranslate2 + + payload["ctranslate2_version"] = str( + getattr(ctranslate2, "__version__", "") + ) + try: + compute_types = ctranslate2.get_supported_compute_types("cuda") + payload["cuda_compute_types"] = sorted( + str(item) for item in compute_types + ) + except Exception as exc: # pylint: disable=broad-except + payload["ctranslate2_cuda_error"] = str(exc) + except Exception as exc: # pylint: disable=broad-except + payload["ctranslate2_error"] = str(exc) + + emit({"type": "result", "id": request_id, "payload": payload}) + def handle_transcribe(self, request_id: str, params: Dict[str, Any]) -> None: wav_path = str(params.get("wav_path") or "") if not wav_path: @@ -236,6 +332,9 @@ class WhisperXWorker: verbose=False, ) transcribe_logs.flush() + if device == "cuda": + del model + self.clear_device_resources(device="cuda") detected_language = str(result.get("language") or language or "unknown") normalized_segments = normalize_segments(result.get("segments")) @@ -258,6 +357,9 @@ class WhisperXWorker: print_progress=True, ) align_logs.flush() + if device == "cuda": + del align_model + self.clear_device_resources(device="cuda") detected_language = str(aligned.get("language") or detected_language) normalized_segments = normalize_segments(aligned.get("segments")) @@ -276,6 +378,8 @@ class WhisperXWorker: "payload": payload, } ) + if device == "cuda": + self.clear_device_resources(device="cuda") def dispatch(self, message: Dict[str, Any]) -> None: request_id = str(message.get("id") or "") @@ -291,6 +395,10 @@ class WhisperXWorker: self.handle_transcribe(request_id, params) return + if method == "probe_runtime": + self.handle_probe_runtime(request_id) + return + if method == "shutdown": emit({"type": "result", "id": request_id, "payload": {"ok": True}}) raise SystemExit(0) diff --git a/lib/blocs/transcription/transcription_bloc.dart b/lib/blocs/transcription/transcription_bloc.dart index bb0594a..8e132e5 100644 --- a/lib/blocs/transcription/transcription_bloc.dart +++ b/lib/blocs/transcription/transcription_bloc.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:path/path.dart' as p; +import '../../models/whisper_runtime_info.dart'; import '../../services/whisper/whisper_service.dart'; import 'transcription_event.dart'; import 'transcription_state.dart'; @@ -40,20 +41,29 @@ class TranscriptionBloc extends Bloc { if (videoPath == null || fileName == null) return; String wavPath = videoPath; + WhisperRuntimeInfo? runtimeInfo; try { - // 1) Prepare runtime resources (accurate byte progress) + // 1) Prepare runtime resources. + // Only the download phase has determinate progress; install/start phases + // stay in RuntimePreparing with translated status text. emit( - RuntimePreparing(videoPath: videoPath, fileName: fileName, progress: 0), + RuntimePreparing( + videoPath: videoPath, + fileName: fileName, + phase: RuntimePreparingPhase.checkingRuntime, + ), ); await _whisperService.downloadModel( event.modelName, - onDownloadProgress: (received, total) { + onPreparationState: (phase, progress) { if (emit.isDone) return; emit( RuntimePreparing( videoPath: videoPath, fileName: fileName, - progress: total > 0 ? received / total : 0, + phase: _runtimePreparingPhaseFromCode(phase), + progress: progress, + runtimeInfo: runtimeInfo, ), ); }, @@ -61,9 +71,18 @@ class TranscriptionBloc extends Bloc { // 2) Select model for this run (actual model load happens in transcribe). await _whisperService.loadModel(event.modelName); + runtimeInfo = await _whisperService.inspectRuntime( + modelName: event.modelName, + ); // 3) Transcode media to WAV (no progress) - emit(AudioTranscoding(videoPath: videoPath, fileName: fileName)); + emit( + AudioTranscoding( + videoPath: videoPath, + fileName: fileName, + runtimeInfo: runtimeInfo, + ), + ); wavPath = await _whisperService.transcodeToWav(videoPath); // 4) Transcribe with explicit sidecar phases + runtime logs. @@ -72,15 +91,21 @@ class TranscriptionBloc extends Bloc { void emitTranscribingState({ required TranscribingPhase phase, String? detail, + WhisperRuntimeInfo? nextRuntimeInfo, }) { final String? normalized = detail == null ? null : _normalizeLogLine(detail); - if (currentPhase == phase && currentDetail == normalized) { + final WhisperRuntimeInfo? resolvedRuntimeInfo = + nextRuntimeInfo ?? runtimeInfo; + if (currentPhase == phase && + currentDetail == normalized && + runtimeInfo == resolvedRuntimeInfo) { return; } currentPhase = phase; currentDetail = normalized; + runtimeInfo = resolvedRuntimeInfo; if (emit.isDone) return; emit( Transcribing( @@ -88,6 +113,7 @@ class TranscriptionBloc extends Bloc { fileName: fileName, phase: phase, statusDetail: normalized, + runtimeInfo: runtimeInfo, ), ); } @@ -97,11 +123,19 @@ class TranscriptionBloc extends Bloc { videoPath: videoPath, fileName: fileName, phase: currentPhase, + runtimeInfo: runtimeInfo, ), ); final result = await _whisperService.transcribeWav( wavPath, language: event.language ?? 'ja', + onRuntimeInfo: (info) { + emitTranscribingState( + phase: currentPhase, + detail: currentDetail, + nextRuntimeInfo: info, + ); + }, onStatus: (status, detail) { final TranscribingPhase phase = _phaseFromWorkerStatus(status); emitTranscribingState(phase: phase, detail: detail); @@ -120,6 +154,7 @@ class TranscriptionBloc extends Bloc { videoPath: videoPath, fileName: fileName, result: result, + runtimeInfo: runtimeInfo, ), ); } catch (e) { @@ -128,6 +163,7 @@ class TranscriptionBloc extends Bloc { videoPath: videoPath, fileName: fileName, message: e.toString(), + runtimeInfo: runtimeInfo, ), ); } finally { @@ -145,6 +181,24 @@ class TranscriptionBloc extends Bloc { return trimmed.length > 140 ? '${trimmed.substring(0, 140)}...' : trimmed; } + RuntimePreparingPhase _runtimePreparingPhaseFromCode(String phase) { + switch (phase) { + case 'downloading_runtime': + return RuntimePreparingPhase.downloadingRuntime; + case 'extracting_runtime': + return RuntimePreparingPhase.extractingRuntime; + case 'creating_environment': + return RuntimePreparingPhase.creatingEnvironment; + case 'installing_dependencies': + return RuntimePreparingPhase.installingDependencies; + case 'starting_sidecar': + return RuntimePreparingPhase.startingSidecar; + case 'checking_runtime': + default: + return RuntimePreparingPhase.checkingRuntime; + } + } + TranscribingPhase _phaseFromWorkerStatus(String status) { switch (status) { case 'loading_audio': diff --git a/lib/blocs/transcription/transcription_state.dart b/lib/blocs/transcription/transcription_state.dart index a581ff2..89b6cdf 100644 --- a/lib/blocs/transcription/transcription_state.dart +++ b/lib/blocs/transcription/transcription_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import '../../models/transcription_result.dart'; +import '../../models/whisper_runtime_info.dart'; /// States for the TranscriptionBloc. abstract class TranscriptionState extends Equatable { @@ -25,31 +26,55 @@ class VideoSelected extends TranscriptionState { List get props => [videoPath, fileName]; } +enum RuntimePreparingPhase { + checkingRuntime, + downloadingRuntime, + extractingRuntime, + creatingEnvironment, + installingDependencies, + startingSidecar, +} + /// Sidecar runtime assets are being prepared/downloaded. class RuntimePreparing extends TranscriptionState { final String videoPath; final String fileName; - final double progress; + final RuntimePreparingPhase phase; + final double? progress; + final WhisperRuntimeInfo? runtimeInfo; const RuntimePreparing({ required this.videoPath, required this.fileName, - required this.progress, + this.phase = RuntimePreparingPhase.checkingRuntime, + this.progress, + this.runtimeInfo, }); @override - List get props => [videoPath, fileName, progress]; + List get props => [ + videoPath, + fileName, + phase, + progress, + runtimeInfo, + ]; } /// Media is being transcoded to WAV. class AudioTranscoding extends TranscriptionState { final String videoPath; final String fileName; + final WhisperRuntimeInfo? runtimeInfo; - const AudioTranscoding({required this.videoPath, required this.fileName}); + const AudioTranscoding({ + required this.videoPath, + required this.fileName, + this.runtimeInfo, + }); @override - List get props => [videoPath, fileName]; + List get props => [videoPath, fileName, runtimeInfo]; } /// WhisperX is transcribing. @@ -66,16 +91,24 @@ class Transcribing extends TranscriptionState { final String fileName; final TranscribingPhase phase; final String? statusDetail; + final WhisperRuntimeInfo? runtimeInfo; const Transcribing({ required this.videoPath, required this.fileName, this.phase = TranscribingPhase.transcribing, this.statusDetail, + this.runtimeInfo, }); @override - List get props => [videoPath, fileName, phase, statusDetail]; + List get props => [ + videoPath, + fileName, + phase, + statusDetail, + runtimeInfo, + ]; } /// Transcription completed successfully. @@ -83,15 +116,17 @@ class TranscriptionComplete extends TranscriptionState { final String videoPath; final String fileName; final TranscriptionResult result; + final WhisperRuntimeInfo? runtimeInfo; const TranscriptionComplete({ required this.videoPath, required this.fileName, required this.result, + this.runtimeInfo, }); @override - List get props => [videoPath, fileName, result]; + List get props => [videoPath, fileName, result, runtimeInfo]; } /// Transcription failed. @@ -99,13 +134,15 @@ class TranscriptionError extends TranscriptionState { final String videoPath; final String fileName; final String message; + final WhisperRuntimeInfo? runtimeInfo; const TranscriptionError({ required this.videoPath, required this.fileName, required this.message, + this.runtimeInfo, }); @override - List get props => [videoPath, fileName, message]; + List get props => [videoPath, fileName, message, runtimeInfo]; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ab0882..24bfabe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -25,6 +25,12 @@ "qualityBest": "Best", "extract": "Extract", "preparingRuntime": "Preparing WhisperX runtime...", + "runtimeChecking": "Checking WhisperX runtime...", + "runtimeDownloading": "Downloading runtime package...", + "runtimeExtracting": "Extracting runtime package...", + "runtimeCreatingEnvironment": "Creating Python environment...", + "runtimeInstallingDependencies": "Installing WhisperX dependencies...", + "runtimeStartingSidecar": "Starting WhisperX sidecar...", "transcodingAudio": "Transcoding media to audio...", "transcribingStatus": "Transcribing...", "transcriptionLoadingAudio": "Loading audio...", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8445c09..04db5b7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -248,6 +248,42 @@ abstract class AppLocalizations { /// **'Preparing WhisperX runtime...'** String get preparingRuntime; + /// No description provided for @runtimeChecking. + /// + /// In en, this message translates to: + /// **'Checking WhisperX runtime...'** + String get runtimeChecking; + + /// No description provided for @runtimeDownloading. + /// + /// In en, this message translates to: + /// **'Downloading runtime package...'** + String get runtimeDownloading; + + /// No description provided for @runtimeExtracting. + /// + /// In en, this message translates to: + /// **'Extracting runtime package...'** + String get runtimeExtracting; + + /// No description provided for @runtimeCreatingEnvironment. + /// + /// In en, this message translates to: + /// **'Creating Python environment...'** + String get runtimeCreatingEnvironment; + + /// No description provided for @runtimeInstallingDependencies. + /// + /// In en, this message translates to: + /// **'Installing WhisperX dependencies...'** + String get runtimeInstallingDependencies; + + /// No description provided for @runtimeStartingSidecar. + /// + /// In en, this message translates to: + /// **'Starting WhisperX sidecar...'** + String get runtimeStartingSidecar; + /// No description provided for @transcodingAudio. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 75a7833..6e95bae 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -83,6 +83,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get preparingRuntime => 'Preparing WhisperX runtime...'; + @override + String get runtimeChecking => 'Checking WhisperX runtime...'; + + @override + String get runtimeDownloading => 'Downloading runtime package...'; + + @override + String get runtimeExtracting => 'Extracting runtime package...'; + + @override + String get runtimeCreatingEnvironment => 'Creating Python environment...'; + + @override + String get runtimeInstallingDependencies => + 'Installing WhisperX dependencies...'; + + @override + String get runtimeStartingSidecar => 'Starting WhisperX sidecar...'; + @override String get transcodingAudio => 'Transcoding media to audio...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index bf3be0b..6b96cb0 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -83,6 +83,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get preparingRuntime => '正在准备 WhisperX 运行时...'; + @override + String get runtimeChecking => '正在检查 WhisperX 运行时...'; + + @override + String get runtimeDownloading => '正在下载运行时包...'; + + @override + String get runtimeExtracting => '正在解压运行时包...'; + + @override + String get runtimeCreatingEnvironment => '正在创建 Python 环境...'; + + @override + String get runtimeInstallingDependencies => '正在安装 WhisperX 依赖...'; + + @override + String get runtimeStartingSidecar => '正在启动 WhisperX 侧车...'; + @override String get transcodingAudio => '正在将媒体转码为音频...'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 50cd03a..4468cb2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -25,6 +25,12 @@ "qualityBest": "最好", "extract": "提取", "preparingRuntime": "正在准备 WhisperX 运行时...", + "runtimeChecking": "正在检查 WhisperX 运行时...", + "runtimeDownloading": "正在下载运行时包...", + "runtimeExtracting": "正在解压运行时包...", + "runtimeCreatingEnvironment": "正在创建 Python 环境...", + "runtimeInstallingDependencies": "正在安装 WhisperX 依赖...", + "runtimeStartingSidecar": "正在启动 WhisperX 侧车...", "transcodingAudio": "正在将媒体转码为音频...", "transcribingStatus": "正在转录...", "transcriptionLoadingAudio": "正在加载音频...", diff --git a/lib/models/whisper_runtime_info.dart b/lib/models/whisper_runtime_info.dart new file mode 100644 index 0000000..92bf587 --- /dev/null +++ b/lib/models/whisper_runtime_info.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +class WhisperRuntimeInfo extends Equatable { + final String modeLabel; + final String? deviceName; + final String computeType; + final int batchSize; + final bool usingGpu; + final bool cudaAvailable; + final String? torchCudaVersion; + final String? note; + + const WhisperRuntimeInfo({ + required this.modeLabel, + required this.deviceName, + required this.computeType, + required this.batchSize, + required this.usingGpu, + required this.cudaAvailable, + required this.torchCudaVersion, + this.note, + }); + + String get technicalSummary { + final List parts = [ + modeLabel, + if (deviceName != null && deviceName!.trim().isNotEmpty) + deviceName!.trim(), + 'compute=$computeType', + 'batch=$batchSize', + if (torchCudaVersion != null && torchCudaVersion!.trim().isNotEmpty) + 'torch CUDA ${torchCudaVersion!.trim()}', + ]; + return parts.join(' | '); + } + + @override + List get props => [ + modeLabel, + deviceName, + computeType, + batchSize, + usingGpu, + cudaAvailable, + torchCudaVersion, + note, + ]; +} diff --git a/lib/services/whisper/whisper_service.dart b/lib/services/whisper/whisper_service.dart index aacba3f..a5252ad 100644 --- a/lib/services/whisper/whisper_service.dart +++ b/lib/services/whisper/whisper_service.dart @@ -1,10 +1,27 @@ import 'dart:io'; +import '../../models/whisper_runtime_info.dart'; import '../../models/subtitle_segment.dart'; import '../../models/transcription_result.dart'; import '../audio/media_to_wav_converter.dart'; import 'whisperx_sidecar.dart'; +class _WhisperExecutionConfig { + final String device; + final String computeType; + final int batchSize; + final bool usesCuda; + final String? statusDetail; + + const _WhisperExecutionConfig({ + required this.device, + required this.computeType, + required this.batchSize, + required this.usesCuda, + this.statusDetail, + }); +} + /// Service for transcribing media using WhisperX through a local Python sidecar. class WhisperService { static const Map modelMap = { @@ -16,38 +33,40 @@ class WhisperService { 'large-v3-turbo': 'large-v3-turbo', }; - static const String _defaultDevice = 'cpu'; - static const String _defaultComputeType = 'int8'; - static const int _defaultBatchSize = 4; + static const String _cpuDevice = 'cpu'; + static const String _cpuComputeType = 'int8'; + static const int _cpuBatchSize = 4; final WhisperXSidecar _sidecar = WhisperXSidecar(); final MediaToWavConverter _wavConverter = MediaToWavConverter(); String? _currentModel; - /// Download sidecar runtime resources with byte-accurate progress. + /// Prepare sidecar runtime resources with phase-aware status updates. /// - /// If resources are already cached, the callback receives an immediate 100%. + /// Only the actual runtime download phase reports determinate byte progress. Future downloadModel( String modelName, { void Function(int received, int total)? onDownloadProgress, + void Function(String phase, double? progress)? onPreparationState, }) async { final String? whisperxModel = modelMap[modelName]; if (whisperxModel == null) { throw ArgumentError('Unknown model: $modelName'); } - bool progressUpdated = false; await _sidecar.ensureStarted( + onBootstrapStatus: (phase) { + onPreparationState?.call(phase, null); + }, onRuntimeDownloadProgress: (received, total) { - progressUpdated = true; onDownloadProgress?.call(received, total); + onPreparationState?.call( + 'downloading_runtime', + total > 0 ? received / total : null, + ); }, ); - - if (!progressUpdated) { - onDownloadProgress?.call(1, 1); - } } /// Ensure sidecar is ready and mark the selected model for next transcription. @@ -74,6 +93,7 @@ class WhisperService { String language = 'auto', void Function(String status, String? detail)? onStatus, void Function(String line)? onLog, + void Function(WhisperRuntimeInfo info)? onRuntimeInfo, }) async { final String? selectedModel = _currentModel; if (selectedModel == null) { @@ -81,18 +101,258 @@ class WhisperService { } final String whisperxModel = modelMap[selectedModel]!; - final Map payload = await _sidecar.transcribe( + final Map payload = await _transcribeWithFallbacks( wavPath: wavPath, modelName: whisperxModel, language: language == 'auto' ? null : language, - device: _defaultDevice, - computeType: _defaultComputeType, - batchSize: _defaultBatchSize, + onStatus: onStatus, + onLog: onLog, + onRuntimeInfo: onRuntimeInfo, + ); + return _parseTranscriptionPayload(payload, requestedLanguage: language); + } + + Future inspectRuntime({required String modelName}) async { + final String whisperxModel = modelMap[modelName] ?? modelName; + final _WhisperExecutionConfig config = await _resolveExecutionConfig( + whisperxModel, + ); + return _buildRuntimeInfo(config); + } + + Future> _transcribeWithFallbacks({ + required String wavPath, + required String modelName, + required String? language, + void Function(String status, String? detail)? onStatus, + void Function(String line)? onLog, + void Function(WhisperRuntimeInfo info)? onRuntimeInfo, + }) async { + final _WhisperExecutionConfig primaryConfig = await _resolveExecutionConfig( + modelName, + ); + + try { + return await _transcribeWithConfig( + wavPath: wavPath, + modelName: modelName, + language: language, + config: primaryConfig, + onStatus: onStatus, + onLog: onLog, + onRuntimeInfo: onRuntimeInfo, + ); + } catch (error) { + if (!primaryConfig.usesCuda || !_looksLikeCudaFailure(error)) { + rethrow; + } + + final _WhisperExecutionConfig? degradedConfig = _buildDegradedCudaConfig( + primaryConfig, + ); + if (degradedConfig != null) { + await _restartSidecarForRetry(); + try { + return await _transcribeWithConfig( + wavPath: wavPath, + modelName: modelName, + language: language, + config: degradedConfig, + onStatus: onStatus, + onLog: onLog, + onRuntimeInfo: onRuntimeInfo, + ); + } catch (retryError) { + if (!_looksLikeCudaFailure(retryError)) { + rethrow; + } + } + } + + await _restartSidecarForRetry(); + return _transcribeWithConfig( + wavPath: wavPath, + modelName: modelName, + language: language, + config: _cpuConfig( + statusDetail: + 'CUDA failed, falling back to CPU ($_cpuComputeType, batch=$_cpuBatchSize)', + ), + onStatus: onStatus, + onLog: onLog, + onRuntimeInfo: onRuntimeInfo, + ); + } + } + + Future> _transcribeWithConfig({ + required String wavPath, + required String modelName, + required String? language, + required _WhisperExecutionConfig config, + void Function(String status, String? detail)? onStatus, + void Function(String line)? onLog, + void Function(WhisperRuntimeInfo info)? onRuntimeInfo, + }) async { + onRuntimeInfo?.call(await _buildRuntimeInfo(config)); + onStatus?.call('preparing_model', config.statusDetail); + return _sidecar.transcribe( + wavPath: wavPath, + modelName: modelName, + language: language, + device: config.device, + computeType: config.computeType, + batchSize: config.batchSize, noAlign: false, onStatus: onStatus, onLog: onLog, ); - return _parseTranscriptionPayload(payload, requestedLanguage: language); + } + + Future<_WhisperExecutionConfig> _resolveExecutionConfig( + String whisperxModel, + ) async { + if (!Platform.isWindows) { + return _cpuConfig(); + } + + final WhisperXRuntimeProbe probe = await _loadRuntimeProbe(); + if (!probe.canUseCuda) { + return _cpuConfig(); + } + + final String computeType = _selectCudaComputeType(probe.cudaComputeTypes); + final int batchSize = _selectCudaBatchSize(whisperxModel); + final String deviceName = (probe.cudaDeviceName?.trim().isNotEmpty ?? false) + ? probe.cudaDeviceName!.trim() + : 'CUDA GPU'; + + return _WhisperExecutionConfig( + device: 'cuda', + computeType: computeType, + batchSize: batchSize, + usesCuda: true, + statusDetail: + 'Using $deviceName on CUDA ($computeType, batch=$batchSize)', + ); + } + + Future _loadRuntimeProbe() async { + return _sidecar.probeRuntime(); + } + + Future _buildRuntimeInfo( + _WhisperExecutionConfig config, + ) async { + final WhisperXRuntimeProbe? probe = Platform.isWindows + ? await _loadRuntimeProbe() + : null; + + final bool cudaAvailable = probe?.cudaAvailable == true; + final String? torchCudaVersion = probe?.torchCudaVersion; + final String? deviceName = config.usesCuda + ? (probe?.cudaDeviceName?.trim().isNotEmpty == true + ? probe!.cudaDeviceName!.trim() + : 'CUDA GPU') + : null; + final String modeLabel = switch ((config.usesCuda, cudaAvailable)) { + (true, _) => 'CUDA GPU', + (false, true) => 'CPU fallback', + (false, false) => 'CPU', + }; + + return WhisperRuntimeInfo( + modeLabel: modeLabel, + deviceName: deviceName, + computeType: config.computeType, + batchSize: config.batchSize, + usingGpu: config.usesCuda, + cudaAvailable: cudaAvailable, + torchCudaVersion: torchCudaVersion, + note: config.statusDetail, + ); + } + + _WhisperExecutionConfig _cpuConfig({String? statusDetail}) { + return _WhisperExecutionConfig( + device: _cpuDevice, + computeType: _cpuComputeType, + batchSize: _cpuBatchSize, + usesCuda: false, + statusDetail: statusDetail, + ); + } + + _WhisperExecutionConfig? _buildDegradedCudaConfig( + _WhisperExecutionConfig config, + ) { + if (!config.usesCuda) { + return null; + } + + final int smallerBatchSize = config.batchSize > 4 + ? config.batchSize ~/ 2 + : 4; + if (config.computeType == 'int8' && smallerBatchSize >= config.batchSize) { + return null; + } + + return _WhisperExecutionConfig( + device: 'cuda', + computeType: 'int8', + batchSize: smallerBatchSize, + usesCuda: true, + statusDetail: + 'CUDA init failed or VRAM is low, retrying lighter GPU mode (int8, batch=$smallerBatchSize)', + ); + } + + String _selectCudaComputeType(List computeTypes) { + final Set normalized = computeTypes + .map((String item) => item.trim().toLowerCase()) + .where((String item) => item.isNotEmpty) + .toSet(); + + for (final String candidate in [ + 'float16', + 'int8_float16', + 'int8', + 'float32', + ]) { + if (normalized.contains(candidate)) { + return candidate; + } + } + + return 'float16'; + } + + int _selectCudaBatchSize(String whisperxModel) { + if (whisperxModel == 'medium' || whisperxModel.startsWith('large')) { + return 8; + } + return 16; + } + + bool _looksLikeCudaFailure(Object error) { + final String lower = error.toString().toLowerCase(); + return [ + 'cuda', + 'cudnn', + 'cublas', + 'ctranslate2', + 'compute type', + 'out of memory', + 'insufficient memory', + 'not enough memory', + 'device-side assert', + 'failed to load library', + 'dll load failed', + ].any(lower.contains); + } + + Future _restartSidecarForRetry() async { + await _sidecar.dispose(); } Future cleanupTempWav( diff --git a/lib/services/whisper/whisperx_runtime.dart b/lib/services/whisper/whisperx_runtime.dart index 205beb8..6f5ead1 100644 --- a/lib/services/whisper/whisperx_runtime.dart +++ b/lib/services/whisper/whisperx_runtime.dart @@ -40,6 +40,46 @@ class _ManagedRuntimeSpec { }); } +class _WhisperXDependencyProfile { + final String id; + final bool prefersCuda; + final String? torchIndexUrl; + + const _WhisperXDependencyProfile({ + required this.id, + required this.prefersCuda, + this.torchIndexUrl, + }); +} + +class _CudaVersion implements Comparable<_CudaVersion> { + final int major; + final int minor; + + const _CudaVersion(this.major, this.minor); + + static _CudaVersion? tryParse(String value) { + final Match? match = RegExp(r'(\d+)\.(\d+)').firstMatch(value); + if (match == null) { + return null; + } + + return _CudaVersion(int.parse(match.group(1)!), int.parse(match.group(2)!)); + } + + @override + int compareTo(_CudaVersion other) { + final int majorDiff = major.compareTo(other.major); + if (majorDiff != 0) { + return majorDiff; + } + return minor.compareTo(other.minor); + } + + @override + String toString() => '$major.$minor'; +} + /// Ensures local Python runtime for WhisperX sidecar execution. /// /// Runtime resolution order: @@ -51,22 +91,37 @@ class WhisperXRuntime { static const String _workerFileName = 'whisperx_worker.py'; static const String _manifestAssetPath = 'assets/sidecar/runtime_manifest.json'; - static const String _runtimeVersion = '2'; - static const String _venvMarkerFile = '.runtime_ready_v2'; + static const String _runtimeVersion = '3'; + static const String _venvMarkerFile = '.runtime_ready_v3'; static const String _managedMarkerFile = '.managed_runtime_v1'; static const String _targetWhisperxVersion = '3.8.2'; + static const String _torchCpuIndexUrl = + 'https://download.pytorch.org/whl/cpu'; WhisperXRuntime._(); static final WhisperXRuntime instance = WhisperXRuntime._(); WhisperXRuntimeInfo? _cachedInfo; + String? _cachedDependencyProfileId; + + Future resolveCurrentDependencyProfileId() async { + final _WhisperXDependencyProfile profile = + await _resolveDependencyProfile(); + return profile.id; + } Future ensureReady({ void Function(int percent)? onProgress, void Function(int received, int total)? onDownloadProgress, + void Function(String phase)? onStatus, }) async { - if (_cachedInfo != null) { + onStatus?.call('checking_runtime'); + final _WhisperXDependencyProfile dependencyProfile = + await _resolveDependencyProfile(); + + if (_cachedInfo != null && + _cachedDependencyProfileId == dependencyProfile.id) { onProgress?.call(100); return _cachedInfo!; } @@ -86,10 +141,12 @@ class WhisperXRuntime { runtimeDir, onProgress: onProgress, onDownloadProgress: onDownloadProgress, + onStatus: onStatus, ); onProgress?.call(62); final Directory venvDir = Directory(p.join(runtimeDir.path, 'venv')); + onStatus?.call('creating_environment'); await _ensureVenv(venvDir, basePythonExecutable); final String venvPythonPath = _resolveVenvPython(venvDir); @@ -99,12 +156,21 @@ class WhisperXRuntime { onProgress?.call(72); final File marker = File(p.join(runtimeDir.path, _venvMarkerFile)); - if (!await _isWhisperxInstalled(venvPythonPath) || !await marker.exists()) { - await _installDependencies(venvPythonPath, onProgress: onProgress); + if (!await _isWhisperxInstalled(venvPythonPath) || + !await _isDependencyMarkerValid(marker, dependencyProfile)) { + await _installDependencies( + venvPythonPath, + dependencyProfile: dependencyProfile, + onProgress: onProgress, + onStatus: onStatus, + ); await marker.writeAsString( jsonEncode({ 'runtimeVersion': _runtimeVersion, 'whisperxVersion': _targetWhisperxVersion, + 'dependencyProfileId': dependencyProfile.id, + 'prefersCuda': dependencyProfile.prefersCuda, + 'torchIndexUrl': dependencyProfile.torchIndexUrl, 'createdAt': DateTime.now().toIso8601String(), }), ); @@ -117,6 +183,7 @@ class WhisperXRuntime { workerScriptPath: workerPath, ); _cachedInfo = info; + _cachedDependencyProfileId = dependencyProfile.id; onProgress?.call(100); return info; } @@ -142,6 +209,7 @@ class WhisperXRuntime { Directory runtimeDir, { void Function(int percent)? onProgress, void Function(int received, int total)? onDownloadProgress, + void Function(String phase)? onStatus, }) async { final _ManagedRuntimeSpec? managedSpec = await _loadManagedSpec(); if (managedSpec != null) { @@ -150,6 +218,7 @@ class WhisperXRuntime { managedSpec, onProgress: onProgress, onDownloadProgress: onDownloadProgress, + onStatus: onStatus, ); if (File(managedPython).existsSync()) { return managedPython; @@ -232,6 +301,7 @@ class WhisperXRuntime { _ManagedRuntimeSpec spec, { void Function(int percent)? onProgress, void Function(int received, int total)? onDownloadProgress, + void Function(String phase)? onStatus, }) async { final Directory managedDir = Directory( p.join(runtimeDir.path, 'managed_python_${spec.id}'), @@ -256,6 +326,7 @@ class WhisperXRuntime { try { onProgress?.call(14); + onStatus?.call('downloading_runtime'); final String archiveExt = spec.archiveType == 'tar.gz' ? 'tar.gz' : 'zip'; final File archiveFile = File( p.join(tempDir.path, 'runtime.$archiveExt'), @@ -285,6 +356,7 @@ class WhisperXRuntime { } onProgress?.call(40); + onStatus?.call('extracting_runtime'); final Directory stageDir = Directory(p.join(tempDir.path, 'stage')); await stageDir.create(recursive: true); await _extractArchive(archiveFile, stageDir, spec.archiveType); @@ -350,6 +422,154 @@ class WhisperXRuntime { } } + Future<_WhisperXDependencyProfile> _resolveDependencyProfile() async { + if (!Platform.isWindows) { + return const _WhisperXDependencyProfile( + id: 'default', + prefersCuda: false, + ); + } + + final _CudaVersion? cudaVersion = await _detectWindowsCudaVersion(); + final String? torchChannel = _selectWindowsTorchChannel(cudaVersion); + if (torchChannel == null) { + return const _WhisperXDependencyProfile( + id: 'windows-cpu', + prefersCuda: false, + torchIndexUrl: _torchCpuIndexUrl, + ); + } + + return _WhisperXDependencyProfile( + id: 'windows-$torchChannel', + prefersCuda: true, + torchIndexUrl: 'https://download.pytorch.org/whl/$torchChannel', + ); + } + + Future<_CudaVersion?> _detectWindowsCudaVersion() async { + if (!Platform.isWindows) { + return null; + } + + final String? systemRoot = Platform.environment['SystemRoot']; + final Set candidates = { + 'nvidia-smi', + if ((systemRoot ?? '').isNotEmpty) + p.join(systemRoot!, 'System32', 'nvidia-smi.exe'), + }; + + for (final String executable in candidates) { + try { + final ProcessResult result = await Process.run( + executable, + const [], + ); + if (result.exitCode != 0) { + continue; + } + + final String output = '${result.stdout}\n${result.stderr}'; + final Match? match = RegExp( + r'CUDA Version:\s*([0-9]+\.[0-9]+)', + caseSensitive: false, + ).firstMatch(output); + if (match == null) { + continue; + } + + final _CudaVersion? parsed = _CudaVersion.tryParse(match.group(1)!); + if (parsed != null) { + return parsed; + } + } catch (_) { + continue; + } + } + + return _detectWindowsCudaVersionFromEnvironment(); + } + + _CudaVersion? _detectWindowsCudaVersionFromEnvironment() { + _CudaVersion? best; + + for (final MapEntry entry in Platform.environment.entries) { + final Match? match = RegExp( + r'^CUDA_PATH_V(\d+)_(\d+)$', + caseSensitive: false, + ).firstMatch(entry.key); + if (match == null) { + continue; + } + + final _CudaVersion parsed = _CudaVersion( + int.parse(match.group(1)!), + int.parse(match.group(2)!), + ); + if (best == null || parsed.compareTo(best) > 0) { + best = parsed; + } + } + + if (best != null) { + return best; + } + + final String? cudaPath = Platform.environment['CUDA_PATH']; + if (cudaPath == null || cudaPath.trim().isEmpty) { + return null; + } + + return _CudaVersion.tryParse(cudaPath); + } + + String? _selectWindowsTorchChannel(_CudaVersion? cudaVersion) { + if (cudaVersion == null) { + return null; + } + + if (cudaVersion.compareTo(const _CudaVersion(12, 6)) >= 0) { + return 'cu126'; + } + if (cudaVersion.compareTo(const _CudaVersion(12, 4)) >= 0) { + return 'cu124'; + } + if (cudaVersion.compareTo(const _CudaVersion(12, 1)) >= 0) { + return 'cu121'; + } + if (cudaVersion.compareTo(const _CudaVersion(11, 8)) >= 0) { + return 'cu118'; + } + + return null; + } + + Future _isDependencyMarkerValid( + File marker, + _WhisperXDependencyProfile dependencyProfile, + ) async { + if (!await marker.exists()) { + return false; + } + + try { + final dynamic decoded = jsonDecode(await marker.readAsString()); + if (decoded is! Map) { + return false; + } + + return (decoded['runtimeVersion'] as String? ?? '') == _runtimeVersion && + (decoded['whisperxVersion'] as String? ?? '') == + _targetWhisperxVersion && + (decoded['dependencyProfileId'] as String? ?? '') == + dependencyProfile.id && + (decoded['torchIndexUrl'] as String?) == + dependencyProfile.torchIndexUrl; + } catch (_) { + return false; + } + } + Future _downloadFile( Uri url, File output, { @@ -468,20 +688,24 @@ class WhisperXRuntime { Future _isWhisperxInstalled(String pythonExecutable) async { final result = await Process.run(pythonExecutable, [ '-c', - 'import whisperx, numpy; print(whisperx.__version__)', + 'from importlib import metadata; import whisperx, numpy; ' + 'print(metadata.version("whisperx"))', ]); if (result.exitCode != 0) { return false; } final String version = (result.stdout as String).trim(); - return version.isNotEmpty; + return version == _targetWhisperxVersion; } Future _installDependencies( String pythonExecutable, { + required _WhisperXDependencyProfile dependencyProfile, void Function(int percent)? onProgress, + void Function(String phase)? onStatus, }) async { + onStatus?.call('installing_dependencies'); onProgress?.call(78); await _runOrThrow(pythonExecutable, [ '-m', @@ -491,16 +715,68 @@ class WhisperXRuntime { 'pip', ], errorPrefix: 'Failed to upgrade pip for WhisperX runtime.'); - onProgress?.call(86); + onProgress?.call(84); await _runOrThrow( pythonExecutable, ['-m', 'pip', 'install', 'whisperx==$_targetWhisperxVersion', 'numpy'], errorPrefix: 'Failed to install WhisperX runtime dependencies.', ); + if (Platform.isWindows && dependencyProfile.torchIndexUrl != null) { + // whisperx resolves torch from the default index, which can replace a + // previously installed CUDA wheel with the CPU build on Windows. + // Reinstall the desired torch channel last so probe_runtime sees the + // actual GPU-capable runtime. + onProgress?.call(90); + await _installWindowsTorchRuntime( + pythonExecutable, + dependencyProfile: dependencyProfile, + ); + } + onProgress?.call(94); } + Future _installWindowsTorchRuntime( + String pythonExecutable, { + required _WhisperXDependencyProfile dependencyProfile, + }) async { + await _runBestEffort(pythonExecutable, [ + '-m', + 'pip', + 'uninstall', + '-y', + 'torch', + 'torchaudio', + 'torchvision', + ]); + + await _runOrThrow( + pythonExecutable, + [ + '-m', + 'pip', + 'install', + '--upgrade', + 'torch', + 'torchaudio', + '--index-url', + dependencyProfile.torchIndexUrl!, + ], + errorPrefix: dependencyProfile.prefersCuda + ? 'Failed to install CUDA-enabled PyTorch runtime for WhisperX.' + : 'Failed to install CPU PyTorch runtime for WhisperX.', + ); + } + + Future _runBestEffort(String executable, List args) async { + try { + await Process.run(executable, args); + } catch (_) { + return; + } + } + Future _runOrThrow( String executable, List args, { diff --git a/lib/services/whisper/whisperx_sidecar.dart b/lib/services/whisper/whisperx_sidecar.dart index 10499f2..28de141 100644 --- a/lib/services/whisper/whisperx_sidecar.dart +++ b/lib/services/whisper/whisperx_sidecar.dart @@ -7,6 +7,68 @@ import 'package:path/path.dart' as p; import 'whisperx_runtime.dart'; +class WhisperXRuntimeProbe { + final String platform; + final String pythonVersion; + final String whisperxVersion; + final String? torchVersion; + final String? torchCudaVersion; + final bool cudaAvailable; + final int cudaDeviceCount; + final String? cudaDeviceName; + final List cudaComputeTypes; + final String? torchError; + final String? ctranslate2Version; + final String? ctranslate2Error; + final String? ctranslate2CudaError; + + const WhisperXRuntimeProbe({ + required this.platform, + required this.pythonVersion, + required this.whisperxVersion, + required this.torchVersion, + required this.torchCudaVersion, + required this.cudaAvailable, + required this.cudaDeviceCount, + required this.cudaDeviceName, + required this.cudaComputeTypes, + required this.torchError, + required this.ctranslate2Version, + required this.ctranslate2Error, + required this.ctranslate2CudaError, + }); + + bool get canUseCuda => + cudaAvailable && cudaDeviceCount > 0 && cudaComputeTypes.isNotEmpty; + + factory WhisperXRuntimeProbe.fromPayload(Map payload) { + final List computeTypes = + ((payload['cuda_compute_types'] as List?) ?? []) + .map((dynamic item) => item.toString().trim().toLowerCase()) + .where((String item) => item.isNotEmpty) + .toSet() + .toList() + ..sort(); + + return WhisperXRuntimeProbe( + platform: (payload['platform'] as String? ?? '').trim().toLowerCase(), + pythonVersion: (payload['python_version'] as String? ?? '').trim(), + whisperxVersion: (payload['whisperx_version'] as String? ?? '').trim(), + torchVersion: (payload['torch_version'] as String?)?.trim(), + torchCudaVersion: (payload['torch_cuda_version'] as String?)?.trim(), + cudaAvailable: payload['cuda_available'] == true, + cudaDeviceCount: (payload['cuda_device_count'] as num?)?.toInt() ?? 0, + cudaDeviceName: (payload['cuda_device_name'] as String?)?.trim(), + cudaComputeTypes: computeTypes, + torchError: (payload['torch_error'] as String?)?.trim(), + ctranslate2Version: (payload['ctranslate2_version'] as String?)?.trim(), + ctranslate2Error: (payload['ctranslate2_error'] as String?)?.trim(), + ctranslate2CudaError: (payload['ctranslate2_cuda_error'] as String?) + ?.trim(), + ); + } +} + class _PendingRequest { final Completer> completer; final void Function(int progress)? onProgress; @@ -31,23 +93,42 @@ class WhisperXSidecar { Future? _startFuture; int _requestSeq = 0; String? _activeRequestForLogs; + Future? _runtimeProbeFuture; + String? _startedDependencyProfileId; Future ensureStarted({ void Function(int percent)? onBootstrapProgress, void Function(int received, int total)? onRuntimeDownloadProgress, + void Function(String phase)? onBootstrapStatus, }) async { - if (_process != null) { + final String? desiredProfileId = Platform.isWindows + ? await WhisperXRuntime.instance.resolveCurrentDependencyProfileId() + : null; + + if (_process != null && + (desiredProfileId == null || + _startedDependencyProfileId == desiredProfileId)) { return; } if (_startFuture != null) { await _startFuture; - return; + if (_process != null && + (desiredProfileId == null || + _startedDependencyProfileId == desiredProfileId)) { + return; + } + } + + if (_process != null) { + await dispose(); } _startFuture = _doStart( + expectedDependencyProfileId: desiredProfileId, onBootstrapProgress: onBootstrapProgress, onRuntimeDownloadProgress: onRuntimeDownloadProgress, + onBootstrapStatus: onBootstrapStatus, ); try { await _startFuture; @@ -57,55 +138,68 @@ class WhisperXSidecar { } Future _doStart({ + String? expectedDependencyProfileId, void Function(int percent)? onBootstrapProgress, void Function(int received, int total)? onRuntimeDownloadProgress, + void Function(String phase)? onBootstrapStatus, }) async { final WhisperXRuntimeInfo info = await WhisperXRuntime.instance.ensureReady( onProgress: onBootstrapProgress, onDownloadProgress: onRuntimeDownloadProgress, + onStatus: onBootstrapStatus, ); _readyCompleter = Completer(); + onBootstrapStatus?.call('starting_sidecar'); final process = await Process.start( info.pythonExecutable, [info.workerScriptPath], workingDirectory: p.dirname(info.workerScriptPath), + environment: const { + 'PYTHONIOENCODING': 'utf-8', + 'PYTHONUTF8': '1', + }, runInShell: false, ); _process = process; + _startedDependencyProfileId = expectedDependencyProfileId; _stdoutSub = process.stdout - .transform(utf8.decoder) + .transform(const Utf8Decoder(allowMalformed: true)) .transform(const LineSplitter()) - .listen(_handleStdoutLine); + .listen( + _handleStdoutLine, + onError: (Object error, StackTrace stackTrace) { + _handleProcessFailure( + Exception('WhisperX sidecar stdout decode failed: $error'), + terminateProcess: true, + ); + }, + ); _stderrSub = process.stderr - .transform(utf8.decoder) + .transform(const Utf8Decoder(allowMalformed: true)) .transform(const LineSplitter()) - .listen((line) { - debugPrint('[WhisperX][stderr] $line'); - final String? id = _activeRequestForLogs; - if (id == null) return; - final _PendingRequest? pending = _pending[id]; - pending?.onLog?.call(line); - }); + .listen( + (line) { + debugPrint('[WhisperX][stderr] $line'); + final String? id = _activeRequestForLogs; + if (id == null) return; + final _PendingRequest? pending = _pending[id]; + pending?.onLog?.call(line); + }, + onError: (Object error, StackTrace stackTrace) { + _handleProcessFailure( + Exception('WhisperX sidecar stderr decode failed: $error'), + terminateProcess: true, + ); + }, + ); process.exitCode.then((code) { - final Exception error = Exception( - 'WhisperX sidecar exited unexpectedly: $code', + _handleProcessFailure( + Exception('WhisperX sidecar exited unexpectedly: $code'), ); - final readyCompleter = _readyCompleter; - if (readyCompleter != null && !readyCompleter.isCompleted) { - readyCompleter.completeError(error); - } - for (final pending in _pending.values) { - if (!pending.completer.isCompleted) { - pending.completer.completeError(error); - } - } - _pending.clear(); - _activeRequestForLogs = null; - _process = null; }); await _readyCompleter!.future.timeout( @@ -200,6 +294,30 @@ class WhisperXSidecar { } } + void _handleProcessFailure( + Exception error, { + bool terminateProcess = false, + }) { + if (terminateProcess) { + _process?.kill(); + } + + final readyCompleter = _readyCompleter; + if (readyCompleter != null && !readyCompleter.isCompleted) { + readyCompleter.completeError(error); + } + for (final pending in _pending.values) { + if (!pending.completer.isCompleted) { + pending.completer.completeError(error); + } + } + _pending.clear(); + _activeRequestForLogs = null; + _runtimeProbeFuture = null; + _startedDependencyProfileId = null; + _process = null; + } + Future> transcribe({ required String wavPath, required String modelName, @@ -229,6 +347,31 @@ class WhisperXSidecar { ); } + Future probeRuntime() { + final Future? cached = _runtimeProbeFuture; + if (cached != null) { + return cached; + } + + final Future future = _sendRequest( + method: 'probe_runtime', + params: const {}, + ).then(WhisperXRuntimeProbe.fromPayload); + + _runtimeProbeFuture = future; + unawaited( + future.then( + (_) {}, + onError: (_) { + if (identical(_runtimeProbeFuture, future)) { + _runtimeProbeFuture = null; + } + }, + ), + ); + return future; + } + Future> _sendRequest({ required String method, required Map params, @@ -287,5 +430,7 @@ class WhisperXSidecar { _readyCompleter = null; _pending.clear(); _activeRequestForLogs = null; + _runtimeProbeFuture = null; + _startedDependencyProfileId = null; } } diff --git a/lib/ui/widgets/transcription_panel.dart b/lib/ui/widgets/transcription_panel.dart index 9bc7ef7..0ac6df1 100644 --- a/lib/ui/widgets/transcription_panel.dart +++ b/lib/ui/widgets/transcription_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../blocs/transcription/transcription_state.dart'; import '../../core/constants.dart'; +import '../../models/whisper_runtime_info.dart'; import 'package:caption_trans/l10n/app_localizations.dart'; /// Panel for controlling Whisper transcription. @@ -22,6 +23,7 @@ class TranscriptionPanel extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; + final WhisperRuntimeInfo? runtimeInfo = _runtimeInfo; return Card( child: Padding( @@ -84,7 +86,16 @@ class TranscriptionPanel extends StatelessWidget { state is TranscriptionError) Padding( padding: const EdgeInsets.only(top: 20), - child: _buildStatusWidget(context, l10n), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatusWidget(context, l10n), + if (runtimeInfo != null) ...[ + const SizedBox(height: 12), + _buildRuntimeInfoCard(context, runtimeInfo), + ], + ], + ), ), ], ), @@ -102,6 +113,16 @@ class TranscriptionPanel extends StatelessWidget { state is TranscriptionError || state is TranscriptionComplete; + WhisperRuntimeInfo? get _runtimeInfo { + final TranscriptionState currentState = state; + if (currentState is RuntimePreparing) return currentState.runtimeInfo; + if (currentState is AudioTranscoding) return currentState.runtimeInfo; + if (currentState is Transcribing) return currentState.runtimeInfo; + if (currentState is TranscriptionComplete) return currentState.runtimeInfo; + if (currentState is TranscriptionError) return currentState.runtimeInfo; + return null; + } + Widget _buildStartButton(BuildContext context, AppLocalizations l10n) { if (_isProcessing) { return const SizedBox( @@ -122,11 +143,20 @@ class TranscriptionPanel extends StatelessWidget { if (state is RuntimePreparing) { final s = state as RuntimePreparing; - return _buildProgressRow( + final String label = _runtimePreparingLabel(l10n, s.phase); + if (s.progress != null) { + return _buildProgressRow( + context, + icon: Icons.download_rounded, + label: label, + progress: s.progress, + color: Colors.blue, + ); + } + return _buildBusyRow( context, - icon: Icons.download_rounded, - label: l10n.preparingRuntime, - progress: s.progress >= 0 ? s.progress : null, + icon: Icons.settings_suggest_rounded, + label: label, color: Colors.blue, ); } @@ -238,6 +268,37 @@ class TranscriptionPanel extends StatelessWidget { ); } + Widget _buildBusyRow( + BuildContext context, { + required IconData icon, + required String label, + required Color color, + }) { + return Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + const SizedBox(width: 8), + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ), + ], + ); + } + Widget _buildStatusRow( BuildContext context, { required IconData icon, @@ -262,6 +323,131 @@ class TranscriptionPanel extends StatelessWidget { ); } + Widget _buildRuntimeInfoCard( + BuildContext context, + WhisperRuntimeInfo runtimeInfo, + ) { + final bool usingGpu = runtimeInfo.usingGpu; + final Color accent = usingGpu ? Colors.greenAccent : Colors.orangeAccent; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: accent.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + usingGpu ? Icons.memory_rounded : Icons.developer_board_rounded, + size: 16, + color: accent, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + usingGpu ? 'GPU acceleration active' : 'CPU transcription', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: accent, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildRuntimeChip(runtimeInfo.modeLabel, accent), + if (runtimeInfo.deviceName != null && + runtimeInfo.deviceName!.trim().isNotEmpty) + _buildRuntimeChip(runtimeInfo.deviceName!, Colors.white70), + _buildRuntimeChip( + 'compute ${runtimeInfo.computeType}', + Colors.white70, + ), + _buildRuntimeChip( + 'batch ${runtimeInfo.batchSize}', + Colors.white70, + ), + if (runtimeInfo.torchCudaVersion != null && + runtimeInfo.torchCudaVersion!.trim().isNotEmpty) + _buildRuntimeChip( + 'torch CUDA ${runtimeInfo.torchCudaVersion!}', + Colors.white70, + ), + ], + ), + if (runtimeInfo.note != null && + runtimeInfo.note!.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + runtimeInfo.note!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.72), + ), + ), + ], + if (!usingGpu && runtimeInfo.cudaAvailable) ...[ + const SizedBox(height: 8), + Text( + 'CUDA is available, but this run is using CPU fallback.', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.orangeAccent), + ), + ], + ], + ), + ); + } + + Widget _buildRuntimeChip(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.22)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + String _runtimePreparingLabel( + AppLocalizations l10n, + RuntimePreparingPhase phase, + ) { + switch (phase) { + case RuntimePreparingPhase.checkingRuntime: + return l10n.runtimeChecking; + case RuntimePreparingPhase.downloadingRuntime: + return l10n.runtimeDownloading; + case RuntimePreparingPhase.extractingRuntime: + return l10n.runtimeExtracting; + case RuntimePreparingPhase.creatingEnvironment: + return l10n.runtimeCreatingEnvironment; + case RuntimePreparingPhase.installingDependencies: + return l10n.runtimeInstallingDependencies; + case RuntimePreparingPhase.startingSidecar: + return l10n.runtimeStartingSidecar; + } + } + Widget _buildModelMenuItem( BuildContext context, WhisperModelInfo info,