295 lines
11 KiB
Python
295 lines
11 KiB
Python
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
|
|
WHISPER_MODEL_CHOICES = [
|
|
"small",
|
|
"base",
|
|
"tiny",
|
|
"medium",
|
|
"large-v3",
|
|
"distil-large-v3",
|
|
]
|
|
|
|
WHISPER_LANGUAGE_CHOICES = [
|
|
"auto",
|
|
"en",
|
|
"de",
|
|
"fr",
|
|
"es",
|
|
"it",
|
|
"pt",
|
|
"nl",
|
|
"pl",
|
|
"tr",
|
|
"ja",
|
|
"ko",
|
|
"zh",
|
|
]
|
|
|
|
WHISPER_INSTALL_MESSAGE = (
|
|
"[ThinkingLLM Whisper ASR] faster-whisper is not installed in this ComfyUI Python. "
|
|
"Install it in the active ComfyUI Python with: python -m pip install faster-whisper"
|
|
)
|
|
|
|
|
|
def _clean_audio_file_path(audio_file_path) -> Path | None:
|
|
if audio_file_path is None:
|
|
return None
|
|
raw_path = str(audio_file_path).strip().strip("\"'")
|
|
if not raw_path:
|
|
return None
|
|
return Path(os.path.expandvars(raw_path)).expanduser()
|
|
|
|
|
|
def _ffmpeg_path() -> str:
|
|
ffmpeg = shutil.which("ffmpeg")
|
|
if not ffmpeg:
|
|
raise RuntimeError("ffmpeg was not found on PATH; install FFmpeg or connect a decoded ComfyUI AUDIO input")
|
|
return ffmpeg
|
|
|
|
|
|
def _decode_audio_file_to_wav(audio_file_path, target_path: Path) -> str:
|
|
source_path = _clean_audio_file_path(audio_file_path)
|
|
if source_path is None:
|
|
raise ValueError("audio_file_path is empty")
|
|
if not source_path.exists() or not source_path.is_file():
|
|
raise FileNotFoundError(f"audio_file_path does not exist or is not a file: {source_path}")
|
|
|
|
result = subprocess.run(
|
|
[
|
|
_ffmpeg_path(),
|
|
"-hide_banner",
|
|
"-v",
|
|
"error",
|
|
"-i",
|
|
str(source_path),
|
|
"-vn",
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
"16000",
|
|
"-acodec",
|
|
"pcm_s16le",
|
|
str(target_path),
|
|
],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"ffmpeg failed to decode audio_file_path: {result.stderr.strip() or 'unknown ffmpeg error'}")
|
|
return str(source_path)
|
|
|
|
|
|
def _audio_to_wav_file(audio, target_path: Path) -> str:
|
|
if not isinstance(audio, dict):
|
|
raise ValueError("expected ComfyUI AUDIO dict with waveform/sample_rate")
|
|
waveform = audio.get("waveform")
|
|
if waveform is None:
|
|
raise ValueError("missing waveform")
|
|
sample_rate = int(audio.get("sample_rate") or 0)
|
|
if sample_rate <= 0:
|
|
raise ValueError(f"invalid sample_rate={audio.get('sample_rate')!r}")
|
|
|
|
tensor = waveform.detach().cpu() if hasattr(waveform, "detach") else waveform
|
|
tensor = tensor.cpu() if hasattr(tensor, "cpu") else tensor
|
|
array = tensor.numpy() if hasattr(tensor, "numpy") else np.asarray(tensor)
|
|
array = np.asarray(array, dtype=np.float32)
|
|
if array.size == 0:
|
|
raise ValueError("empty waveform")
|
|
if array.ndim == 3:
|
|
array = array[0]
|
|
if array.ndim == 1:
|
|
array = array[None, :]
|
|
if array.ndim != 2:
|
|
raise ValueError(f"unsupported waveform shape={getattr(array, 'shape', None)}")
|
|
if array.shape[0] > array.shape[-1]:
|
|
array = array.T
|
|
if not np.all(np.isfinite(array)):
|
|
array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=-1.0)
|
|
|
|
mono = np.mean(array, axis=0).astype(np.float32)
|
|
if sample_rate != 16000:
|
|
target_length = max(1, int(round(float(mono.size) * 16000.0 / float(sample_rate))))
|
|
old_positions = np.linspace(0.0, float(mono.size - 1), num=mono.size, dtype=np.float64)
|
|
new_positions = np.linspace(0.0, float(mono.size - 1), num=target_length, dtype=np.float64)
|
|
mono = np.interp(new_positions, old_positions, mono).astype(np.float32)
|
|
|
|
pcm16 = (np.clip(mono, -1.0, 1.0) * 32767.0).astype(np.int16)
|
|
with wave.open(str(target_path), "wb") as wav:
|
|
wav.setnchannels(1)
|
|
wav.setsampwidth(2)
|
|
wav.setframerate(16000)
|
|
wav.writeframes(pcm16.tobytes())
|
|
return "ComfyUI AUDIO input"
|
|
|
|
|
|
def _resolve_whisper_device(device: str) -> str:
|
|
if device == "auto":
|
|
try:
|
|
import torch
|
|
|
|
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
except Exception:
|
|
return "cpu"
|
|
return device
|
|
|
|
|
|
def _resolve_compute_type(device: str, compute_type: str) -> str:
|
|
if compute_type != "auto":
|
|
return compute_type
|
|
return "float16" if device == "cuda" else "int8"
|
|
|
|
|
|
def _missing_audio_result(notes: list[str] | None = None) -> tuple[str, str, str]:
|
|
message = (
|
|
"[ThinkingLLM Whisper ASR] No usable audio input. Connect a ComfyUI AUDIO output "
|
|
"or set audio_file_path to an existing M4A/MP3/WAV/FLAC file."
|
|
)
|
|
trace_lines = ["[AUDIO]", message]
|
|
if notes:
|
|
trace_lines.extend(str(note) for note in notes if note)
|
|
return message, "[]", "\n".join(trace_lines)
|
|
|
|
|
|
def _is_local_whisper_cache_miss(exc: Exception) -> bool:
|
|
try:
|
|
from huggingface_hub.errors import LocalEntryNotFoundError
|
|
|
|
if isinstance(exc, LocalEntryNotFoundError):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
message = str(exc or "").lower()
|
|
return "local_files_only" in message and "cached" in message
|
|
|
|
|
|
class ThinkingLLM_Whisper_ASR:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_size": (WHISPER_MODEL_CHOICES, {"default": "small", "tooltip": "faster-whisper model size. small is a reliable Windows-friendly starting point; large-v3 is higher quality but downloads and runs much larger."}),
|
|
"language": (WHISPER_LANGUAGE_CHOICES, {"default": "auto", "tooltip": "Audio language. Use auto to let Whisper detect it."}),
|
|
"task": (["transcribe", "translate"], {"default": "transcribe", "tooltip": "transcribe preserves the original language; translate returns English."}),
|
|
"device": (["cpu", "auto", "cuda"], {"default": "cpu", "tooltip": "cpu is the most reliable Windows default; choose cuda when your faster-whisper/CTranslate2 CUDA runtime is working."}),
|
|
"compute_type": (["int8", "auto", "float16", "int8_float16", "float32"], {"default": "int8", "tooltip": "int8 is reliable on CPU. Use float16 or int8_float16 for CUDA."}),
|
|
"beam_size": ("INT", {"default": 5, "min": 1, "max": 10, "tooltip": "Higher values can improve accuracy but run slower."}),
|
|
"vad_filter": ("BOOLEAN", {"default": True, "tooltip": "Skip long silence and non-speech sections before transcription."}),
|
|
},
|
|
"optional": {
|
|
"audio": ("AUDIO",),
|
|
"audio_file_path": ("STRING", {"default": "", "multiline": False, "tooltip": "Optional local audio file path. M4A, MP3, WAV, FLAC, and other FFmpeg-readable files are decoded to 16 kHz mono WAV before Whisper."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("TRANSCRIPT", "SEGMENTS_JSON", "RAW_TRACE")
|
|
FUNCTION = "process"
|
|
CATEGORY = "ThinkingLLM/Audio"
|
|
|
|
def process(
|
|
self,
|
|
model_size: str,
|
|
language: str,
|
|
task: str,
|
|
device: str,
|
|
compute_type: str,
|
|
beam_size: int,
|
|
vad_filter: bool,
|
|
audio=None,
|
|
audio_file_path="",
|
|
):
|
|
notes: list[str] = []
|
|
clean_path = _clean_audio_file_path(audio_file_path)
|
|
if audio is None and clean_path is None:
|
|
return _missing_audio_result()
|
|
|
|
try:
|
|
from faster_whisper import WhisperModel
|
|
except Exception as exc:
|
|
trace = "\n".join(["[WHISPER]", WHISPER_INSTALL_MESSAGE, f"import_error={exc}"])
|
|
return WHISPER_INSTALL_MESSAGE, "[]", trace
|
|
|
|
with tempfile.TemporaryDirectory(prefix="thinkingllm-whisper-") as temp_dir:
|
|
wav_path = Path(temp_dir) / "audio.wav"
|
|
try:
|
|
if clean_path is not None:
|
|
source_label = _decode_audio_file_to_wav(str(clean_path), wav_path)
|
|
else:
|
|
source_label = _audio_to_wav_file(audio, wav_path)
|
|
except Exception as exc:
|
|
return _missing_audio_result([str(exc)])
|
|
|
|
resolved_device = _resolve_whisper_device(str(device))
|
|
resolved_compute_type = _resolve_compute_type(resolved_device, str(compute_type))
|
|
local_files_only = True
|
|
try:
|
|
model = WhisperModel(
|
|
str(model_size),
|
|
device=resolved_device,
|
|
compute_type=resolved_compute_type,
|
|
local_files_only=True,
|
|
)
|
|
except Exception as exc:
|
|
if not _is_local_whisper_cache_miss(exc):
|
|
raise
|
|
notes.append("Local Whisper cache miss; downloaded model from Hugging Face once.")
|
|
local_files_only = False
|
|
model = WhisperModel(
|
|
str(model_size),
|
|
device=resolved_device,
|
|
compute_type=resolved_compute_type,
|
|
local_files_only=False,
|
|
)
|
|
segments_iter, info = model.transcribe(
|
|
str(wav_path),
|
|
language=None if language == "auto" else str(language),
|
|
task=str(task),
|
|
beam_size=int(beam_size),
|
|
vad_filter=bool(vad_filter),
|
|
)
|
|
segments = [
|
|
{
|
|
"start": float(segment.start),
|
|
"end": float(segment.end),
|
|
"text": str(segment.text).strip(),
|
|
}
|
|
for segment in segments_iter
|
|
]
|
|
|
|
transcript = "\n".join(segment["text"] for segment in segments if segment["text"]).strip()
|
|
segments_json = json.dumps(segments, ensure_ascii=False, indent=2)
|
|
detected_language = getattr(info, "language", None)
|
|
detected_probability = getattr(info, "language_probability", None)
|
|
duration = getattr(info, "duration", None)
|
|
trace = "\n".join(
|
|
[
|
|
"[WHISPER]",
|
|
f"source={source_label}",
|
|
f"model_size={model_size}; device={resolved_device}; compute_type={resolved_compute_type}; local_files_only={local_files_only}; task={task}; vad_filter={bool(vad_filter)}",
|
|
f"language={detected_language}; language_probability={detected_probability}; duration={duration}",
|
|
f"segments={len(segments)}; transcript_chars={len(transcript)}",
|
|
*notes,
|
|
]
|
|
)
|
|
return transcript, segments_json, trace
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"ThinkingLLM_Whisper_ASR": ThinkingLLM_Whisper_ASR,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"ThinkingLLM_Whisper_ASR": "ThinkingLLM Whisper ASR",
|
|
}
|