code cleanup
This commit is contained in:
parent
9e89f31bc7
commit
8cdeb006b6
14 changed files with 277 additions and 275 deletions
71
studio/TESTING.md
Normal file
71
studio/TESTING.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Testing Matrix: Audio, Text & VLM
|
||||
|
||||
## Text Model Tests
|
||||
|
||||
| # | Test | Model | Steps | Expected |
|
||||
|---|------|-------|-------|----------|
|
||||
| 1 | **Text inference — basic chat** | `unsloth/Qwen3-4B` or any text model | Load model → send "Hello, how are you?" | Streaming text response, no errors |
|
||||
| 2 | **Text inference — system prompt** | Any text model | Set system prompt to "You are a pirate" → send "Tell me about the ocean" | Response in pirate persona |
|
||||
| 3 | **Text training — LoRA** | Any text model | Load model → pick Alpaca dataset → set max_steps=10 → start training | Training completes, checkpoint saved, loss decreases |
|
||||
| 4 | **Text compare mode** | Any text model with trained LoRA | Open compare view → send message | Both Base and LoRA columns respond, responses differ |
|
||||
|
||||
## VLM (Vision) Tests
|
||||
|
||||
| # | Test | Model | Steps | Expected |
|
||||
|---|------|-------|-------|----------|
|
||||
| 5 | **VLM inference — image description** | `unsloth/Llama-3.2-11B-Vision` or Gemma-3 vision | Load → attach image via paperclip → "What's in this image?" | Describes the image content accurately |
|
||||
| 6 | **VLM inference — text only (no image)** | Same VLM | Send text message without image | Normal text response (no crash) |
|
||||
| 7 | **VLM training — vision LoRA** | Any VLM | Load → pick image-text dataset → check finetune_vision_layers is ON → train max_steps=10 | Training completes with vision+language LoRA |
|
||||
| 8 | **VLM compare mode with image** | VLM with trained LoRA | Open compare view → upload image → send | Both panels describe the image, LoRA panel should differ |
|
||||
| 9 | **VLM dataset mapping** | Any VLM | Pick a dataset that needs manual column mapping | Mapping card shows correctly, VLM-specific labels appear |
|
||||
|
||||
## Audio TTS (Text-to-Speech) Tests
|
||||
|
||||
| # | Test | Model | Steps | Expected |
|
||||
|---|------|-------|-------|----------|
|
||||
| 10 | **TTS inference — SNAC/Orpheus** | `canopylabs/orpheus-3b-0.1-ft` | Load → send text message | AudioPlayer renders with playable WAV audio |
|
||||
| 11 | **TTS inference — SparkTTS (BiCodec)** | `SparkAudio/Spark-TTS-0.5B` | Load → send text "Hello world" | AudioPlayer with synthesized speech (not raw bicodec tokens) |
|
||||
| 12 | **TTS inference — OuteTTS (DAC)** | `OuteAI/Llama-OuteTTS-1.0-1B` | Load → send text | AudioPlayer with audio output |
|
||||
| 13 | **TTS inference — CSM/Sesame** | `sesame/csm-1b` | Load → send text | AudioPlayer with audio output |
|
||||
| 14 | **TTS training — LoRA** | Any TTS model (e.g., Orpheus) | Load → pick audio dataset → train max_steps=10 | Training completes, checkpoint saved |
|
||||
| 15 | **TTS compare mode** | TTS with LoRA adapter | Open compare → send text | Both panels show AudioPlayer, LoRA should sound different |
|
||||
| 16 | **SparkTTS LoRA inference** | SparkTTS with trained adapter | Load LoRA checkpoint → send text | Plays audio without 404 error (bicodec loads from local path) |
|
||||
|
||||
## Audio ASR (Speech-to-Text) Tests
|
||||
|
||||
| # | Test | Model | Steps | Expected |
|
||||
|---|------|-------|-------|----------|
|
||||
| 17 | **Whisper inference — audio transcription** | `unsloth/whisper-large-v3` | Load → upload audio via headphones button → send | Transcribed text appears (no "only accepts audio" error) |
|
||||
| 18 | **Whisper inference — no audio error** | `unsloth/whisper-large-v3` | Load → send text without audio | Clear error: "Whisper models require audio input" |
|
||||
| 19 | **Gemma 3n inference — audio ASR** | `unsloth/gemma-3n-E4B-it` | Load → upload audio → "Transcribe this audio" | Accurate transcription, uses greedy decoding |
|
||||
| 20 | **Gemma 3n inference — text only** | `unsloth/gemma-3n-E4B-it` | Load → send text without audio | Normal text response (model is also a text/vision model) |
|
||||
| 21 | **Gemma 3n inference — image** | `unsloth/gemma-3n-E4B-it` | Load → attach image → "What's in this image?" | Describes image (Gemma 3n supports vision too) |
|
||||
| 22 | **Gemma 3n training — audio dataset** | `unsloth/gemma-3n-E4B-it` | Load → pick speech dataset → train max_steps=10 | Training completes, audio mapping card shows "audio and text" |
|
||||
| 23 | **Audio chip in user message** | Any ASR model | Upload audio → send message | Audio filename chip appears in user message bubble |
|
||||
|
||||
## Cross-Cutting / Edge Case Tests
|
||||
|
||||
| # | Test | Model | Steps | Expected |
|
||||
|---|------|-------|-------|----------|
|
||||
| 24 | **Model switch — text to TTS** | Text model → TTS model | Load text model → chat → switch to TTS → chat | First gives text, second gives AudioPlayer — no leftover state |
|
||||
| 25 | **Model switch — TTS to ASR** | TTS → Whisper or Gemma 3n | Load TTS → generate audio → switch to ASR → upload audio | TTS gives audio, ASR gives text — clean transition |
|
||||
| 26 | **Model switch — VLM to text** | VLM → text model | Load VLM → send image → switch to text model → send text | No vision errors on text model, image attachment ignored |
|
||||
| 27 | **Abort mid-generation** | Any streaming model | Send message → click stop button mid-stream | Generation stops cleanly, partial response visible, no crash |
|
||||
| 28 | **Large audio file rejection** | Any ASR model | Try uploading audio > 50MB | Upload rejected (MAX_AUDIO_SIZE), no crash |
|
||||
| 29 | **No model loaded error** | No model | Open chat → send message | Toast: "No model loaded — Pick model in top bar, then retry" |
|
||||
| 30 | **Training then inference** | Any model | Train LoRA → load checkpoint → chat | Trained checkpoint responds (different from base) |
|
||||
|
||||
## Quick Smoke Test Order (prioritized)
|
||||
|
||||
If running a fast subset, do these 10 in order:
|
||||
|
||||
1. **#1** — Text basic chat (sanity check)
|
||||
2. **#5** — VLM image description
|
||||
3. **#10** — TTS audio generation (SNAC)
|
||||
4. **#19** — Gemma 3n audio ASR
|
||||
5. **#17** — Whisper transcription
|
||||
6. **#4** — Text compare mode
|
||||
7. **#3** — Text LoRA training
|
||||
8. **#23** — Audio chip in user message
|
||||
9. **#24** — Model switch text→TTS
|
||||
10. **#29** — No model loaded error
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
# Also applies to: unsloth/whisper-large-v3, openai/whisper-large-v3
|
||||
|
||||
audio_type: whisper
|
||||
audio_input: true
|
||||
|
||||
training:
|
||||
eval_steps: 5
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 59d896747aa0a6a207837e7da2d6921805eae684
|
||||
|
|
@ -118,22 +118,45 @@ class InferenceBackend:
|
|||
elif audio_type == "bicodec":
|
||||
import os
|
||||
from unsloth import FastModel
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
# Spark-TTS: download full repo, then load from /LLM subfolder
|
||||
hf_repo = config.path
|
||||
local_dir = hf_repo.split("/")[-1]
|
||||
repo_path = snapshot_download(hf_repo, local_dir=local_dir)
|
||||
abs_repo_path = os.path.abspath(repo_path)
|
||||
llm_path = os.path.join(abs_repo_path, "LLM")
|
||||
logger.info(f"Spark-TTS: downloaded repo to {repo_path}, loading LLM from {llm_path}")
|
||||
if config.is_lora and config.base_model:
|
||||
# LoRA adapter: load from local adapter path.
|
||||
# base_model is e.g. /home/.../Spark-TTS-0.5B/LLM
|
||||
# The BiCodec weights are in the parent dir (Spark-TTS-0.5B/).
|
||||
base_path = config.base_model
|
||||
if os.path.isdir(base_path):
|
||||
abs_repo_path = os.path.abspath(os.path.dirname(base_path))
|
||||
else:
|
||||
# base_model is an HF ID — download it
|
||||
from huggingface_hub import snapshot_download
|
||||
local_dir = base_path.split("/")[-1]
|
||||
repo_path = snapshot_download(base_path, local_dir=local_dir)
|
||||
abs_repo_path = os.path.abspath(repo_path)
|
||||
|
||||
logger.info(f"Spark-TTS LoRA: loading adapter from {config.path}, BiCodec from {abs_repo_path}")
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
config.path,
|
||||
dtype=torch.float32,
|
||||
load_in_4bit=False,
|
||||
token=hf_token if hf_token and hf_token.strip() else None,
|
||||
)
|
||||
else:
|
||||
# Base model: download full HF repo, then load from /LLM subfolder
|
||||
from huggingface_hub import snapshot_download
|
||||
hf_repo = config.path
|
||||
local_dir = hf_repo.split("/")[-1]
|
||||
repo_path = snapshot_download(hf_repo, local_dir=local_dir)
|
||||
abs_repo_path = os.path.abspath(repo_path)
|
||||
llm_path = os.path.join(abs_repo_path, "LLM")
|
||||
logger.info(f"Spark-TTS: downloaded repo to {repo_path}, loading LLM from {llm_path}")
|
||||
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
llm_path,
|
||||
dtype=torch.float32,
|
||||
load_in_4bit=False,
|
||||
token=hf_token if hf_token and hf_token.strip() else None,
|
||||
)
|
||||
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
llm_path,
|
||||
dtype=torch.float32,
|
||||
load_in_4bit=False,
|
||||
token=hf_token if hf_token and hf_token.strip() else None,
|
||||
)
|
||||
FastModel.for_inference(model)
|
||||
self.models[model_name]["model"] = model
|
||||
self.models[model_name]["tokenizer"] = tokenizer
|
||||
|
|
@ -150,6 +173,35 @@ class InferenceBackend:
|
|||
FastModel.for_inference(model)
|
||||
self.models[model_name]["model"] = model
|
||||
self.models[model_name]["tokenizer"] = tokenizer
|
||||
elif audio_type == "whisper":
|
||||
# Whisper ASR — uses FastModel with WhisperForConditionalGeneration
|
||||
from unsloth import FastModel
|
||||
from transformers import WhisperForConditionalGeneration
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
config.path,
|
||||
auto_model=WhisperForConditionalGeneration,
|
||||
whisper_language="English",
|
||||
whisper_task="transcribe",
|
||||
load_in_4bit=False,
|
||||
token=hf_token if hf_token and hf_token.strip() else None,
|
||||
)
|
||||
FastModel.for_inference(model)
|
||||
model.eval()
|
||||
|
||||
# Create ASR pipeline (per notebook)
|
||||
from transformers import pipeline as hf_pipeline
|
||||
whisper_pipe = hf_pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model,
|
||||
tokenizer=tokenizer.tokenizer,
|
||||
feature_extractor=tokenizer.feature_extractor,
|
||||
processor=tokenizer,
|
||||
return_language=True,
|
||||
torch_dtype=torch.float16,
|
||||
)
|
||||
self.models[model_name]["model"] = model
|
||||
self.models[model_name]["tokenizer"] = tokenizer
|
||||
self.models[model_name]["whisper_pipeline"] = whisper_pipe
|
||||
else:
|
||||
# SNAC (Orpheus) uses FastLanguageModel
|
||||
model, tokenizer = FastLanguageModel.from_pretrained(
|
||||
|
|
@ -162,9 +214,10 @@ class InferenceBackend:
|
|||
self.models[model_name]["model"] = model
|
||||
self.models[model_name]["tokenizer"] = tokenizer
|
||||
|
||||
# Load the external codec for this audio type
|
||||
model_repo_path = self.models[model_name].get("model_repo_path")
|
||||
self._audio_codec_manager.load_codec(audio_type, self.device, model_repo_path=model_repo_path)
|
||||
# Load the external codec for TTS audio types (Whisper is ASR, no codec needed)
|
||||
if audio_type != "whisper":
|
||||
model_repo_path = self.models[model_name].get("model_repo_path")
|
||||
self._audio_codec_manager.load_codec(audio_type, self.device, model_repo_path=model_repo_path)
|
||||
|
||||
self.active_model_name = model_name
|
||||
self.loading_models.discard(model_name)
|
||||
|
|
@ -259,9 +312,7 @@ class InferenceBackend:
|
|||
self.loading_models.discard(model_name)
|
||||
|
||||
raise Exception(error_msg)
|
||||
pass
|
||||
|
||||
# Add this new function
|
||||
def unload_model(self, model_name: str) -> bool:
|
||||
"""
|
||||
Completely removes a model from the registry and clears GPU memory.
|
||||
|
|
@ -295,7 +346,6 @@ class InferenceBackend:
|
|||
else:
|
||||
logger.warning(f"Attempted to unload model '{model_name}', but it was not found in the registry.")
|
||||
return True
|
||||
pass
|
||||
|
||||
def revert_to_base_model(self, base_model_name: str) -> bool:
|
||||
"""
|
||||
|
|
@ -331,151 +381,6 @@ class InferenceBackend:
|
|||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
def activate_lora_adapter(self, base_model_name: str, lora_path: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Activates a specific LoRA adapter on what is assumed to be a clean base model.
|
||||
Uses PeftModel.from_pretrained() which correctly wraps the base model.
|
||||
"""
|
||||
model = self.models[base_model_name].get("model")
|
||||
adapter_name_to_load = lora_path.split("/")[-1].replace(".", "_")
|
||||
|
||||
try:
|
||||
# Use PeftModel.from_pretrained to wrap the clean base model with the adapter.
|
||||
# This is the correct approach after model.unload() + del peft_config.
|
||||
logger.info(f"Loading LoRA adapter '{adapter_name_to_load}' from '{lora_path}'...")
|
||||
model = PeftModel.from_pretrained(model, lora_path, adapter_name=adapter_name_to_load)
|
||||
self.models[base_model_name]["model"] = model
|
||||
logger.info(f"LoRA adapter '{adapter_name_to_load}' activated successfully.")
|
||||
|
||||
return True, adapter_name_to_load
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to activate LoRA adapter '{adapter_name_to_load}': {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False, None
|
||||
|
||||
def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str = None) -> bool:
|
||||
"""
|
||||
Load a LoRA adapter onto the base model if it's not already registered.
|
||||
This method is idempotent.
|
||||
"""
|
||||
if base_model_name not in self.models:
|
||||
logger.error(f"Base model {base_model_name} not loaded")
|
||||
return False
|
||||
|
||||
model = self.models[base_model_name].get("model")
|
||||
if model is None:
|
||||
logger.error(f"Model object for {base_model_name} is None.")
|
||||
return False
|
||||
|
||||
if adapter_name is None:
|
||||
adapter_name = adapter_path.split("/")[-1].replace(".", "_")
|
||||
|
||||
# If we've loaded this adapter before, we don't need to do anything.
|
||||
if adapter_name in self.models[base_model_name].get("loaded_adapters", {}):
|
||||
logger.info(f"Adapter '{adapter_name}' is already registered. Skipping.")
|
||||
return True
|
||||
|
||||
try:
|
||||
logger.info(f"Loading new adapter '{adapter_name}' from '{adapter_path}' onto {base_model_name}")
|
||||
|
||||
# Unsloth modifies the model in-place and returns None. Do NOT re-assign.
|
||||
model.load_adapter(adapter_path, adapter_name=adapter_name)
|
||||
|
||||
# Update our internal registry so we don't load it again.
|
||||
self.models[base_model_name]["loaded_adapters"][adapter_name] = adapter_path
|
||||
|
||||
total_adapters = len(getattr(model, 'peft_config', {}))
|
||||
logger.info(f"Adapter '{adapter_name}' loaded successfully. (Total adapters on model: {total_adapters})")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load adapter '{adapter_name}': {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
pass
|
||||
|
||||
def enable_adapter(self, base_model_name: str, adapter_name: str) -> bool:
|
||||
"""Enable specific adapter (for generation)"""
|
||||
if base_model_name not in self.models:
|
||||
return False
|
||||
|
||||
model = self.models[base_model_name]["model"]
|
||||
|
||||
try:
|
||||
logger.info(f"Enabling adapter: {adapter_name}")
|
||||
model.set_adapter(adapter_name)
|
||||
self.models[base_model_name]["active_adapter"] = adapter_name
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enable adapter: {e}")
|
||||
return False
|
||||
|
||||
def disable_adapters(self, base_model_name: str) -> bool:
|
||||
"""Disable all adapters (back to pure base model)"""
|
||||
if base_model_name not in self.models:
|
||||
return False
|
||||
|
||||
model = self.models[base_model_name]["model"]
|
||||
|
||||
try:
|
||||
logger.info(f"Disabling all adapters on {base_model_name}")
|
||||
model.disable_adapters()
|
||||
self.models[base_model_name]["active_adapter"] = None
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to disable adapters: {e}")
|
||||
return False
|
||||
|
||||
# In backend/inference.py
|
||||
|
||||
def load_for_eval(self, lora_path: str, max_seq_length: int = 2048,
|
||||
dtype = None, load_in_4bit: bool = True,
|
||||
hf_token: Optional[str] = None) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Prepare for eval: ensure base model and the specified adapter are loaded.
|
||||
"""
|
||||
try:
|
||||
from utils.models import ModelConfig
|
||||
lora_config = ModelConfig.from_lora_path(lora_path, hf_token)
|
||||
if not lora_config:
|
||||
return False, None, None
|
||||
|
||||
base_model_name = lora_config.base_model
|
||||
|
||||
# 1. Load the base model if it's not already in memory (this logic is correct)
|
||||
if base_model_name not in self.models or not self.models[base_model_name].get("model"):
|
||||
logger.info(f"Base model '{base_model_name}' not loaded, loading now.")
|
||||
base_config = ModelConfig.from_ui_selection(base_model_name, None, is_lora=False)
|
||||
if not self.load_model(base_config, max_seq_length, dtype, load_in_4bit, hf_token):
|
||||
return False, None, None
|
||||
else:
|
||||
logger.info(f"Base model '{base_model_name}' is already in memory.")
|
||||
|
||||
self.active_model_name = base_model_name
|
||||
|
||||
# 2. Delegate to our now-idempotent load_adapter function.
|
||||
# It will handle all cases: first adapter, or subsequent adapters.
|
||||
adapter_name = lora_path.split("/")[-1].replace(".", "_")
|
||||
adapter_success = self.load_adapter(
|
||||
base_model_name=base_model_name,
|
||||
adapter_path=lora_path,
|
||||
adapter_name=adapter_name
|
||||
)
|
||||
|
||||
if not adapter_success:
|
||||
return False, base_model_name, None
|
||||
|
||||
return True, base_model_name, adapter_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during load_for_eval: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False, None, None
|
||||
pass
|
||||
|
||||
|
||||
def load_for_eval(self, lora_path: str, max_seq_length: int = 2048,
|
||||
dtype = None, load_in_4bit: bool = True,
|
||||
hf_token: Optional[str] = None) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
|
|
@ -522,7 +427,6 @@ class InferenceBackend:
|
|||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False, None, None
|
||||
pass
|
||||
|
||||
def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str) -> bool:
|
||||
"""
|
||||
|
|
@ -550,7 +454,6 @@ class InferenceBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Failed to load adapter '{adapter_name}': {e}")
|
||||
return False
|
||||
pass
|
||||
|
||||
def set_active_adapter(self, base_model_name: str, adapter_name: str) -> bool:
|
||||
"""
|
||||
|
|
@ -566,7 +469,6 @@ class InferenceBackend:
|
|||
# This will catch the "adapter not found" error if something goes wrong.
|
||||
logger.error(f"Failed to set active adapter to '{adapter_name}': {e}")
|
||||
return False
|
||||
pass
|
||||
|
||||
def _apply_adapter_state(self, use_adapter: Optional[Union[bool, str]]) -> None:
|
||||
"""
|
||||
|
|
@ -885,7 +787,6 @@ class InferenceBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Vision generation error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
pass
|
||||
|
||||
def generate_audio_input_response(self, messages, system_prompt, audio_array,
|
||||
temperature, top_p, top_k, min_p,
|
||||
|
|
@ -903,25 +804,29 @@ class InferenceBackend:
|
|||
processor = model_info.get("processor") or model_info.get("tokenizer")
|
||||
raw_tokenizer = getattr(processor, "tokenizer", processor)
|
||||
|
||||
# Extract last user text
|
||||
user_text = "Transcribe this audio."
|
||||
# Extract last user text — default matches notebook prompt
|
||||
user_text = "Please transcribe this audio."
|
||||
if messages:
|
||||
for msg in reversed(messages):
|
||||
if msg["role"] == "user" and msg.get("content"):
|
||||
user_text = msg["content"]
|
||||
break
|
||||
|
||||
# Use ASR-specific system prompt if user hasn't set a custom one
|
||||
if not system_prompt or system_prompt == "You are a helpful AI assistant.":
|
||||
system_prompt = "You are an assistant that transcribes speech accurately."
|
||||
|
||||
# Build messages in Gemma 3n format — audio goes INTO apply_chat_template
|
||||
audio_messages = []
|
||||
if system_prompt:
|
||||
audio_messages.append({"role": "system", "content": [{"type": "text", "text": system_prompt}]})
|
||||
audio_messages.append({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio", "audio": audio_array},
|
||||
{"type": "text", "text": user_text},
|
||||
],
|
||||
})
|
||||
audio_messages = [
|
||||
{"role": "system", "content": [{"type": "text", "text": system_prompt}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio", "audio": audio_array},
|
||||
{"type": "text", "text": user_text},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# apply_chat_template handles audio embedding + tokenization in one step
|
||||
inputs = processor.apply_chat_template(
|
||||
|
|
@ -943,16 +848,13 @@ class InferenceBackend:
|
|||
timeout=0.2,
|
||||
)
|
||||
|
||||
# Notebook uses do_sample=False for ASR (greedy decoding for accuracy)
|
||||
generation_kwargs = dict(
|
||||
**inputs,
|
||||
streamer=streamer,
|
||||
max_new_tokens=max_new_tokens,
|
||||
use_cache=True,
|
||||
do_sample=temperature > 0,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
min_p=min_p,
|
||||
do_sample=False,
|
||||
)
|
||||
|
||||
err: dict[str, str] = {}
|
||||
|
|
@ -1003,6 +905,28 @@ class InferenceBackend:
|
|||
logger.error(f"Audio input generation error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
|
||||
def generate_whisper_response(self, audio_array, cancel_event=None) -> Generator[str, None, None]:
|
||||
"""Whisper ASR — takes audio numpy array, yields transcribed text.
|
||||
|
||||
Uses the pre-built transformers pipeline (created during model loading).
|
||||
"""
|
||||
model_info = self.models[self.active_model_name]
|
||||
whisper_pipe = model_info.get("whisper_pipeline")
|
||||
if not whisper_pipe:
|
||||
yield "Error: Whisper pipeline not initialized"
|
||||
return
|
||||
|
||||
try:
|
||||
with self._generation_lock:
|
||||
result = whisper_pipe({"raw": audio_array, "sampling_rate": 16000})
|
||||
|
||||
text = result.get("text", "") if isinstance(result, dict) else str(result)
|
||||
if text:
|
||||
yield text
|
||||
except Exception as e:
|
||||
logger.error(f"Whisper ASR error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
|
||||
def generate_stream(self,
|
||||
prompt: str,
|
||||
temperature: float = 0.7,
|
||||
|
|
@ -1124,9 +1048,6 @@ class InferenceBackend:
|
|||
logger.error(f"Error during generation: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
|
||||
# ... other helper methods (format_chat_prompt, _clean_generated_text, etc.)
|
||||
pass
|
||||
|
||||
# ── Audio (TTS) Generation ────────────────────────────────────
|
||||
|
||||
def generate_audio_response(
|
||||
|
|
@ -1507,7 +1428,6 @@ class InferenceBackend:
|
|||
logger.debug(f"Reset generation state for model: {model_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fully reset model state for {model_name}: {e}")
|
||||
pass
|
||||
|
||||
def reset_generation_state(self):
|
||||
"""Reset any cached generation state to prevent hanging after errors"""
|
||||
|
|
@ -1673,57 +1593,6 @@ class InferenceBackend:
|
|||
return False
|
||||
|
||||
|
||||
|
||||
def load_model_simple(self,
|
||||
model_path: str,
|
||||
hf_token: Optional[str] = None,
|
||||
max_seq_length: int = 2048,
|
||||
load_in_4bit: bool = True) -> bool:
|
||||
"""
|
||||
Simple model loading wrapper for chat interface.
|
||||
Accepts model path as string and handles ModelConfig creation internally.
|
||||
|
||||
Args:
|
||||
model_path: Model name or path (e.g., "unsloth/llama-3-8b")
|
||||
hf_token: HuggingFace token for gated models
|
||||
max_seq_length: Maximum sequence length
|
||||
load_in_4bit: Whether to use 4-bit quantization
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
from backend.model_config import ModelConfig
|
||||
|
||||
logger.info(f"load_model_simple called with: {model_path}")
|
||||
|
||||
# Create config from string path
|
||||
config = ModelConfig.from_ui_selection(
|
||||
model_path,
|
||||
lora_path=None, # No LoRA for chat
|
||||
is_lora=False
|
||||
)
|
||||
|
||||
logger.info(f"Created ModelConfig with identifier: {config.identifier}")
|
||||
|
||||
# Call existing load_model with config
|
||||
return self.load_model(
|
||||
config=config,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=None, # Auto-detect
|
||||
load_in_4bit=load_in_4bit,
|
||||
hf_token=hf_token
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in load_model_simple: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Global inference backend instance
|
||||
inference_backend = InferenceBackend()
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 59d896747aa0a6a207837e7da2d6921805eae684
|
||||
|
|
@ -1048,7 +1048,7 @@ class UnslothTrainer:
|
|||
return formatted
|
||||
|
||||
self._update_progress(status_message="Formatting audio VLM dataset...")
|
||||
dataset = dataset.map(format_messages, batched=True, batch_size=4, num_proc=4)
|
||||
dataset = dataset.map(format_messages, batched=True, batch_size=4, num_proc=safe_num_proc(4))
|
||||
print(f"Audio VLM dataset formatted: {len(dataset)} examples\n")
|
||||
return dataset
|
||||
|
||||
|
|
|
|||
|
|
@ -569,10 +569,18 @@ async def openai_chat_completions(
|
|||
model_name = backend.active_model_name or payload.model
|
||||
|
||||
# ── Audio TTS path: auto-route to audio generation ────
|
||||
# (Whisper is ASR not TTS — handled below in audio input path)
|
||||
model_info = backend.models.get(backend.active_model_name, {})
|
||||
if model_info.get("is_audio"):
|
||||
if model_info.get("is_audio") and model_info.get("audio_type") != "whisper":
|
||||
return await generate_audio(payload, request)
|
||||
|
||||
# ── Whisper without audio: return clear error ──
|
||||
if model_info.get("audio_type") == "whisper" and not payload.audio_base64:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Whisper models require audio input. Please upload an audio file.",
|
||||
)
|
||||
|
||||
# ── Audio INPUT path: decode WAV and route to audio input generation ──
|
||||
if payload.audio_base64 and model_info.get("has_audio_input"):
|
||||
audio_array = _decode_audio_base64(payload.audio_base64)
|
||||
|
|
@ -582,6 +590,11 @@ async def openai_chat_completions(
|
|||
created = int(time.time())
|
||||
|
||||
def audio_input_generate():
|
||||
if model_info.get("audio_type") == "whisper":
|
||||
return backend.generate_whisper_response(
|
||||
audio_array=audio_array,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
return backend.generate_audio_input_response(
|
||||
messages=chat_messages,
|
||||
system_prompt=system_prompt,
|
||||
|
|
|
|||
|
|
@ -423,13 +423,14 @@ def safe_num_proc(desired: Optional[int] = None) -> int:
|
|||
"""
|
||||
Return a safe ``num_proc`` for ``dataset.map()`` calls.
|
||||
|
||||
Fork-based multiprocessing deadlocks when CUDA has already been
|
||||
initialized (e.g. after inference). This helper detects that case
|
||||
and forces ``num_proc=1``.
|
||||
|
||||
On multi-GPU machines the NVIDIA driver spawns extra background threads,
|
||||
making ``os.fork()`` prone to deadlocks when many workers are created.
|
||||
This helper caps ``num_proc`` to 4 on such machines.
|
||||
|
||||
On single-GPU (or CPU-only) machines the original value is returned
|
||||
unchanged.
|
||||
|
||||
Args:
|
||||
desired: The num_proc you *want*. If None, auto-computes from
|
||||
``os.cpu_count()``.
|
||||
|
|
@ -442,6 +443,14 @@ def safe_num_proc(desired: Optional[int] = None) -> int:
|
|||
if desired is None or not isinstance(desired, int):
|
||||
desired = max(1, os.cpu_count() // 3)
|
||||
|
||||
# After inference, CUDA is initialized — forking will deadlock.
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_initialized():
|
||||
return 1
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if get_physical_gpu_count() > 1:
|
||||
capped = min(4, desired)
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -443,7 +443,7 @@ def is_audio_model(model_name: str) -> Optional[str]:
|
|||
try:
|
||||
defaults = load_model_defaults(model_name)
|
||||
audio_type = defaults.get('audio_type')
|
||||
if audio_type and isinstance(audio_type, str) and audio_type in ('snac', 'csm', 'bicodec', 'dac'):
|
||||
if audio_type and isinstance(audio_type, str) and audio_type in ('snac', 'csm', 'bicodec', 'dac', 'whisper'):
|
||||
logger.info(f"Model {model_name} detected as audio model: audio_type={audio_type}")
|
||||
return audio_type
|
||||
return None
|
||||
|
|
@ -914,6 +914,23 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
|||
logger.info(f"Loaded model defaults from {config_path} (via mapping)")
|
||||
return config
|
||||
|
||||
# If model_name is a local path (e.g. /home/.../Spark-TTS-0.5B/LLM from
|
||||
# adapter_config.json), try matching the last 1-2 path components against
|
||||
# the registry (e.g. "Spark-TTS-0.5B/LLM").
|
||||
if model_name not in _REVERSE_MODEL_MAPPING and (model_name.startswith("/") or model_name.startswith(".")):
|
||||
parts = Path(model_name).parts
|
||||
for depth in [2, 1]:
|
||||
if len(parts) >= depth:
|
||||
suffix = "/".join(parts[-depth:])
|
||||
if suffix in _REVERSE_MODEL_MAPPING:
|
||||
canonical_file = _REVERSE_MODEL_MAPPING[suffix]
|
||||
for config_path in defaults_dir.rglob(canonical_file):
|
||||
if config_path.is_file():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
logger.info(f"Loaded model defaults from {config_path} (via path suffix '{suffix}')")
|
||||
return config
|
||||
|
||||
# Try exact model name match (for backward compatibility)
|
||||
model_filename = model_name.replace("/", "_") + ".yaml"
|
||||
# Search in subfolders and root
|
||||
|
|
@ -1163,6 +1180,12 @@ class ModelConfig:
|
|||
|
||||
display_name = Path(path).name if is_local else identifier.split("/")[-1]
|
||||
|
||||
# Audio models are never vision models (e.g. WhisperForConditionalGeneration
|
||||
# and CsmForConditionalGeneration match the ForConditionalGeneration suffix
|
||||
# but are not VLMs).
|
||||
if audio_type_val is not None:
|
||||
vision = False
|
||||
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
|||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
|
||||
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -165,22 +167,6 @@ const ComposerAnimated: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AUDIO_ACCEPT = "audio/wav,audio/mpeg,audio/webm,audio/ogg,audio/flac,audio/mp4";
|
||||
const MAX_AUDIO_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(",");
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const PendingAudioChip: FC = () => {
|
||||
const audioName = useChatRuntimeStore((s) => s.pendingAudioName);
|
||||
const clearPendingAudio = useChatRuntimeStore((s) => s.clearPendingAudio);
|
||||
|
|
@ -438,6 +424,19 @@ const AssistantActionBar: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const UserMessageAudio: FC = () => {
|
||||
const audioName = useAuiState(({ message }) => sentAudioNames.get(message.id));
|
||||
if (!audioName) return null;
|
||||
return (
|
||||
<div className="col-start-2 flex justify-end">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs">
|
||||
<HeadphonesIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="max-w-48 truncate">{audioName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
|
|
@ -445,6 +444,7 @@ const UserMessage: FC = () => {
|
|||
data-role="user"
|
||||
>
|
||||
<UserMessageAttachments />
|
||||
<UserMessageAudio />
|
||||
|
||||
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
|
||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import {
|
|||
type RunMessages = Parameters<ChatModelAdapter["run"]>[0]["messages"];
|
||||
type RunMessage = RunMessages[number];
|
||||
|
||||
/** Tracks which user messages were sent with an audio file (messageId → filename). */
|
||||
export const sentAudioNames = new Map<string, string>();
|
||||
|
||||
function collectTextParts(message: RunMessage): string[] {
|
||||
const textParts = message.content
|
||||
.filter((part) => part.type === "text")
|
||||
|
|
@ -157,6 +160,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
const audioBase64 = findLatestUserAudioBase64(messages);
|
||||
// Clear pending audio from store after extracting (consumed on send)
|
||||
if (audioBase64) {
|
||||
const audioName = runtime.pendingAudioName;
|
||||
if (audioName) {
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
if (lastUserMsg) sentAudioNames.set(lastUserMsg.id, audioName);
|
||||
}
|
||||
runtime.clearPendingAudio();
|
||||
}
|
||||
const useAdapter = await resolveUseAdapter(unstable_threadId);
|
||||
|
|
@ -165,7 +173,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
const activeModel = runtime.models.find(
|
||||
(m) => m.id === params.checkpoint,
|
||||
);
|
||||
if (activeModel?.isAudio) {
|
||||
if (activeModel?.isAudio && !activeModel?.hasAudioInput) {
|
||||
const threadKey = unstable_threadId || "__default";
|
||||
runtime.setThreadRunning(threadKey, true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
|
||||
import { useAui } from "@assistant-ui/react";
|
||||
import { ArrowUpIcon, HeadphonesIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
|
|
@ -29,8 +30,6 @@ export interface CompareHandle {
|
|||
|
||||
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
const AUDIO_ACCEPT = "audio/wav,audio/mpeg,audio/webm,audio/ogg,audio/flac,audio/mp4";
|
||||
const MAX_AUDIO_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
function fileToBase64DataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -220,15 +219,10 @@ export function SharedComposer({
|
|||
if (!file) continue;
|
||||
// Handle audio files
|
||||
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(",");
|
||||
const base64 = commaIndex >= 0 ? result.slice(commaIndex + 1) : result;
|
||||
fileToBase64(file).then((base64) => {
|
||||
setPendingAudio({ name: file.name, base64 });
|
||||
setPendingAudioStore(base64, file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Handle image files
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export function DatasetMappingCard({
|
|||
mappingOk,
|
||||
autoDetected = false,
|
||||
isVlm = false,
|
||||
isAudio = false,
|
||||
format,
|
||||
}: {
|
||||
mapping: Record<string, string>;
|
||||
|
|
|
|||
15
studio/frontend/src/lib/audio-utils.ts
Normal file
15
studio/frontend/src/lib/audio-utils.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export const AUDIO_ACCEPT = "audio/wav,audio/mpeg,audio/webm,audio/ogg,audio/flac,audio/mp4";
|
||||
export const MAX_AUDIO_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(",");
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue