import math import os import random import re import numpy as np import torch from tokenizers import AddedToken from transformers import PreTrainedTokenizerFast # to avoid: "The current process just got forked, after parallelism has already been used" os.environ["TOKENIZERS_PARALLELISM"] = "False" def detect_chorus_sections(text_aligned): """Detect chorus sections from aligned lyrics based on section tags in brackets. Args: text_aligned: List of [start_time, end_time, text] tuples Returns: List of (start_time, end_time) tuples for detected chorus sections """ if not text_aligned: return [] chorus_sections = [] current_chorus_start = None for start_time, end_time, text in text_aligned: # Convert text to lowercase for case-insensitive matching text_lower = text.lower().strip() # Check if line contains [chorus] or similar chorus indicators if "[chorus]" in text_lower: if current_chorus_start is None: current_chorus_start = start_time # Check for section end markers or new sections elif "[" in text_lower: if current_chorus_start is not None: # End the current chorus section chorus_sections.append((current_chorus_start, start_time)) current_chorus_start = None # Handle case where song ends during chorus if current_chorus_start is not None and text_aligned: last_end_time = text_aligned[-1][1] chorus_sections.append((current_chorus_start, last_end_time)) return chorus_sections def get_chorus_section_offset(data_meta): """Get the start time of the first chorus section from aligned lyrics. Args: data_meta: Metadata dictionary containing text_aligned Returns: float or None: Start time in seconds of first chorus section, or None if no chorus found """ if not data_meta.get("text_aligned"): return None chorus_sections = detect_chorus_sections(data_meta["text_aligned"]) if chorus_sections: return max(0, chorus_sections[0][0] - 2) # Return start time of first chorus, 2 seconds before return None def calculate_activity_percentage(loudness_array, threshold=0.01): """Calculate percentage of time that loudness exceeds threshold. Args: loudness_array: Array of loudness values threshold: Minimum loudness to consider "active" Returns: float: Percentage of active time (0-1) """ if loudness_array is None or len(loudness_array) == 0: return 0.0 active_frames = np.sum(loudness_array > threshold) return active_frames / len(loudness_array) def generate_span_activity_tags(loudness_seq, num_spans=None, rate_hz=25): """Generate span-based activity tags from loudness data. Args: loudness_seq: Array of loudness values at target rate (e.g., 25Hz) num_spans: Number of spans to generate (default: random 1-3) rate_hz: Feature rate in Hz (default 25), used to determine minimum duration Returns: list: Activity tags in format "activity[start:end]:percentage%" """ # Minimum 2 seconds of audio required min_frames = int(2 * rate_hz) if loudness_seq is None or len(loudness_seq) <= min_frames: return [] if num_spans is None: num_spans = random.randint(1, 3) tags = [] loudness_length = len(loudness_seq) for _ in range(num_spans): # Generate random span within the loudness array # Minimum span: 0.4 seconds (10 frames at 25Hz) min_span_frames = max(1, int(0.4 * rate_hz)) span_length = random.randint(min_span_frames, loudness_length // 2) start_frame = random.randint(0, max(0, loudness_length - span_length)) end_frame = start_frame + span_length # Ensure valid bounds end_frame = min(end_frame, loudness_length) # Calculate activity for this span span_loudness = loudness_seq[start_frame:end_frame] if len(span_loudness) > 0: span_activity_pct = calculate_activity_percentage(span_loudness) * 100 rounded_span_activity = round(span_activity_pct / 10) * 10 tags.append(f"activity[{start_frame}:{end_frame}]:{int(rounded_span_activity)}%") return tags def generate_activity_tags(loudness_seq, rate_hz=25): """Generate all activity-related tags from loudness data. Args: loudness_seq: Array of loudness values at target rate (e.g., 25Hz) rate_hz: Feature rate in Hz (default 25), used to determine minimum duration Returns: list: All activity tags (basic + threshold + span-based) """ if loudness_seq is None: return [] tags = [] # Calculate overall activity percentage activity_percentage = calculate_activity_percentage(loudness_seq) # Basic activity tag rounded_activity = round(activity_percentage * 100 / 10) * 10 tags.append(f"activity:{int(rounded_activity)}%") # Threshold-based activity tag activity_pct = activity_percentage * 100 random_threshold = random.uniform(0, activity_pct) rounded_threshold = round(random_threshold / 10) * 10 tags.append(f"activity>{int(rounded_threshold)}%") # Span-based activity tags span_tags = generate_span_activity_tags(loudness_seq, rate_hz=rate_hz) tags.extend(span_tags) return tags def calculate_spectral_metric_percentage(array, threshold): """Calculate percentage of frames where metric exceeds threshold. Similar to calculate_activity_percentage but for generic spectral metrics. Args: array: Array of metric values threshold: Minimum value to consider "above threshold" Returns: float: Percentage of frames above threshold (0-1) """ if array is None or len(array) == 0: return 0.0 above_threshold = np.sum(array > threshold) return above_threshold / len(array) def generate_loudness_contour_tags(loudness_normalized, is_warped=False): """Generate single full-length span tag with ALL frame values for loudness contour. Example output: loudness_contour:[45,52,68,71,...] (normal) loudness_warped_contour:[45,52,68,71,...] (when warped) Args: loudness_normalized: Array of normalized loudness values [0,1] is_warped: If True, use "_warped_contour" suffix Returns: list: Single-element list with full-length value tag in 0-100 format """ if loudness_normalized is None or len(loudness_normalized) == 0: return [] # Scale normalized [0,1] values to integer [0,100] range scaled_values = [int(round(v * 100)) for v in loudness_normalized] values_str = ",".join([str(v) for v in scaled_values]) tag_name = "loudness_warped_contour" if is_warped else "loudness_contour" return [f"{tag_name}:[{values_str}]"] def generate_spectral_centroid_span_with_values(centroid_normalized, is_warped=False): """Generate single full-length span tag with ALL frame values for spectral centroid. Example output: spectral_centroid_contour:[53,57,60,58,62,...] (normal) spectral_centroid_warped_contour:[53,57,60,58,62,...] (when warped) Args: centroid_normalized: Array of normalized spectral centroid values [0,1] is_warped: If True, use "_warped_contour" suffix Returns: list: Single-element list with full-length value tag in 0-100 format """ if centroid_normalized is None or len(centroid_normalized) == 0: return [] # Scale normalized [0,1] values to integer [0,100] range scaled_values = [int(round(v * 100)) for v in centroid_normalized] values_str = ",".join([str(v) for v in scaled_values]) tag_name = "spectral_centroid_warped_contour" if is_warped else "spectral_centroid_contour" return [f"{tag_name}:[{values_str}]"] def generate_spectral_complexity_span_with_values(complexity_normalized, is_warped=False): """Generate single full-length span tag with ALL frame values for spectral complexity. Example output: spectral_complexity_contour:[68,71,73,70,75,...] (normal) spectral_complexity_warped_contour:[68,71,73,70,75,...] (when warped) Args: complexity_normalized: Array of normalized spectral complexity values [0,1] is_warped: If True, use "_warped_contour" suffix Returns: list: Single-element list with full-length value tag in 0-100 format """ if complexity_normalized is None or len(complexity_normalized) == 0: return [] # Scale normalized [0,1] values to integer [0,100] range scaled_values = [int(round(v * 100)) for v in complexity_normalized] values_str = ",".join([str(v) for v in scaled_values]) tag_name = "spectral_complexity_warped_contour" if is_warped else "spectral_complexity_contour" return [f"{tag_name}:[{values_str}]"] def generate_spectral_centroid_tags(centroid_normalized, is_warped=False): """Generate spectral centroid tag from normalized centroid data. Args: centroid_normalized: Array of normalized spectral centroid values [0,1] is_warped: If True, use "_warped_contour" suffix Returns: list: Single centroid value list tag in 0-100 format """ if centroid_normalized is None or len(centroid_normalized) == 0: return [] return generate_spectral_centroid_span_with_values(centroid_normalized, is_warped=is_warped) def generate_spectral_complexity_tags(complexity_normalized, is_warped=False): """Generate spectral complexity tag from normalized complexity data. Args: complexity_normalized: Array of normalized spectral complexity values [0,1] is_warped: If True, use "_warped_contour" suffix Returns: list: Single complexity value list tag in 0-100 format """ if complexity_normalized is None or len(complexity_normalized) == 0: return [] return generate_spectral_complexity_span_with_values(complexity_normalized, is_warped=is_warped) global tokenizer g_tokenizer = None MAX_TAG_LEN = 256 # characters, not tokens MAX_TOT_TAGS_LEN = 1024 # characters, not tokens MAX_N_TAGS = 10 # to avoid overfitting to an artist def load_tokenizer(tokenizer_fp=None): global g_tokenizer if g_tokenizer is not None: return g_tokenizer assert os.path.exists(tokenizer_fp) g_tokenizer = PreTrainedTokenizerFast( tokenizer_file=tokenizer_fp, unk_token="[UNK]", pad_token="[PAD]", clean_up_tokenization_spaces=True, ) g_tokenizer.add_special_tokens({"additional_special_tokens": [AddedToken("\n")]}) return g_tokenizer def _space_repl(m): s = m.group() n_newline = s.count("\n") if n_newline >= 2: return "\n\n" elif n_newline == 1: return "\n" return " " def _simplify_whitespace(text, retain_newlines=True): """simplify while respecting up to 2 newlines""" if retain_newlines: text = re.sub(r"\s+", _space_repl, text).strip() else: text = re.sub(r"\s+", " ", text).strip() return text def tokenize_batch( text_list, max_tokens=None, pad_token_id=0, retain_newlines=True, tokenizer_fp=None, ): tokenizer = load_tokenizer(tokenizer_fp) text_list = [_simplify_whitespace(s, retain_newlines=retain_newlines) for s in text_list] text_enc = tokenizer( text_list, add_special_tokens=False, truncation=max_tokens is not None, max_length=max_tokens, padding="longest", return_tensors="pt", )["input_ids"].type(torch.long) text_enc[text_enc == tokenizer.pad_token_id] = pad_token_id return text_enc CASE_AUGMENT_FUNCS = [ str.upper, str.lower, str.capitalize, str.title, ] def _augment_tag(s): # case augment if random.random() >= 0.8: s = random.choice(CASE_AUGMENT_FUNCS)(s) # other misc formatting if random.random() >= 0.5: s = s.replace("-", " ").strip() return s def get_control_tags( sample_duration_s, sample_duration_toks, sample_offset_toks=0, sample_vocal_start_s=None, hook_only=False, start_from_chorus=False, do_augment=True, max_possible_duration_s: int = 60 * 40, critical_control_tags: list[str] | None = None, semantic_rate_hz: int = 25, audio_sample_start_times_s=None, # Audio sample timings for conditioning (list) audio_sample_sources=None, # Source types for each audio sample ("vocal", "drum", "full_mix", etc.) loudness_25hz=None, # Loudness at 25Hz for activity tag calculations loudness_seq=None, # Loudness contour with randomized rate/smoothing/warping spectral_centroid_seq=None, # Spectral centroid sequence with randomized rate/smoothing/warping spectral_complexity_seq=None, # Spectral complexity sequence with randomized rate/smoothing/warping contour_rate_hz: int | None = None, # Rate for loudness_seq and spectral contour features (randomized 0.2-1Hz, for debugging) contour_is_warped: bool = False, # Whether time-warping was applied to contour features vocal_pitch_hz_min=None, # Minimum vocal pitch in Hz for pitch range control tags vocal_pitch_hz_max=None, # Maximum vocal pitch in Hz for pitch range control tags ): control_tags = [] control_tags.append(f"duration:{int(round(sample_duration_s))}") control_tags.append(f"duration_toks:{sample_duration_toks}") increment_s = 10 min_durations = [ dur for dur in range(0, max_possible_duration_s, increment_s) if dur <= sample_duration_s ] max_durations = [ dur for dur in range(0, max_possible_duration_s, increment_s) if dur >= sample_duration_s ] if len(min_durations) > 0: control_tags.append(f"min_duration:{int(random.choice(min_durations))}") if len(max_durations) > 0: control_tags.append(f"max_duration:{int(random.choice(max_durations))}") if sample_vocal_start_s is not None: if sample_vocal_start_s <= 5: control_tags.append("vocals:early") if sample_vocal_start_s <= 15: control_tags.append("vocals:normal") if 10 <= sample_vocal_start_s <= 20: control_tags.append("vocals:intro") if critical_control_tags is None: critical_control_tags = [] if sample_offset_toks > 0: critical_control_tags.append(f"start_offset:{sample_offset_toks}") if hook_only: critical_control_tags.append("hook_offset:random") if start_from_chorus: critical_control_tags.append("start_from_chorus") # Add audio sample timings and source tags if available if audio_sample_start_times_s is not None and len(audio_sample_start_times_s) > 0: for i, start_time in enumerate(audio_sample_start_times_s): start_time_s = int(round(start_time)) start_time_toks = int(round(start_time * semantic_rate_hz)) # Always use zero-based indexing for consistency control_tags.append(f"audio_sample_time_{i}:{start_time_s}") control_tags.append(f"audio_sample_start_toks_{i}:{start_time_toks}") # Add source type tag if available if audio_sample_sources is not None and i < len(audio_sample_sources): source_type = audio_sample_sources[i] control_tags.append(f"audio_sample_{source_type}_{i}") # Add activity-based tags from loudness_25hz data (precise alignment at 25Hz) activity_tags = generate_activity_tags(loudness_25hz, rate_hz=semantic_rate_hz) control_tags.extend(activity_tags) # Add loudness contour tags (with randomized rate/smoothing/warping) loudness_contour_tags = generate_loudness_contour_tags(loudness_seq, is_warped=contour_is_warped) control_tags.extend(loudness_contour_tags) # Add spectral centroid tags (brightness control) centroid_tags = generate_spectral_centroid_tags(spectral_centroid_seq, is_warped=contour_is_warped) control_tags.extend(centroid_tags) # Add spectral complexity tags (harmonic richness control) complexity_tags = generate_spectral_complexity_tags( spectral_complexity_seq, is_warped=contour_is_warped ) control_tags.extend(complexity_tags) # Add vocal pitch range control tags if available if vocal_pitch_hz_min is not None: control_tags.append(f"vocal_pitch_hz_min:{int(round(vocal_pitch_hz_min))}") if vocal_pitch_hz_max is not None: control_tags.append(f"vocal_pitch_hz_max:{int(round(vocal_pitch_hz_max))}") if do_augment: if random.random() >= 0.5: random.shuffle(control_tags) control_tags = control_tags[: random.randint(0, len(control_tags))] if critical_control_tags is not None: control_tags += critical_control_tags random.shuffle(control_tags) if len(control_tags) == 0: return None return "{" + ";".join(control_tags) + "}" def augment_tags(tags): random.shuffle(tags) if random.random() <= 0.5: tags = tags[: random.randint(0, len(tags))] tags = [_augment_tag(tag) for tag in tags] return tags def clean_tags(tags): return [ clean_tag[:MAX_TAG_LEN] for tag in tags if len(clean_tag := _simplify_whitespace(tag, retain_newlines=False)) > 0 ] def clean_inline_tags(m): tags = m.group(2).split(";") tags = clean_tags(tags) ts = ";".join(tags)[:MAX_TOT_TAGS_LEN] if len(ts) > 0: return f"[{m.group(1)}: {ts}]" return f"[{m.group(1)}]" def augment_inline_tags(m): tags = m.group(2).split(";") tags = augment_tags(tags) merge_char = random.choice([", ", " ", "; ", ",", ";", ". "]) ts = merge_char.join(tags) if len(ts) > 0: return f"[{m.group(1)}: {ts}]" return f"[{m.group(1)}]" def randomize_lyrics(lyrics, max_ngram=5): # remove some basics and split into words lyrics = lyrics.lower() lyrics = re.sub(r"\[.*?\]", " ", lyrics) lyrics = re.sub(r"[\s+\,\;\(\)]", " ", lyrics).strip() words = lyrics.split() # Generate fragments with variable length, biased toward single words n_grams = random.randint(1, max_ngram) fragments = [ " ".join(words[n * n_grams : (n + 1) * n_grams]) for n in range(math.ceil(len(words) / n_grams)) ] # dedupe and shuffle fragments = list(set(fragments)) random.shuffle(fragments) return "; ".join(fragments) def format_timestamped_lyrics(text_lines, augment_probability=0.4): """ Format lyrics with timestamps based on text_lines data. Examples: # all_timestamps strategy: "[0.5-2.1] Verse one starts here" "[2.1-4.3] And continues with more lyrics" # start_end_only strategy: "[0.5] Verse one starts here" "And continues with more lyrics" "Final line here [4.3]" # random_dropout strategy: "[0.5] Verse one starts here" "And continues with more lyrics" "[3.2-4.3] Final line here" # start_times_only strategy: "[0.5] Verse one starts here" "[2.1] And continues with more lyrics" "[3.2] Final line here" """ if not text_lines or random.random() >= augment_probability: # Return regular text without timestamps if not text_lines: return "" return "\n".join([line[2] for line in text_lines]) # Decide on timestamp dropout strategy dropout_strategy = random.choice( [ "all_timestamps", # Include all timestamps "start_end_only", # Only first start and last end "per_line_random", # Randomly decide per line independently ] ) def format_line_with_timestamps(text, start_s=None, end_s=None): """Helper function to format a line with optional timestamps.""" # Handle multiple trailing newlines trailing_newlines = "" clean_text = text while clean_text.endswith("\n"): trailing_newlines += "\n" clean_text = clean_text[:-1] if start_s is not None and end_s is not None: formatted = f"[{start_s:.1f}] {clean_text} [{end_s:.1f}]" elif start_s is not None: formatted = f"[{start_s:.1f}] {clean_text}" elif end_s is not None: formatted = f"{clean_text} [{end_s:.1f}]" else: formatted = clean_text return formatted + trailing_newlines formatted_lines = [] for i, line in enumerate(text_lines): start_s, end_s, text = line if dropout_strategy == "all_timestamps": formatted_lines.append(format_line_with_timestamps(text, start_s, end_s)) elif dropout_strategy == "start_end_only": if i == 0: formatted_lines.append(format_line_with_timestamps(text, start_s)) elif i == len(text_lines) - 1: formatted_lines.append(format_line_with_timestamps(text, end_s=end_s)) else: formatted_lines.append(text) elif dropout_strategy == "per_line_random": rand_choice = random.random() if rand_choice < 0.25: # 25% no timestamp formatted_lines.append(text) elif rand_choice < 0.5: # 25% start only formatted_lines.append(format_line_with_timestamps(text, start_s)) elif rand_choice < 0.75: # 25% end only formatted_lines.append(format_line_with_timestamps(text, end_s=end_s)) else: # 25% start-end range formatted_lines.append(format_line_with_timestamps(text, start_s, end_s)) return "\n".join(formatted_lines) def add_vocal_suffixes(vocal_tags): """Add vocal suffixes to vocal tags with random probability.""" if not vocal_tags: return vocal_tags vocal_suffixes = ["vocal", "voice", "vox"] processed_vocal_tags = [] for tag in vocal_tags: if random.random() <= 0.7: # 70% chance to add suffix suffix = random.choice(vocal_suffixes) processed_vocal_tags.append(f"{tag} {suffix}") else: processed_vocal_tags.append(tag) return processed_vocal_tags def add_tags_element(text_elements, tags, separator, prefix=""): """Add a tag element to text_elements and return new list. Args: text_elements: List to add the tag element to tags: List of tags to process separator: Character(s) to join tags with prefix: Optional prefix for the tag element (e.g., "vocal:") Returns: New text_elements list with tag element added (if valid) """ if not tags: return text_elements tags_str = f"{separator.join(tags[:MAX_N_TAGS])}"[:MAX_TOT_TAGS_LEN] if len(tags_str) > 0: element = f"[{prefix}{tags_str}]" if prefix else f"[{tags_str}]" return text_elements + [element] return text_elements def build_text( tags, text, sample_duration_s, sample_duration_toks, sample_vocal_start_s=None, inference=False, suppress_text=False, enable_control_tags=True, hook_only=False, start_from_chorus=False, sample_offset_toks=0, max_possible_duration_s=60 * 40, critical_control_tags=None, audio_sample_start_times_s=None, # Audio sample timings for text conditioning (list) audio_sample_sources=None, # Source types for each audio sample ("vocal", "drum", "full_mix", etc.) semantic_rate_hz=25, # Default semantic rate for control tag calculations loudness_25hz=None, # Loudness at 25Hz for activity tag calculations loudness_seq=None, # Loudness contour with randomized rate/smoothing/warping spectral_centroid_seq=None, # Spectral centroid sequence with randomized rate/smoothing/warping spectral_complexity_seq=None, # Spectral complexity sequence with randomized rate/smoothing/warping contour_rate_hz=None, # Rate for loudness_seq and spectral features (randomized 0.2-1Hz, for debugging) contour_is_warped=False, # Whether time-warping was applied to contour features vocal_tags=None, # Vocal characteristic tags for voice conditioning vocal_pitch_hz_min=None, # Minimum vocal pitch in Hz for pitch range control tags vocal_pitch_hz_max=None, # Maximum vocal pitch in Hz for pitch range control tags ): if suppress_text: return "" text_elements = [] # Process tags and vocal tags with shared logic tags = clean_tags(tags) if tags else [] vocal_tags = clean_tags(vocal_tags) if vocal_tags else [] if not inference: # Training mode: augment and use random joining tags = augment_tags(tags) if tags else [] vocal_tags = augment_tags(vocal_tags) if vocal_tags else [] merge_char = random.choice([", ", " ", "; ", ",", ";", ". "]) # Two paths for vocal tags processing if random.random() <= 0.5: # Path 1: Add vocal suffixes and merge into regular tags vocal_tags_with_suffixes = add_vocal_suffixes(vocal_tags) combined_tags = tags + vocal_tags_with_suffixes random.shuffle(combined_tags) # Randomize the order of combined tags text_elements = add_tags_element(text_elements, combined_tags, merge_char) else: # Path 2: Separate processing - regular tags + [vocal:...] element text_elements = add_tags_element(text_elements, tags, merge_char) text_elements = add_tags_element(text_elements, vocal_tags, merge_char, prefix="vocal:") else: # Inference mode: keep the non-inference behavior text_elements = add_tags_element(text_elements, tags, ",") text_elements = add_tags_element(text_elements, vocal_tags, ",", prefix="vocal:") # get lyrics if len(text) > 0 and not inference: text = re.sub(r"\[(.*?)\:(.*?)\]", clean_inline_tags, text) # augment tags inside text: text = re.sub(r"\[(.*?)\:(.*?)\]", augment_inline_tags, text) if random.random() >= 0.95: text = text.lower() if random.random() >= 0.95: text = re.sub(r"\n+", " ", text) if len(text) > 0: text_elements.append(text.strip()) # get control tags for n in range(len(text_elements)): text_elements[n] = text_elements[n].replace("{", "").replace("}", "") if (inference or random.random() >= 0.1) and enable_control_tags: control_tags = get_control_tags( sample_duration_s, sample_duration_toks, sample_vocal_start_s=sample_vocal_start_s, hook_only=hook_only, start_from_chorus=start_from_chorus, sample_offset_toks=sample_offset_toks, do_augment=not inference, max_possible_duration_s=max_possible_duration_s, critical_control_tags=critical_control_tags, semantic_rate_hz=semantic_rate_hz, audio_sample_start_times_s=audio_sample_start_times_s, audio_sample_sources=audio_sample_sources, loudness_25hz=loudness_25hz, loudness_seq=loudness_seq, spectral_centroid_seq=spectral_centroid_seq, spectral_complexity_seq=spectral_complexity_seq, contour_rate_hz=contour_rate_hz, contour_is_warped=contour_is_warped, vocal_pitch_hz_min=vocal_pitch_hz_min, vocal_pitch_hz_max=vocal_pitch_hz_max, ) if control_tags is not None: text_elements = [control_tags] + text_elements if inference or random.random() >= 0.5: text = "\n\n".join(text_elements) else: text = "" for t in text_elements: text += random.choice([" ", "\n", "\n\n"]) + t text = text.strip() return text