774 lines
28 KiB
Python
774 lines
28 KiB
Python
"""
|
|
Segment-level Parameter System for Per-Segment TTS Parameter Control
|
|
|
|
Allows parameters like seed, temperature, cfg, etc. to be set inline in text/SRT
|
|
using pipe-separated syntax: [Alice|seed:42|temperature:0.5]
|
|
|
|
Parameters override node defaults for a single segment, then revert after.
|
|
"""
|
|
|
|
from typing import Dict, List, Tuple, Optional, Any
|
|
import logging
|
|
import re
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
INDEX_TTS_EMOTIONS = (
|
|
'happy', 'angry', 'sad', 'afraid', 'disgusted',
|
|
'melancholic', 'surprised', 'calm'
|
|
)
|
|
|
|
|
|
# Parameter aliases: user_input -> canonical_name
|
|
PARAMETER_ALIASES = {
|
|
'seed': 'seed',
|
|
'temp': 'temperature',
|
|
'temperature': 'temperature',
|
|
'cfg': 'cfg',
|
|
'cfg_weight': 'cfg', # cfg_weight alias -> cfg (more universal)
|
|
'cfgweight': 'cfg',
|
|
'cfg_scale': 'cfg',
|
|
'stg': 'stg_scale',
|
|
'stg_scale': 'stg_scale',
|
|
'duration_multiplier': 'duration_multiplier',
|
|
'dur_mult': 'duration_multiplier',
|
|
'duration_factor': 'duration_factor',
|
|
'dur_factor': 'duration_factor',
|
|
'gen_duration': 'gen_duration',
|
|
'generation_duration': 'gen_duration',
|
|
'ref_duration': 'ref_duration',
|
|
'reference_duration': 'ref_duration',
|
|
'rescale': 'rescale_scale',
|
|
'rescale_scale': 'rescale_scale',
|
|
'prompt_template': 'prompt_template',
|
|
'template': 'prompt_template',
|
|
'num_steps': 'num_steps',
|
|
'num_step': 'num_steps',
|
|
'guidance_scale': 'guidance_scale',
|
|
'guidance': 'guidance_scale',
|
|
'duration': 'duration',
|
|
't_shift': 't_shift',
|
|
'layer_penalty_factor': 'layer_penalty_factor',
|
|
'position_temperature': 'position_temperature',
|
|
'class_temperature': 'class_temperature',
|
|
'audio_chunk_duration': 'audio_chunk_duration',
|
|
'audio_chunk_threshold': 'audio_chunk_threshold',
|
|
'speaker_scale': 'speaker_scale',
|
|
'speaker_guidance': 'speaker_scale',
|
|
'cfg_text': 'cfg_scale_text',
|
|
'cfg_scale_text': 'cfg_scale_text',
|
|
'cfg_speaker': 'cfg_scale_speaker',
|
|
'cfg_scale_speaker': 'cfg_scale_speaker',
|
|
'cfg_min_t': 'cfg_min_t',
|
|
'cfg_max_t': 'cfg_max_t',
|
|
'truncation': 'truncation_factor',
|
|
'truncation_factor': 'truncation_factor',
|
|
'rescale_k': 'rescale_k',
|
|
'rescale_sigma': 'rescale_sigma',
|
|
'speaker_kv_scale': 'speaker_kv_scale',
|
|
'speaker_kv_max_layers': 'speaker_kv_max_layers',
|
|
'speaker_kv_min_t': 'speaker_kv_min_t',
|
|
'sequence_length': 'sequence_length',
|
|
'seq_len': 'sequence_length',
|
|
'exaggeration': 'exaggeration',
|
|
'exag': 'exaggeration',
|
|
'speed': 'speed',
|
|
'top_p': 'top_p',
|
|
'top_k': 'top_k',
|
|
'topk': 'top_k',
|
|
'topp': 'top_p',
|
|
'audio_temp': 'audio_temperature',
|
|
'audio_temperature': 'audio_temperature',
|
|
'audio_top_p': 'audio_top_p',
|
|
'audio_top_k': 'audio_top_k',
|
|
'rep_penalty': 'repetition_penalty',
|
|
'repetition_penalty': 'repetition_penalty',
|
|
'audio_rep_penalty': 'audio_repetition_penalty',
|
|
'audio_repetition_penalty': 'audio_repetition_penalty',
|
|
'tokens': 'duration_tokens',
|
|
'duration_tokens': 'duration_tokens',
|
|
'max_new_tokens': 'max_new_tokens',
|
|
'max_generate_length': 'max_generate_length',
|
|
'max_audio_patches': 'max_generate_length',
|
|
'n_vq': 'n_vq_for_inference',
|
|
'n_vq_for_inference': 'n_vq_for_inference',
|
|
'instruction': 'instruction',
|
|
'quality': 'quality',
|
|
'sound_event': 'sound_event',
|
|
'ambient_sound': 'ambient_sound',
|
|
'inference_steps': 'inference_steps',
|
|
'steps': 'inference_steps',
|
|
'sigma_shift': 'sigma_shift',
|
|
'negative_prompt': 'negative_prompt',
|
|
'negative': 'negative_prompt',
|
|
'neg': 'negative_prompt',
|
|
'duration_seconds': 'duration_seconds',
|
|
'seconds': 'duration_seconds',
|
|
'emotion_alpha': 'emotion_alpha',
|
|
'vector': 'emotion_vector_inline',
|
|
'emotion': 'emotion_text_inline',
|
|
**{name: f'emotion_{name}_inline' for name in INDEX_TTS_EMOTIONS},
|
|
# [emotion:...] is reserved for IndexTTS text emotion. Character audio
|
|
# references remain unambiguous because their key is the character name.
|
|
}
|
|
|
|
# Parameter compatibility matrix: canonical_name -> set of engine_types
|
|
PARAMETER_ENGINES = {
|
|
'seed': {
|
|
'chatterbox', 'chatterbox_official_23lang', 'f5tts', 'higgs_audio',
|
|
'higgs_audio_v3', 'vibevoice', 'index_tts', 'step_audio_editx', 'cosyvoice', 'qwen3_tts',
|
|
'dots_tts', 'fish_audio_s2', 'omnivoice',
|
|
'echo_tts', 'moss_tts', 'moss_soundeffect_v2', 'dramabox', 'audio_cpp'
|
|
},
|
|
'temperature': {
|
|
'chatterbox', 'chatterbox_official_23lang', 'f5tts', 'higgs_audio',
|
|
'higgs_audio_v3', 'vibevoice', 'index_tts', 'step_audio_editx', 'qwen3_tts', 'moss_tts', 'fish_audio_s2', 'audio_cpp'
|
|
},
|
|
'cfg': {
|
|
'f5tts', 'vibevoice', 'index_tts', 'chatterbox', 'chatterbox_official_23lang',
|
|
'moss_soundeffect_v2', 'dramabox'
|
|
},
|
|
'stg_scale': {
|
|
'dramabox'
|
|
},
|
|
'duration_multiplier': {
|
|
'dramabox'
|
|
},
|
|
'duration_factor': {
|
|
'index_tts'
|
|
},
|
|
'gen_duration': {
|
|
'dramabox'
|
|
},
|
|
'ref_duration': {
|
|
'dramabox'
|
|
},
|
|
'rescale_scale': {
|
|
'dramabox'
|
|
},
|
|
'prompt_template': {
|
|
'dramabox'
|
|
},
|
|
'num_steps': {
|
|
'echo_tts', 'dots_tts', 'omnivoice', 'audio_cpp'
|
|
},
|
|
'guidance_scale': {
|
|
'dots_tts', 'omnivoice', 'audio_cpp'
|
|
},
|
|
'duration': {
|
|
'omnivoice'
|
|
},
|
|
't_shift': {
|
|
'omnivoice'
|
|
},
|
|
'layer_penalty_factor': {
|
|
'omnivoice'
|
|
},
|
|
'position_temperature': {
|
|
'omnivoice'
|
|
},
|
|
'class_temperature': {
|
|
'omnivoice'
|
|
},
|
|
'audio_chunk_duration': {
|
|
'omnivoice'
|
|
},
|
|
'audio_chunk_threshold': {
|
|
'omnivoice'
|
|
},
|
|
'speaker_scale': {
|
|
'dots_tts'
|
|
},
|
|
'cfg_scale_text': {
|
|
'echo_tts'
|
|
},
|
|
'cfg_scale_speaker': {
|
|
'echo_tts'
|
|
},
|
|
'cfg_min_t': {
|
|
'echo_tts'
|
|
},
|
|
'cfg_max_t': {
|
|
'echo_tts'
|
|
},
|
|
'truncation_factor': {
|
|
'echo_tts'
|
|
},
|
|
'rescale_k': {
|
|
'echo_tts'
|
|
},
|
|
'rescale_sigma': {
|
|
'echo_tts'
|
|
},
|
|
'speaker_kv_scale': {
|
|
'echo_tts'
|
|
},
|
|
'speaker_kv_max_layers': {
|
|
'echo_tts'
|
|
},
|
|
'speaker_kv_min_t': {
|
|
'echo_tts'
|
|
},
|
|
'sequence_length': {
|
|
'echo_tts'
|
|
},
|
|
'exaggeration': {
|
|
'chatterbox', 'chatterbox_official_23lang'
|
|
},
|
|
'speed': {
|
|
'f5tts', 'cosyvoice', 'omnivoice', 'audio_cpp'
|
|
},
|
|
'top_p': {
|
|
'higgs_audio', 'higgs_audio_v3', 'vibevoice', 'index_tts', 'qwen3_tts', 'moss_tts', 'fish_audio_s2', 'audio_cpp'
|
|
},
|
|
'top_k': {
|
|
'higgs_audio', 'higgs_audio_v3', 'index_tts', 'qwen3_tts', 'moss_tts', 'audio_cpp'
|
|
},
|
|
'audio_temperature': {
|
|
'moss_tts'
|
|
},
|
|
'audio_top_p': {
|
|
'moss_tts'
|
|
},
|
|
'audio_top_k': {
|
|
'moss_tts'
|
|
},
|
|
'repetition_penalty': {
|
|
'moss_tts', 'fish_audio_s2', 'audio_cpp'
|
|
},
|
|
'audio_repetition_penalty': {
|
|
'moss_tts'
|
|
},
|
|
'duration_tokens': {
|
|
'moss_tts'
|
|
},
|
|
'max_new_tokens': {
|
|
'higgs_audio_v3', 'moss_tts', 'fish_audio_s2', 'audio_cpp'
|
|
},
|
|
'max_generate_length': {
|
|
'dots_tts'
|
|
},
|
|
'n_vq_for_inference': {
|
|
'moss_tts'
|
|
},
|
|
'instruction': {
|
|
'moss_tts', 'audio_cpp'
|
|
},
|
|
'quality': {
|
|
'moss_tts'
|
|
},
|
|
'sound_event': {
|
|
'moss_tts'
|
|
},
|
|
'ambient_sound': {
|
|
'moss_tts'
|
|
},
|
|
'inference_steps': {
|
|
'vibevoice', 'moss_soundeffect_v2'
|
|
},
|
|
'sigma_shift': {
|
|
'moss_soundeffect_v2'
|
|
},
|
|
'negative_prompt': {
|
|
'moss_soundeffect_v2', 'dramabox'
|
|
},
|
|
'duration_seconds': {
|
|
'moss_tts', 'moss_soundeffect_v2'
|
|
},
|
|
'emotion_alpha': {
|
|
'index_tts'
|
|
},
|
|
'emotion_vector_inline': {'index_tts'},
|
|
'emotion_text_inline': {'index_tts'},
|
|
**{f'emotion_{name}_inline': {'index_tts'} for name in INDEX_TTS_EMOTIONS},
|
|
}
|
|
|
|
# Parameter type validation: canonical_name -> (type, min, max, description)
|
|
PARAMETER_VALIDATION = {
|
|
'seed': (int, 0, 2**32 - 1, "Random seed for reproducible generation"),
|
|
'temperature': (float, 0.1, 2.0, "Randomness/creativity control (lower=more deterministic)"),
|
|
'cfg': (float, 0.0, 20.0, "Classifier-free guidance strength"),
|
|
'stg_scale': (float, 0.0, 5.0, "DramaBox skip-token guidance strength"),
|
|
'duration_multiplier': (float, 0.5, 3.0, "DramaBox estimated-duration multiplier"),
|
|
'duration_factor': (float, 0.5, 2.0, "IndexTTS-2.5 internal feature-duration scaling"),
|
|
'gen_duration': (float, 0.0, 60.0, "DramaBox explicit output duration"),
|
|
'ref_duration': (float, 3.0, 30.0, "DramaBox reference-audio duration"),
|
|
'rescale_scale': (str, None, None, "DramaBox CFG rescale: auto or 0 to 1"),
|
|
'prompt_template': (str, None, None, "DramaBox per-segment prompt template using {seg}"),
|
|
'num_steps': (int, 1, 200, "Number of inference steps"),
|
|
'guidance_scale': (float, 0.0, 10.0, "Classifier-free guidance scale"),
|
|
'duration': (float, 0.0, 600.0, "Fixed output duration in seconds"),
|
|
't_shift': (float, 0.0, 1.0, "Noise schedule time-step shift"),
|
|
'layer_penalty_factor': (float, 0.0, 10.0, "Penalty encouraging lower codebook layers first"),
|
|
'position_temperature': (float, 0.0, 10.0, "Mask-position sampling temperature"),
|
|
'class_temperature': (float, 0.0, 2.0, "Token sampling temperature"),
|
|
'audio_chunk_duration': (float, 1.0, 60.0, "Native long-form chunk target duration in seconds"),
|
|
'audio_chunk_threshold': (float, 1.0, 180.0, "Native long-form chunking activation threshold in seconds"),
|
|
'speaker_scale': (float, 0.0, 5.0, "Dots TTS speaker conditioning scale"),
|
|
'cfg_scale_text': (float, 0.0, 20.0, "CFG scale for text guidance (Echo-TTS)"),
|
|
'cfg_scale_speaker': (float, 0.0, 20.0, "CFG scale for speaker guidance (Echo-TTS)"),
|
|
'cfg_min_t': (float, 0.0, 1.0, "CFG minimum t value (Echo-TTS)"),
|
|
'cfg_max_t': (float, 0.0, 1.0, "CFG maximum t value (Echo-TTS)"),
|
|
'truncation_factor': (float, 0.0, 2.0, "Truncation factor (Echo-TTS)"),
|
|
'rescale_k': (float, 0.0, 5.0, "Rescale k (Echo-TTS)"),
|
|
'rescale_sigma': (float, 0.0, 10.0, "Rescale sigma (Echo-TTS)"),
|
|
'speaker_kv_scale': (float, 0.0, 10.0, "Speaker KV scale (Echo-TTS)"),
|
|
'speaker_kv_max_layers': (int, 0, 100, "Speaker KV max layers (Echo-TTS)"),
|
|
'speaker_kv_min_t': (float, 0.0, 1.0, "Speaker KV min t (Echo-TTS)"),
|
|
'sequence_length': (int, 1, 2048, "Sequence length / block size (Echo-TTS)"),
|
|
'exaggeration': (float, 0.0, 2.0, "Voice emotion exaggeration (ChatterBox only)"),
|
|
'speed': (float, 0.25, 3.0, "Speech speed multiplier"),
|
|
'top_p': (float, 0.0, 1.0, "Nucleus sampling probability"),
|
|
'top_k': (int, 1, 100, "Top-k sampling"),
|
|
'audio_temperature': (float, 0.1, 2.5, "MOSS-TTS audio sampling temperature"),
|
|
'audio_top_p': (float, 0.0, 1.0, "MOSS-TTS audio nucleus sampling probability"),
|
|
'audio_top_k': (int, 1, 200, "MOSS-TTS audio top-k sampling"),
|
|
'repetition_penalty': (float, 0.5, 3.0, "Audio repetition penalty"),
|
|
'audio_repetition_penalty': (float, 0.5, 3.0, "MOSS-TTS audio repetition penalty"),
|
|
'duration_tokens': (int, 0, 8192, "MOSS-TTS duration hint in audio tokens"),
|
|
'max_new_tokens': (int, 64, 16384, "Maximum generated tokens"),
|
|
'max_generate_length': (int, 32, 1024, "Dots TTS maximum audio patch budget"),
|
|
'n_vq_for_inference': (int, 0, 32, "MOSS-TTS Local Transformer RVQ layers for inference"),
|
|
'instruction': (str, None, None, "MOSS-TTS whole-segment instruction"),
|
|
'quality': (str, None, None, "MOSS-TTS whole-segment quality hint"),
|
|
'sound_event': (str, None, None, "MOSS-TTS whole-segment sound event hint"),
|
|
'ambient_sound': (str, None, None, "MOSS-TTS whole-segment ambient sound hint"),
|
|
'inference_steps': (int, 1, 150, "Number of inference steps"),
|
|
'sigma_shift': (float, 0.0, 10.0, "Flow-matching schedule shift"),
|
|
'negative_prompt': (str, None, None, "Sounds or qualities to discourage"),
|
|
'duration_seconds': (float, 0.5, 300.0, "Sound-effect segment duration in seconds"),
|
|
'emotion_alpha': (float, 0.0, 1.0, "Emotion strength (IndexTTS-2 only)"),
|
|
# Keep inline emotion values as strings so an explicit leading sign remains
|
|
# available to distinguish deltas from absolute replacements.
|
|
'emotion_vector_inline': (str, None, None, "Ordered IndexTTS-2 emotion vector"),
|
|
'emotion_text_inline': (str, None, None, "IndexTTS-2 emotion preset or quoted text"),
|
|
**{f'emotion_{name}_inline': (str, None, None, f"IndexTTS-2 {name} value")
|
|
for name in INDEX_TTS_EMOTIONS},
|
|
}
|
|
|
|
# Mapping of canonical parameter names to node config keys (handles engine-specific naming)
|
|
# When applying to config, use these keys
|
|
PARAMETER_NODE_KEYS = {
|
|
'seed': 'seed',
|
|
'temperature': 'temperature',
|
|
'cfg': {
|
|
'default': 'cfg_weight',
|
|
'f5tts': 'cfg_strength',
|
|
'moss_soundeffect_v2': 'cfg_scale',
|
|
'dramabox': 'cfg_scale',
|
|
}, # Engine-specific mapping
|
|
'stg_scale': 'stg_scale',
|
|
'duration_multiplier': 'duration_multiplier',
|
|
'duration_factor': 'duration_factor',
|
|
'gen_duration': 'gen_duration',
|
|
'ref_duration': 'ref_duration',
|
|
'rescale_scale': 'rescale_scale',
|
|
'prompt_template': 'prompt_template',
|
|
'num_steps': {
|
|
'default': 'num_steps',
|
|
'audio_cpp': 'num_inference_steps',
|
|
},
|
|
'guidance_scale': 'guidance_scale',
|
|
'duration': 'duration',
|
|
't_shift': 't_shift',
|
|
'layer_penalty_factor': 'layer_penalty_factor',
|
|
'position_temperature': 'position_temperature',
|
|
'class_temperature': 'class_temperature',
|
|
'audio_chunk_duration': 'audio_chunk_duration',
|
|
'audio_chunk_threshold': 'audio_chunk_threshold',
|
|
'speaker_scale': 'speaker_scale',
|
|
'cfg_scale_text': 'cfg_scale_text',
|
|
'cfg_scale_speaker': 'cfg_scale_speaker',
|
|
'cfg_min_t': 'cfg_min_t',
|
|
'cfg_max_t': 'cfg_max_t',
|
|
'truncation_factor': 'truncation_factor',
|
|
'rescale_k': 'rescale_k',
|
|
'rescale_sigma': 'rescale_sigma',
|
|
'speaker_kv_scale': 'speaker_kv_scale',
|
|
'speaker_kv_max_layers': 'speaker_kv_max_layers',
|
|
'speaker_kv_min_t': 'speaker_kv_min_t',
|
|
'sequence_length': 'sequence_length',
|
|
'exaggeration': 'exaggeration',
|
|
'speed': {
|
|
'default': 'speed',
|
|
'audio_cpp': 'speaking_rate',
|
|
},
|
|
'top_p': 'top_p',
|
|
'top_k': 'top_k',
|
|
'audio_temperature': 'audio_temperature',
|
|
'audio_top_p': 'audio_top_p',
|
|
'audio_top_k': 'audio_top_k',
|
|
'repetition_penalty': 'repetition_penalty',
|
|
'audio_repetition_penalty': 'audio_repetition_penalty',
|
|
'duration_tokens': 'duration_tokens',
|
|
'max_new_tokens': {
|
|
'default': 'max_new_tokens',
|
|
'audio_cpp': 'max_tokens',
|
|
},
|
|
'max_generate_length': 'max_generate_length',
|
|
'n_vq_for_inference': 'n_vq_for_inference',
|
|
'instruction': {
|
|
'default': 'instruction',
|
|
'audio_cpp': 'instruct',
|
|
},
|
|
'quality': 'quality',
|
|
'sound_event': 'sound_event',
|
|
'ambient_sound': 'ambient_sound',
|
|
'inference_steps': 'inference_steps',
|
|
'sigma_shift': 'sigma_shift',
|
|
'negative_prompt': 'negative_prompt',
|
|
'duration_seconds': 'duration_seconds',
|
|
'emotion_alpha': 'emotion_alpha',
|
|
'emotion_vector_inline': 'emotion_vector_inline',
|
|
'emotion_text_inline': 'emotion_text_inline',
|
|
**{f'emotion_{name}_inline': f'emotion_{name}_inline' for name in INDEX_TTS_EMOTIONS},
|
|
}
|
|
|
|
|
|
class ParameterValidator:
|
|
"""Validates and filters parameters based on engine type and value constraints."""
|
|
|
|
@staticmethod
|
|
def normalize_parameter_name(param_name: str) -> Optional[str]:
|
|
"""
|
|
Normalize parameter name (lowercase) and resolve aliases.
|
|
|
|
Args:
|
|
param_name: Raw parameter name from user input
|
|
|
|
Returns:
|
|
Canonical parameter name, or None if unknown
|
|
"""
|
|
normalized = param_name.strip().lower()
|
|
return PARAMETER_ALIASES.get(normalized)
|
|
|
|
@staticmethod
|
|
def is_parameter_supported(param_name: str, engine_type: str) -> bool:
|
|
"""
|
|
Check if a parameter is supported by the given engine.
|
|
|
|
Args:
|
|
param_name: Canonical parameter name (already normalized)
|
|
engine_type: Engine type (e.g., 'chatterbox', 'f5tts')
|
|
|
|
Returns:
|
|
True if parameter is supported, False otherwise
|
|
"""
|
|
if param_name not in PARAMETER_ENGINES:
|
|
return False
|
|
return engine_type in PARAMETER_ENGINES[param_name]
|
|
|
|
@staticmethod
|
|
def validate_parameter_value(param_name: str, value: Any) -> Tuple[bool, Optional[str], Any]:
|
|
"""
|
|
Validate and convert parameter value.
|
|
|
|
Args:
|
|
param_name: Canonical parameter name (already normalized)
|
|
value: Raw value from segment tag
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message, converted_value)
|
|
"""
|
|
if param_name not in PARAMETER_VALIDATION:
|
|
return False, f"Unknown parameter: {param_name}", value
|
|
|
|
expected_type, min_val, max_val, description = PARAMETER_VALIDATION[param_name]
|
|
|
|
try:
|
|
# Try to convert to expected type
|
|
if expected_type == int:
|
|
converted = int(float(value)) # Handle "42.0" -> 42
|
|
elif expected_type == float:
|
|
converted = float(value)
|
|
elif expected_type == str:
|
|
converted = str(value).strip()
|
|
if not converted:
|
|
return False, f"{param_name} must be a non-empty string", value
|
|
else:
|
|
converted = value
|
|
|
|
if param_name == 'rescale_scale':
|
|
normalized = str(converted).strip().lower()
|
|
if normalized == 'auto':
|
|
converted = 'auto'
|
|
else:
|
|
converted = float(normalized)
|
|
if not 0.0 <= converted <= 1.0:
|
|
return False, "rescale_scale must be 'auto' or between 0 and 1", value
|
|
|
|
# Check bounds
|
|
if min_val is not None and max_val is not None and (converted < min_val or converted > max_val):
|
|
return False, f"{param_name} must be between {min_val} and {max_val}, got {converted}", value
|
|
|
|
return True, None, converted
|
|
|
|
except (ValueError, TypeError) as e:
|
|
return False, f"{param_name} must be {expected_type.__name__}, got {type(value).__name__}", value
|
|
|
|
@staticmethod
|
|
def filter_parameters_for_engine(
|
|
parameters: Dict[str, Any],
|
|
engine_type: str,
|
|
warn: bool = True
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Filter parameters to only those supported by the given engine.
|
|
Optionally warns about unsupported parameters.
|
|
|
|
Args:
|
|
parameters: Dictionary of parameters
|
|
engine_type: Engine type
|
|
warn: Whether to log warnings for unsupported parameters
|
|
|
|
Returns:
|
|
Filtered dictionary with only supported parameters
|
|
"""
|
|
filtered = {}
|
|
|
|
for param_name, value in parameters.items():
|
|
if ParameterValidator.is_parameter_supported(param_name, engine_type):
|
|
filtered[param_name] = value
|
|
elif warn:
|
|
logger.warning(
|
|
f"⚠️ Parameter '{param_name}' not supported by {engine_type} engine, ignoring"
|
|
)
|
|
|
|
return filtered
|
|
|
|
|
|
class SegmentParameterParser:
|
|
"""Parses and extracts parameters from pipe-separated tags like [Alice|seed:42|temperature:0.5]"""
|
|
|
|
@staticmethod
|
|
def parse_tag_segments(tag_content: str) -> Tuple[List[str], Dict[str, Any]]:
|
|
"""
|
|
Parse a character tag into segments (character/language info) and parameters.
|
|
|
|
Examples:
|
|
"[Alice]" -> (["Alice"], {})
|
|
"[Alice|seed:42]" -> (["Alice"], {"seed": 42})
|
|
"[fr:Alice|seed:42]" -> (["fr:Alice"], {"seed": 42})
|
|
"[seed:42|fr:Alice]" -> (["fr:Alice"], {"seed": 42}) # Order-independent
|
|
"[Alice|SEED:42|TEMPERATURE:0.5]" -> (["Alice"], {"seed": 42, "temperature": 0.5}) # Case-insensitive
|
|
|
|
Args:
|
|
tag_content: Content inside brackets (e.g., "Alice|seed:42|temperature:0.5")
|
|
|
|
Returns:
|
|
Tuple of (tag_segments, parameters_dict)
|
|
- tag_segments: List of non-parameter parts (language:character format)
|
|
- parameters_dict: Dictionary of extracted parameters (canonical names, lowercase)
|
|
"""
|
|
if not tag_content or not isinstance(tag_content, str):
|
|
return [], {}
|
|
|
|
# Split by pipe character
|
|
parts = tag_content.split('|')
|
|
tag_segments = []
|
|
parameters = {}
|
|
|
|
for part in parts:
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
|
|
# Check if this part is a parameter (contains :)
|
|
if ':' in part:
|
|
# Could be parameter or language:character
|
|
# Parameters are like "seed:42", "temperature:0.5"
|
|
# Language:character is like "fr:Alice", "en:Bob"
|
|
key, value = part.split(':', 1)
|
|
key_stripped = key.strip()
|
|
value = value.strip()
|
|
|
|
# Try to normalize parameter name (handles aliases and case-insensitivity)
|
|
canonical_name = ParameterValidator.normalize_parameter_name(key_stripped)
|
|
|
|
# A character may legitimately be named after an emotion. Only a
|
|
# numeric value turns [sad:...] into an inline vector control;
|
|
# [sad:audio_ref] must remain a character emotion-reference tag.
|
|
if key_stripped.lower() in INDEX_TTS_EMOTIONS and not re.fullmatch(
|
|
r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)', value
|
|
):
|
|
canonical_name = None
|
|
|
|
if canonical_name:
|
|
# It's a parameter - validate and add
|
|
is_valid, error_msg, converted = ParameterValidator.validate_parameter_value(canonical_name, value)
|
|
if is_valid:
|
|
parameters[canonical_name] = converted
|
|
else:
|
|
logger.warning(f"⚠️ Invalid parameter: {error_msg}")
|
|
else:
|
|
# It's likely language:character, keep as segment
|
|
tag_segments.append(part)
|
|
else:
|
|
# No colon, could be just character name
|
|
tag_segments.append(part)
|
|
|
|
return tag_segments, parameters
|
|
|
|
@staticmethod
|
|
def reconstruct_character_tag(tag_segments: List[str]) -> str:
|
|
"""
|
|
Reconstruct a character tag from segments (without parameters).
|
|
|
|
Args:
|
|
tag_segments: List of non-parameter parts
|
|
|
|
Returns:
|
|
Reconstructed tag content (e.g., "Alice" or "fr:Alice")
|
|
"""
|
|
if not tag_segments:
|
|
return ""
|
|
|
|
# Join all non-parameter segments with |
|
|
# But typically there's only one segment (the character/language part)
|
|
# Multiple segments shouldn't happen with proper parsing
|
|
return tag_segments[0] if len(tag_segments) == 1 else "|".join(tag_segments)
|
|
|
|
|
|
class SegmentParameterCollector:
|
|
"""Collects parameters from a text segment's character tags."""
|
|
|
|
@staticmethod
|
|
def extract_parameters_from_text(text: str) -> Tuple[str, Dict[str, Any], str]:
|
|
"""
|
|
Extract parameters from a text segment's character tags.
|
|
|
|
Example:
|
|
"[Alice|seed:42] Hi there"
|
|
-> ("Hi there", {"seed": 42}, "Alice")
|
|
|
|
Args:
|
|
text: Text segment potentially with character tags
|
|
|
|
Returns:
|
|
Tuple of (cleaned_text, parameters, character_name)
|
|
- cleaned_text: Text with tags removed
|
|
- parameters: Extracted parameters
|
|
- character_name: Extracted character name (or empty string if none)
|
|
"""
|
|
import re
|
|
|
|
# Match all character tags like [content]
|
|
# Using same pattern as character_parser but we'll extract parameters
|
|
tag_pattern = re.compile(r'\[(?!(?:pause|wait|stop|Pause|Wait|Stop|PAUSE|WAIT|STOP):)(?!(?:it|IT|italian|Italian)\])([^\]]+)\]')
|
|
|
|
parameters = {}
|
|
character_name = ""
|
|
|
|
def replace_tag(match):
|
|
nonlocal parameters, character_name
|
|
|
|
tag_content = match.group(1)
|
|
tag_segments, tag_params = SegmentParameterParser.parse_tag_segments(tag_content)
|
|
|
|
# Extract parameters
|
|
parameters.update(tag_params)
|
|
|
|
# Reconstruct character tag (without parameters)
|
|
if tag_segments:
|
|
character_name = tag_segments[0] # Usually just one segment
|
|
|
|
# Return empty string to remove the tag from text
|
|
return ""
|
|
|
|
# Replace all tags and collect parameters
|
|
cleaned_text = tag_pattern.sub(replace_tag, text).strip()
|
|
|
|
return cleaned_text, parameters, character_name
|
|
|
|
|
|
def parse_parameter_segments(text: str) -> List[Tuple[str, Dict[str, Any]]]:
|
|
"""Split text on parameter-only tags without applying character semantics."""
|
|
tag_pattern = re.compile(r'\[([^\]]+)\]')
|
|
segments: List[Tuple[str, Dict[str, Any]]] = []
|
|
|
|
for line in str(text or "").splitlines():
|
|
cursor = 0
|
|
current_parameters: Dict[str, Any] = {}
|
|
for match in tag_pattern.finditer(line):
|
|
tag_segments, tag_parameters = SegmentParameterParser.parse_tag_segments(match.group(1))
|
|
if not tag_parameters or tag_segments:
|
|
continue
|
|
before_tag = line[cursor:match.start()].strip()
|
|
if before_tag:
|
|
segments.append((before_tag, current_parameters.copy()))
|
|
current_parameters = tag_parameters.copy()
|
|
cursor = match.end()
|
|
|
|
remaining = line[cursor:].strip()
|
|
if remaining:
|
|
segments.append((remaining, current_parameters.copy()))
|
|
|
|
return segments
|
|
|
|
|
|
def parse_segment_text(text: str) -> Tuple[str, Dict[str, Any], Optional[str]]:
|
|
"""
|
|
Convenience function to parse segment text with parameters.
|
|
|
|
Args:
|
|
text: Text segment potentially with [Character|param:value] tags
|
|
|
|
Returns:
|
|
Tuple of (cleaned_text, parameters, character_name)
|
|
"""
|
|
return SegmentParameterCollector.extract_parameters_from_text(text)
|
|
|
|
|
|
def filter_parameters(parameters: Dict[str, Any], engine_type: str) -> Dict[str, Any]:
|
|
"""
|
|
Convenience function to filter parameters for an engine.
|
|
|
|
Args:
|
|
parameters: Dictionary of parameters
|
|
engine_type: Engine type
|
|
|
|
Returns:
|
|
Filtered parameters supported by the engine
|
|
"""
|
|
return ParameterValidator.filter_parameters_for_engine(parameters, engine_type)
|
|
|
|
|
|
def apply_segment_parameters(
|
|
base_config: Dict[str, Any],
|
|
segment_params: Dict[str, Any],
|
|
engine_type: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Apply segment-level parameters to override base config for a single segment.
|
|
|
|
Args:
|
|
base_config: Base node configuration
|
|
segment_params: Segment-level parameters to apply
|
|
engine_type: Engine type
|
|
|
|
Returns:
|
|
New config dict with segment parameters applied (doesn't modify original)
|
|
"""
|
|
# Create a copy to avoid modifying original
|
|
config = base_config.copy()
|
|
|
|
# Filter parameters for this engine
|
|
filtered_params = ParameterValidator.filter_parameters_for_engine(segment_params, engine_type)
|
|
|
|
# Apply each parameter
|
|
for param_name, value in filtered_params.items():
|
|
# Map parameter name to actual config key (handles engine-specific naming)
|
|
param_mapping = PARAMETER_NODE_KEYS.get(param_name, param_name)
|
|
|
|
# Check if mapping is engine-specific (dict) or generic (string)
|
|
if isinstance(param_mapping, dict):
|
|
# Engine-specific mapping: use engine-specific key if available, else use 'default'
|
|
config_key = param_mapping.get(engine_type, param_mapping.get('default', param_name))
|
|
else:
|
|
# Generic mapping: use the same key for all engines
|
|
config_key = param_mapping
|
|
|
|
config[config_key] = value
|
|
|
|
return config
|