feat: enhance WhisperX integration with MPS support and refactor device management

This commit is contained in:
lml committed 2026-03-21 20:50:46 +08:00
1 parent b20656d6c0
commit a1d99ac5d9
3 files changed
+267 -73

No files matched your search

+125 -26
View File
@@ -11,7 +11,9 @@ import wave
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import torch
import whisperx
from whisperx.vads.pyannote import Pyannote
SAMPLE_RATE = 16000
@@ -42,6 +44,11 @@ JAPANESE_SEGMENTATION_OPTIONS: Dict[str, Any] = {
"max_segment_chars": 24,
"min_split_chars": 4,
}
DEFAULT_WHISPERX_VAD_OPTIONS: Dict[str, Any] = {
"chunk_size": 30,
"vad_onset": 0.500,
"vad_offset": 0.363,
}
def configure_stdio() -> None:
@@ -188,6 +195,38 @@ def normalize_options(raw_options: Any) -> Dict[str, Any]:
return normalized
def normalize_device(value: Any, default: str = "cpu") -> str:
device = str(value or default).strip().lower()
return device or default
def ensure_mps_available() -> None:
mps_backend = getattr(torch.backends, "mps", None)
if mps_backend is None or not mps_backend.is_built():
raise RuntimeError("This PyTorch build does not include MPS support.")
if not mps_backend.is_available():
raise RuntimeError("MPS is not available on this machine.")
def build_vad_model_options(vad_options: Dict[str, Any]) -> Dict[str, Any]:
model_options: Dict[str, Any] = {}
for key in ("vad_onset", "vad_offset"):
if key in vad_options:
model_options[key] = vad_options[key]
return model_options
def build_effective_vad_options(
vad_options: Dict[str, Any], use_custom_vad: bool
) -> Dict[str, Any]:
if not use_custom_vad:
return dict(vad_options)
effective = dict(DEFAULT_WHISPERX_VAD_OPTIONS)
effective.update(vad_options)
return effective
def build_segmentation_options(
language: Optional[str], override_options: Dict[str, Any]
) -> Dict[str, Any]:
@@ -794,16 +833,25 @@ def normalize_transcript_segments(
class WhisperXWorker:
def __init__(self) -> None:
self.models: Dict[Tuple[str, str, str, Optional[str], str, str], Any] = {}
self.models: Dict[
Tuple[str, str, str, str, Optional[str], str, str], Any
] = {}
self.vad_models: Dict[Tuple[str, 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.vad_models.clear()
self.align_models.clear()
else:
self.models = {
key: value for key, value in self.models.items() if key[1] != device
key: value
for key, value in self.models.items()
if key[1] != device and key[2] != device
}
self.vad_models = {
key: value for key, value in self.vad_models.items() if key[0] != device
}
self.align_models = {
key: value
@@ -813,52 +861,87 @@ class WhisperXWorker:
gc.collect()
if device not in (None, "cuda"):
return
try:
import torch
if torch.cuda.is_available():
if device in (None, "cuda") and torch.cuda.is_available():
torch.cuda.empty_cache()
if device in (None, "mps"):
empty_cache = getattr(getattr(torch, "mps", None), "empty_cache", None)
if callable(empty_cache):
empty_cache()
except Exception:
return
def get_vad_model(self, device: str, vad_options: Dict[str, Any]) -> Any:
device = normalize_device(device)
model_options = build_vad_model_options(vad_options)
key = (
device,
json.dumps(model_options, sort_keys=True, ensure_ascii=False),
)
if key in self.vad_models:
return self.vad_models[key]
if device == "cuda":
self.clear_device_resources(device="cuda")
elif device == "mps":
ensure_mps_available()
model = Pyannote(torch.device(device), **model_options)
self.vad_models[key] = model
return model
def get_model(
self,
model_name: str,
device: str,
asr_device: str,
compute_type: str,
language: Optional[str],
asr_options: Dict[str, Any],
vad_options: Dict[str, Any],
vad_device: Optional[str] = None,
) -> Any:
resolved_vad_device = normalize_device(vad_device, asr_device)
use_custom_vad = resolved_vad_device != asr_device
effective_vad_options = build_effective_vad_options(
vad_options, use_custom_vad
)
key = (
model_name,
device,
asr_device,
resolved_vad_device,
compute_type,
language,
json.dumps(asr_options, sort_keys=True, ensure_ascii=False),
json.dumps(vad_options, sort_keys=True, ensure_ascii=False),
json.dumps(effective_vad_options, sort_keys=True, ensure_ascii=False),
)
if key in self.models:
return self.models[key]
if device == "cuda":
if asr_device == "cuda" or resolved_vad_device == "cuda":
self.clear_device_resources(device="cuda")
load_kwargs: Dict[str, Any] = {
"compute_type": compute_type,
"language": language,
"asr_options": asr_options,
}
if effective_vad_options:
load_kwargs["vad_options"] = effective_vad_options
if use_custom_vad:
load_kwargs["vad_model"] = self.get_vad_model(
resolved_vad_device, effective_vad_options
)
model = whisperx.load_model(
model_name,
device,
compute_type=compute_type,
language=language,
asr_options=asr_options,
vad_options=vad_options,
asr_device,
**load_kwargs,
)
self.models[key] = model
return model
def get_align_model(self, language: str, device: str) -> Tuple[Any, Dict[str, Any]]:
device = normalize_device(device)
key = (language, device)
if key in self.align_models:
return self.align_models[key]
@@ -870,6 +953,8 @@ class WhisperXWorker:
if cached_key[1] != "cuda"
}
self.clear_device_resources(device="cuda")
elif device == "mps":
ensure_mps_available()
model, metadata = whisperx.load_align_model(
language_code=language,
@@ -887,11 +972,11 @@ class WhisperXWorker:
"cuda_device_count": 0,
"cuda_device_name": None,
"cuda_compute_types": [],
"mps_built": False,
"mps_available": False,
}
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())
@@ -900,6 +985,10 @@ class WhisperXWorker:
payload["cuda_device_count"] = device_count
if device_count > 0:
payload["cuda_device_name"] = str(torch.cuda.get_device_name(0))
mps_backend = getattr(torch.backends, "mps", None)
if mps_backend is not None:
payload["mps_built"] = bool(mps_backend.is_built())
payload["mps_available"] = bool(mps_backend.is_available())
except Exception as exc: # pylint: disable=broad-except
payload["torch_error"] = str(exc)
@@ -929,7 +1018,10 @@ class WhisperXWorker:
model_name = str(params.get("model") or "small")
language = params.get("language")
language = str(language) if language else None
device = str(params.get("device") or "cpu")
device = normalize_device(params.get("device"), "cpu")
asr_device = normalize_device(params.get("asr_device"), device)
vad_device = normalize_device(params.get("vad_device"), asr_device)
align_device = normalize_device(params.get("align_device"), asr_device)
compute_type = str(params.get("compute_type") or "int8")
batch_size = int(params.get("batch_size") or 4)
no_align = to_bool(params.get("no_align"), False)
@@ -949,11 +1041,12 @@ class WhisperXWorker:
):
model = self.get_model(
model_name=model_name,
device=device,
asr_device=asr_device,
compute_type=compute_type,
language=language,
asr_options=asr_options,
vad_options=vad_options,
vad_device=vad_device,
)
model_logs.flush()
@@ -970,7 +1063,7 @@ class WhisperXWorker:
verbose=False,
)
transcribe_logs.flush()
if device == "cuda":
if asr_device == "cuda" or vad_device == "cuda":
del model
self.clear_device_resources(device="cuda")
@@ -980,7 +1073,9 @@ class WhisperXWorker:
if not no_align and normalized_segments:
emit_status(request_id, "aligning")
emit_progress(request_id, 72)
align_model, align_metadata = self.get_align_model(detected_language, device)
align_model, align_metadata = self.get_align_model(
detected_language, align_device
)
align_logs = ProgressLogStream(request_id)
with contextlib.redirect_stdout(
align_logs
@@ -990,12 +1085,12 @@ class WhisperXWorker:
align_model,
align_metadata,
audio,
device,
align_device,
return_char_alignments=False,
print_progress=True,
)
align_logs.flush()
if device == "cuda":
if align_device == "cuda":
del align_model
self.clear_device_resources(device="cuda")
detected_language = str(aligned.get("language") or detected_language)
@@ -1026,7 +1121,11 @@ class WhisperXWorker:
"payload": payload,
}
)
if device == "cuda":
if (
asr_device == "cuda"
or vad_device == "cuda"
or align_device == "cuda"
):
self.clear_device_resources(device="cuda")
def dispatch(self, message: Dict[str, Any]) -> None:
+126 -47
View File
@@ -7,17 +7,27 @@ import '../audio/media_to_wav_converter.dart';
import 'whisperx_sidecar.dart';
class _WhisperExecutionConfig {
final String device;
final String asrDevice;
final String vadDevice;
final String alignDevice;
final String computeType;
final int batchSize;
final bool usingGpu;
final bool usesCuda;
final String modeLabel;
final String? deviceName;
final String? statusDetail;
const _WhisperExecutionConfig({
required this.device,
required this.asrDevice,
required this.vadDevice,
required this.alignDevice,
required this.computeType,
required this.batchSize,
required this.usingGpu,
required this.usesCuda,
required this.modeLabel,
required this.deviceName,
this.statusDetail,
});
}
@@ -34,8 +44,9 @@ class WhisperService {
};
static const String _cpuDevice = 'cpu';
static const String _mpsDevice = 'mps';
static const String _cpuComputeType = 'int8';
static const int _cpuBatchSize = 4;
static const int _cpuBatchSize = 8;
final WhisperXSidecar _sidecar = WhisperXSidecar();
final MediaToWavConverter _wavConverter = MediaToWavConverter();
@@ -143,30 +154,49 @@ class WhisperService {
onRuntimeInfo: onRuntimeInfo,
);
} catch (error) {
if (!primaryConfig.usesCuda || !_looksLikeCudaFailure(error)) {
rethrow;
}
if (primaryConfig.usesCuda) {
if (!_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;
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,
);
}
if (!primaryConfig.usingGpu || !_looksLikeMpsFailure(error)) {
rethrow;
}
await _restartSidecarForRetry();
@@ -176,7 +206,7 @@ class WhisperService {
language: language,
config: _cpuConfig(
statusDetail:
'CUDA failed, falling back to CPU ($_cpuComputeType, batch=$_cpuBatchSize)',
'MPS failed, falling back to CPU ($_cpuComputeType, batch=$_cpuBatchSize)',
),
onStatus: onStatus,
onLog: onLog,
@@ -205,7 +235,9 @@ class WhisperService {
wavPath: wavPath,
modelName: modelName,
language: language,
device: config.device,
device: config.asrDevice,
vadDevice: config.vadDevice,
alignDevice: config.alignDevice,
computeType: config.computeType,
batchSize: config.batchSize,
noAlign: false,
@@ -233,11 +265,7 @@ class WhisperService {
Map<String, dynamic> _buildVadOptions(String? language) {
switch (_normalizeLanguageCode(language)) {
case 'ja':
return const <String, dynamic>{
'chunk_size': 12,
'vad_onset': 0.4,
'vad_offset': 0.25,
};
return const <String, dynamic>{};
default:
return const <String, dynamic>{};
}
@@ -270,6 +298,30 @@ class WhisperService {
Future<_WhisperExecutionConfig> _resolveExecutionConfig(
String whisperxModel,
) async {
if (Platform.isMacOS && _isArm64Process()) {
final WhisperXRuntimeProbe probe = await _loadRuntimeProbe();
if (probe.canUseMps) {
return const _WhisperExecutionConfig(
asrDevice: _cpuDevice,
vadDevice: _mpsDevice,
alignDevice: _mpsDevice,
computeType: _cpuComputeType,
batchSize: _cpuBatchSize,
usingGpu: true,
usesCuda: false,
modeLabel: 'Mixed CPU + MPS',
deviceName: 'Apple Metal (MPS)',
statusDetail:
'Using CPU ASR with MPS-accelerated VAD and alignment (int8, batch=8)',
);
}
final String statusDetail = probe.mpsBuilt
? 'MPS is unavailable on this machine, using CPU ($_cpuComputeType, batch=$_cpuBatchSize)'
: 'Installed PyTorch runtime does not expose MPS, using CPU ($_cpuComputeType, batch=$_cpuBatchSize)';
return _cpuConfig(statusDetail: statusDetail);
}
if (!Platform.isWindows) {
return _cpuConfig();
}
@@ -286,10 +338,15 @@ class WhisperService {
: 'CUDA GPU';
return _WhisperExecutionConfig(
device: 'cuda',
asrDevice: 'cuda',
vadDevice: 'cuda',
alignDevice: 'cuda',
computeType: computeType,
batchSize: batchSize,
usingGpu: true,
usesCuda: true,
modeLabel: 'CUDA GPU',
deviceName: deviceName,
statusDetail:
'Using $deviceName on CUDA ($computeType, batch=$batchSize)',
);
@@ -302,29 +359,23 @@ class WhisperService {
Future<WhisperRuntimeInfo> _buildRuntimeInfo(
_WhisperExecutionConfig config,
) async {
final WhisperXRuntimeProbe? probe = Platform.isWindows
final WhisperXRuntimeProbe? probe =
(Platform.isWindows || (Platform.isMacOS && _isArm64Process()))
? 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',
};
final String modeLabel = !config.usingGpu && cudaAvailable
? 'CPU fallback'
: config.modeLabel;
return WhisperRuntimeInfo(
modeLabel: modeLabel,
deviceName: deviceName,
deviceName: config.deviceName,
computeType: config.computeType,
batchSize: config.batchSize,
usingGpu: config.usesCuda,
usingGpu: config.usingGpu,
cudaAvailable: cudaAvailable,
torchCudaVersion: torchCudaVersion,
note: config.statusDetail,
@@ -333,10 +384,15 @@ class WhisperService {
_WhisperExecutionConfig _cpuConfig({String? statusDetail}) {
return _WhisperExecutionConfig(
device: _cpuDevice,
asrDevice: _cpuDevice,
vadDevice: _cpuDevice,
alignDevice: _cpuDevice,
computeType: _cpuComputeType,
batchSize: _cpuBatchSize,
usingGpu: false,
usesCuda: false,
modeLabel: 'CPU',
deviceName: null,
statusDetail: statusDetail,
);
}
@@ -356,10 +412,15 @@ class WhisperService {
}
return _WhisperExecutionConfig(
device: 'cuda',
asrDevice: 'cuda',
vadDevice: 'cuda',
alignDevice: 'cuda',
computeType: 'int8',
batchSize: smallerBatchSize,
usingGpu: true,
usesCuda: true,
modeLabel: 'CUDA GPU',
deviceName: config.deviceName,
statusDetail:
'CUDA init failed or VRAM is low, retrying lighter GPU mode (int8, batch=$smallerBatchSize)',
);
@@ -409,6 +470,24 @@ class WhisperService {
].any(lower.contains);
}
bool _looksLikeMpsFailure(Object error) {
final String lower = error.toString().toLowerCase();
return <String>[
'mps',
'metal',
'not implemented for mps',
'placeholder storage has not been allocated on mps',
'mps backend out of memory',
'does not include mps support',
'is not available on this machine',
].any(lower.contains);
}
bool _isArm64Process() {
final String version = Platform.version.toLowerCase();
return version.contains('arm64') || version.contains('aarch64');
}
Future<void> _restartSidecarForRetry() async {
await _sidecar.dispose();
}
@@ -13,6 +13,8 @@ class WhisperXRuntimeProbe {
final String whisperxVersion;
final String? torchVersion;
final String? torchCudaVersion;
final bool mpsBuilt;
final bool mpsAvailable;
final bool cudaAvailable;
final int cudaDeviceCount;
final String? cudaDeviceName;
@@ -28,6 +30,8 @@ class WhisperXRuntimeProbe {
required this.whisperxVersion,
required this.torchVersion,
required this.torchCudaVersion,
required this.mpsBuilt,
required this.mpsAvailable,
required this.cudaAvailable,
required this.cudaDeviceCount,
required this.cudaDeviceName,
@@ -40,6 +44,7 @@ class WhisperXRuntimeProbe {
bool get canUseCuda =>
cudaAvailable && cudaDeviceCount > 0 && cudaComputeTypes.isNotEmpty;
bool get canUseMps => mpsBuilt && mpsAvailable;
factory WhisperXRuntimeProbe.fromPayload(Map<String, dynamic> payload) {
final List<String> computeTypes =
@@ -56,6 +61,8 @@ class WhisperXRuntimeProbe {
whisperxVersion: (payload['whisperx_version'] as String? ?? '').trim(),
torchVersion: (payload['torch_version'] as String?)?.trim(),
torchCudaVersion: (payload['torch_cuda_version'] as String?)?.trim(),
mpsBuilt: payload['mps_built'] == true,
mpsAvailable: payload['mps_available'] == true,
cudaAvailable: payload['cuda_available'] == true,
cudaDeviceCount: (payload['cuda_device_count'] as num?)?.toInt() ?? 0,
cudaDeviceName: (payload['cuda_device_name'] as String?)?.trim(),
@@ -323,6 +330,8 @@ class WhisperXSidecar {
required String computeType,
required int batchSize,
required bool noAlign,
String? vadDevice,
String? alignDevice,
Map<String, dynamic>? asrOptions,
Map<String, dynamic>? vadOptions,
Map<String, dynamic>? segmentationOptions,
@@ -335,10 +344,17 @@ class WhisperXSidecar {
'model': modelName,
'language': language,
'device': device,
'asr_device': device,
'compute_type': computeType,
'batch_size': batchSize,
'no_align': noAlign,
};
if (vadDevice != null && vadDevice.trim().isNotEmpty) {
params['vad_device'] = vadDevice.trim();
}
if (alignDevice != null && alignDevice.trim().isNotEmpty) {
params['align_device'] = alignDevice.trim();
}
if (asrOptions != null && asrOptions.isNotEmpty) {
params['asr_options'] = asrOptions;
}