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:
1 parent
29be695d63
commit
fb801bb447
13 files changed
+1268
-69
No files matched your search
@@ -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];
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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...';
|
||||
|
||||
|
||||
@@ -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 => '正在将媒体转码为音频...';
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"qualityBest": "最好",
|
||||
"extract": "提取",
|
||||
"preparingRuntime": "正在准备 WhisperX 运行时...",
|
||||
"runtimeChecking": "正在检查 WhisperX 运行时...",
|
||||
"runtimeDownloading": "正在下载运行时包...",
|
||||
"runtimeExtracting": "正在解压运行时包...",
|
||||
"runtimeCreatingEnvironment": "正在创建 Python 环境...",
|
||||
"runtimeInstallingDependencies": "正在安装 WhisperX 依赖...",
|
||||
"runtimeStartingSidecar": "正在启动 WhisperX 侧车...",
|
||||
"transcodingAudio": "正在将媒体转码为音频...",
|
||||
"transcribingStatus": "正在转录...",
|
||||
"transcriptionLoadingAudio": "正在加载音频...",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user