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
This commit is contained in:
lml committed 2026-03-20 09:21:32 +08:00
1 parent 29be695d63
commit fb801bb447
13 files changed
+1268 -69

No files matched your search

+108
View File
@@ -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)
@@ -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<TranscriptionEvent, TranscriptionState> {
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<TranscriptionEvent, TranscriptionState> {
// 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<TranscriptionEvent, TranscriptionState> {
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<TranscriptionEvent, TranscriptionState> {
fileName: fileName,
phase: phase,
statusDetail: normalized,
runtimeInfo: runtimeInfo,
),
);
}
@@ -97,11 +123,19 @@ class TranscriptionBloc extends Bloc<TranscriptionEvent, TranscriptionState> {
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<TranscriptionEvent, TranscriptionState> {
videoPath: videoPath,
fileName: fileName,
result: result,
runtimeInfo: runtimeInfo,
),
);
} catch (e) {
@@ -128,6 +163,7 @@ class TranscriptionBloc extends Bloc<TranscriptionEvent, TranscriptionState> {
videoPath: videoPath,
fileName: fileName,
message: e.toString(),
runtimeInfo: runtimeInfo,
),
);
} finally {
@@ -145,6 +181,24 @@ class TranscriptionBloc extends Bloc<TranscriptionEvent, TranscriptionState> {
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':
@@ -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<Object?> 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<Object?> get props => [videoPath, fileName, progress];
List<Object?> 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<Object?> get props => [videoPath, fileName];
List<Object?> 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<Object?> get props => [videoPath, fileName, phase, statusDetail];
List<Object?> 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<Object?> get props => [videoPath, fileName, result];
List<Object?> 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<Object?> get props => [videoPath, fileName, message];
List<Object?> get props => [videoPath, fileName, message, runtimeInfo];
}
+6
View File
@@ -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...",
+36
View File
@@ -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:
+19
View File
@@ -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...';
+18
View File
@@ -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 => '正在将媒体转码为音频...';
+6
View File
@@ -25,6 +25,12 @@
"qualityBest": "最好",
"extract": "提取",
"preparingRuntime": "正在准备 WhisperX 运行时...",
"runtimeChecking": "正在检查 WhisperX 运行时...",
"runtimeDownloading": "正在下载运行时包...",
"runtimeExtracting": "正在解压运行时包...",
"runtimeCreatingEnvironment": "正在创建 Python 环境...",
"runtimeInstallingDependencies": "正在安装 WhisperX 依赖...",
"runtimeStartingSidecar": "正在启动 WhisperX 侧车...",
"transcodingAudio": "正在将媒体转码为音频...",
"transcribingStatus": "正在转录...",
"transcriptionLoadingAudio": "正在加载音频...",
+48
View File
@@ -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<String> parts = <String>[
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<Object?> get props => <Object?>[
modeLabel,
deviceName,
computeType,
batchSize,
usingGpu,
cudaAvailable,
torchCudaVersion,
note,
];
}
+276 -16
View File
@@ -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<String, String> 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<void> 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<String, dynamic> payload = await _sidecar.transcribe(
final Map<String, dynamic> 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<WhisperRuntimeInfo> inspectRuntime({required String modelName}) async {
final String whisperxModel = modelMap[modelName] ?? modelName;
final _WhisperExecutionConfig config = await _resolveExecutionConfig(
whisperxModel,
);
return _buildRuntimeInfo(config);
}
Future<Map<String, dynamic>> _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<Map<String, dynamic>> _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<WhisperXRuntimeProbe> _loadRuntimeProbe() async {
return _sidecar.probeRuntime();
}
Future<WhisperRuntimeInfo> _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<String> computeTypes) {
final Set<String> normalized = computeTypes
.map((String item) => item.trim().toLowerCase())
.where((String item) => item.isNotEmpty)
.toSet();
for (final String candidate in <String>[
'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 <String>[
'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<void> _restartSidecarForRetry() async {
await _sidecar.dispose();
}
Future<void> cleanupTempWav(
+284 -8
View File
@@ -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<String> resolveCurrentDependencyProfileId() async {
final _WhisperXDependencyProfile profile =
await _resolveDependencyProfile();
return profile.id;
}
Future<WhisperXRuntimeInfo> 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<String> candidates = <String>{
'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 <String>[],
);
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<String, String> 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<bool> _isDependencyMarkerValid(
File marker,
_WhisperXDependencyProfile dependencyProfile,
) async {
if (!await marker.exists()) {
return false;
}
try {
final dynamic decoded = jsonDecode(await marker.readAsString());
if (decoded is! Map<String, dynamic>) {
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<void> _downloadFile(
Uri url,
File output, {
@@ -468,20 +688,24 @@ class WhisperXRuntime {
Future<bool> _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<void> _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<void> _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<void> _runBestEffort(String executable, List<String> args) async {
try {
await Process.run(executable, args);
} catch (_) {
return;
}
}
Future<void> _runOrThrow(
String executable,
List<String> args, {
+171 -26
View File
@@ -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<String> 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<String, dynamic> payload) {
final List<String> computeTypes =
((payload['cuda_compute_types'] as List<dynamic>?) ?? <dynamic>[])
.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<Map<String, dynamic>> completer;
final void Function(int progress)? onProgress;
@@ -31,23 +93,42 @@ class WhisperXSidecar {
Future<void>? _startFuture;
int _requestSeq = 0;
String? _activeRequestForLogs;
Future<WhisperXRuntimeProbe>? _runtimeProbeFuture;
String? _startedDependencyProfileId;
Future<void> 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<void> _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<void>();
onBootstrapStatus?.call('starting_sidecar');
final process = await Process.start(
info.pythonExecutable,
[info.workerScriptPath],
workingDirectory: p.dirname(info.workerScriptPath),
environment: const <String, String>{
'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<Map<String, dynamic>> transcribe({
required String wavPath,
required String modelName,
@@ -229,6 +347,31 @@ class WhisperXSidecar {
);
}
Future<WhisperXRuntimeProbe> probeRuntime() {
final Future<WhisperXRuntimeProbe>? cached = _runtimeProbeFuture;
if (cached != null) {
return cached;
}
final Future<WhisperXRuntimeProbe> future = _sendRequest(
method: 'probe_runtime',
params: const <String, dynamic>{},
).then(WhisperXRuntimeProbe.fromPayload);
_runtimeProbeFuture = future;
unawaited(
future.then<void>(
(_) {},
onError: (_) {
if (identical(_runtimeProbeFuture, future)) {
_runtimeProbeFuture = null;
}
},
),
);
return future;
}
Future<Map<String, dynamic>> _sendRequest({
required String method,
required Map<String, dynamic> params,
@@ -287,5 +430,7 @@ class WhisperXSidecar {
_readyCompleter = null;
_pending.clear();
_activeRequestForLogs = null;
_runtimeProbeFuture = null;
_startedDependencyProfileId = null;
}
}
+191 -5
View File
@@ -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,