"""
reference_listener.py - Reference-track audio analysis and sample matching.

Improved for Phase 4:
- Enhanced section detection (intro, verse, build, drop, break, outro)
- Better role detection per segment
- Precise one-shot vs loop classification
- Improved clap, hat, bass loop, vocal, fx detection
- Family repetition penalty system
"""

from __future__ import annotations

import json
import logging
import math
import random
import re
import warnings
import gzip
import hashlib
import time
from collections import defaultdict, deque
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import numpy as np

try:
    import librosa
except ImportError:  # pragma: no cover
    librosa = None

try:
    import torch
    import torch.nn.functional as F
except ImportError:  # pragma: no cover
    torch = None
    F = None

try:
    import torch_directml
except ImportError:  # pragma: no cover
    torch_directml = None

# Import sample selector for section-aware selection
try:
    from sample_selector import get_selector as get_sample_selector
except ImportError:
    try:
        from .sample_selector import get_selector as get_sample_selector
    except ImportError:
        get_sample_selector = None

try:
    from midi_preset_indexer import get_indexer as get_midi_preset_indexer
except ImportError:
    try:
        from .midi_preset_indexer import get_indexer as get_midi_preset_indexer
    except ImportError:
        get_midi_preset_indexer = None

try:
    from spectral_engine import get_spectral_engine, SpectralProfile
    SPECTRAL_ENGINE_AVAILABLE = True
except ImportError:
    try:
        from .spectral_engine import get_spectral_engine, SpectralProfile
        SPECTRAL_ENGINE_AVAILABLE = True
    except ImportError:
        get_spectral_engine = None
        SpectralProfile = None
        SPECTRAL_ENGINE_AVAILABLE = False

_reference_spectral_profile: Optional[Dict[str, Any]] = None
_reference_perc_centroid: Optional[float] = None
_reference_bass_centroid: Optional[float] = None


logger = logging.getLogger("ReferenceListener")

NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
KEY_PROFILES = {
    'major': [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88],
    'minor': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17],
}

_cross_generation_reference_family_memory: Dict[str, int] = defaultdict(int)
_cross_generation_reference_path_memory: Dict[str, int] = defaultdict(int)

# Section type definitions with characteristic energy patterns
# Enhanced with clearer energy thresholds and additional features for robust detection
SECTION_PROFILES = {
    'intro': {
        'energy_range': (0.0, 0.35),
        'onset_density': (0.0, 0.4),
        'spectral_brightness': (0.0, 0.5),
        'energy_stability': (0.4, 1.0),
        'typical_position': (0.0, 0.15),
        'min_bars': 4,
        'max_bars': 32,
    },
    'verse': {
        'energy_range': (0.25, 0.55),
        'onset_density': (0.3, 0.6),
        'spectral_brightness': (0.3, 0.6),
        'energy_stability': (0.5, 1.0),
        'typical_position': (0.1, 0.7),
        'min_bars': 8,
        'max_bars': 32,
    },
    'build': {
        'energy_range': (0.45, 0.85),
        'onset_density': (0.5, 0.9),
        'spectral_brightness': (0.5, 0.8),
        'energy_stability': (0.0, 0.6),
        'energy_slope': (0.05, 1.0),
        'typical_position': (0.15, 0.85),
        'min_bars': 4,
        'max_bars': 24,
        'rising': True,
    },
    'drop': {
        'energy_range': (0.65, 1.0),
        'onset_density': (0.5, 1.0),
        'spectral_brightness': (0.5, 1.0),
        'energy_stability': (0.5, 1.0),
        'typical_position': (0.2, 0.9),
        'min_bars': 8,
        'max_bars': 64,
    },
    'break': {
        'energy_range': (0.1, 0.45),
        'onset_density': (0.1, 0.4),
        'spectral_brightness': (0.2, 0.5),
        'energy_stability': (0.4, 1.0),
        'typical_position': (0.3, 0.7),
        'min_bars': 4,
        'max_bars': 24,
    },
    'outro': {
        'energy_range': (0.05, 0.4),
        'onset_density': (0.05, 0.5),
        'spectral_brightness': (0.1, 0.4),
        'energy_stability': (0.0, 0.6),
        'energy_slope': (-1.0, -0.02),
        'typical_position': (0.82, 1.0),
        'min_bars': 4,
        'max_bars': 32,
        'falling': True,
    },
}

# ============================================================================
# P0: BUS-AWARE PACK COHERENCE SYSTEM
# ============================================================================

# Bus-aware pack groups - defines which packs are coherent for each bus
# This replaces the single dominant_pack global approach
BUS_PACK_GROUPS = {
    'drums': {
        'folders': ['drumloops', '16bloody', 'ss_rnbl', 'perc loop', 'drum loops', 'one shots'],
        'roles': ['kick', 'snare', 'clap', 'hat', 'hat_closed', 'hat_open', 'perc_loop', 'top_loop', 'crash_fx', 'fill_fx', 'snare_roll'],
    },
    'music': {
        'folders': ['midilatino', 'sentimientolatino2025', 'music loops', 'instrumental loops', 'chords', 'synth'],
        'roles': ['chords', 'pad', 'lead', 'pluck', 'synth_loop', 'arp', 'drone', 'stab', 'counter', 'atmos_fx'],
    },
    'fx': {
        'folders': ['reggaeton 3', 'bigcayu', 'impact', 'fx', 'fill', 'intro', 'textures'],
        'roles': ['atmos_fx', 'fill_fx', 'crash_fx', 'snare_roll', 'vocal_shot', 'riser', 'impact', 'reverse_fx'],
    },
    'vocal': {
        'folders': ['midilatino', 'sentimientolatino2025', 'reggaeton 3', 'vocals', 'vocal phrases', 'lead vocals', 'add libs'],
        'roles': ['vocal_loop', 'vocal_build', 'vocal_peak', 'vocal_shot'],
    },
}

# Non-harmonic roles that can freely mix across packs without coherence penalty
NON_HARMONIC_ROLES = {'kick', 'snare', 'clap', 'hat', 'hat_closed', 'hat_open', 'perc_loop', 'top_loop', 'vocal_shot', 'crash_fx'}

# Role to bus mapping for quick lookup
ROLE_TO_BUS_MAP = {}
for bus_name, bus_config in BUS_PACK_GROUPS.items():
    for role in bus_config['roles']:
        ROLE_TO_BUS_MAP[role] = bus_name


def _get_bus_for_role(role: str) -> str:
    """Get the bus assignment for a given role."""
    return ROLE_TO_BUS_MAP.get(role.lower(), 'music')  # Default to music bus


def _get_dominant_pack_for_bus(pack_scores: Dict[str, Dict[str, Any]], bus_name: str, 
                               candidates_by_role: Dict[str, List[Dict[str, Any]]]) -> str:
    """
    Select dominant pack for a specific bus based on bus-aware scoring.
    
    Args:
        pack_scores: Scores for all packs
        bus_name: Bus to select for ('drums', 'music', 'fx', 'vocal')
        candidates_by_role: Role -> candidates mapping for filtering
        
    Returns:
        Dominant pack name for this bus
    """
    bus_config = BUS_PACK_GROUPS.get(bus_name, {})
    bus_roles = set(bus_config.get('roles', []))
    bus_folders = set(f.lower() for f in bus_config.get('folders', []))
    
    # Filter pack scores to only include those relevant to this bus
    bus_pack_scores = {}
    for pack, data in pack_scores.items():
        # Check if pack covers any bus roles
        pack_roles = data.get('roles', [])
        bus_relevant_roles = bus_roles.intersection(set(pack_roles))
        
        # Check if pack is from bus-compatible folders
        pack_lower = pack.lower()
        is_folder_match = any(folder in pack_lower for folder in bus_folders)
        
        if bus_relevant_roles or is_folder_match:
            # Weight by bus-relevant roles
            bus_score = data['score'] * (1.0 + len(bus_relevant_roles) * 0.5)
            if is_folder_match:
                bus_score *= 1.3  # 30% bonus for folder match
            bus_pack_scores[pack] = {
                'score': bus_score,
                'roles': list(bus_relevant_roles) if bus_relevant_roles else pack_roles,
                'original_score': data['score']
            }
    
    if not bus_pack_scores:
        # Fallback: find any pack that matches bus folders
        for role, candidates in candidates_by_role.items():
            if role in bus_roles:
                for candidate in candidates:
                    pack = _extract_pack_from_path(candidate.get('path', ''))
                    pack_lower = pack.lower()
                    if any(folder in pack_lower for folder in bus_folders):
                        return pack
        # Last resort: return first pack found
        if pack_scores:
            return list(pack_scores.keys())[0]
        return "unknown"
    
    # Sort by bus score
    sorted_packs = sorted(
        bus_pack_scores.items(),
        key=lambda x: (len(x[1]['roles']), x[1]['score']),
        reverse=True
    )
    
    return sorted_packs[0][0]


def _extract_pack_from_path(sample_path: str) -> str:
    """Extract pack name from sample path (helper function for module-level access)."""
    if not sample_path:
        return "unknown"
    path = Path(sample_path)
    parts = [part for part in path.parts if part and part not in {path.anchor}]
    
    def _normalized_folder(value: str) -> str:
        return re.sub(r"[^a-z]+", " ", str(value or "").lower()).strip()
    
    def _is_generic_folder(value: str) -> bool:
        normalized = _normalized_folder(value)
        if not normalized or normalized.isdigit():
            return True
        generic_names = {
            "libreria", "library", "all tracks", "all_tracks", "reggaeton",
            "one shot", "one shots", "20 one shots", "drum loop", "drum loops", "instrumental loop", "instrumental loops",
            "vocal", "vocals", "vocal phrases", "lead vocals dry", "add libs vocals dry",
            "midi", "midi pack", "sample pack", "sounds presets",
            "kick", "snare", "hat", "hats", "hi hat", "fx", "fill", "impact intro",
            "perc", "perc loop", "top loop", "audio", "loops", "loop", "drums",
            "midi remote scripts", "resources", "programdata", "users", "ren",
            "live", "live suite", "live 12 suite", "ableton", "remote scripts", "oneshots",
            "stems", "samples", "claps", "kicks", "snares", "hats", "cymbals", "percussion",
            "melodic", "chords", "bass", "synths", "pads", "leads", "arps",
            "wav", "mp3", "aiff", "audio files", "samples pack",
        }
        return normalized in generic_names

    for part in reversed(parts):
        part_lower = part.lower()
        if part_lower.startswith("ss_rnbl_") or "ss_rnbl" in part_lower:
            return "ss_rnbl"
        if part_lower.startswith("midilatino_") or "midilatino" in part_lower:
            return "midilatino"
        if "sentimientolatino" in part_lower:
            return "sentimientolatino2025"
        if "reggaeton 3" in part_lower:
            return "reggaeton 3"
        if "bigcayu" in part_lower:
            return "bigcayu"
        if "dastin" in part_lower:
            return "dastin"
        if "drumloops" in part_lower:
            return "drumloops"
    
    for index, part in enumerate(parts[:-1]):
        normalized = _normalized_folder(part)
        if "sample pack" in normalized or "midi pack" in normalized:
            if index + 1 < len(parts) - 1 and not _is_generic_folder(parts[index + 1]):
                return parts[index + 1]
        if any(indicator in normalized for indicator in ("pack", "kit", "library", "collection", "bundle")) and not _is_generic_folder(part):
            return part
    
    # Check for bus-group folder names in path
    for part in parts:
        part_lower = part.lower()
        for bus_config in BUS_PACK_GROUPS.values():
            for folder in bus_config['folders']:
                if folder.lower() in part_lower and not _is_generic_folder(part):
                    return part
    
    for parent in path.parents:
        folder = parent.name
        if folder and not _is_generic_folder(folder):
            return folder
    
    # T071: Check grandparent when parent is a generic category folder
    if len(parts) >= 2:
        parent_folder = parts[-2]
        if not _is_generic_folder(parent_folder):
            return parent_folder
        # Parent is generic, try grandparent
        if len(parts) >= 3:
            grandparent = parts[-3]
            if not _is_generic_folder(grandparent):
                return grandparent
        return "unknown"
    return parts[-1] if parts else "unknown"


def _is_pack_in_bus_group(pack_name: str, bus_name: str) -> bool:
    """Check if a pack belongs to a specific bus group."""
    bus_config = BUS_PACK_GROUPS.get(bus_name, {})
    bus_folders = [f.lower() for f in bus_config.get('folders', [])]
    pack_lower = pack_name.lower()
    
    # Check direct folder match
    if any(folder in pack_lower for folder in bus_folders):
        return True
    
    # Check sibling pack relationships
    if bus_name == 'drums' and any(f in pack_lower for f in ['drumloops', '16bloody', 'ss_rnbl', 'perc']):
        return True
    if bus_name == 'music' and any(f in pack_lower for f in ['midilatino', 'sentimientolatino', 'music']):
        return True
    if bus_name == 'fx' and any(f in pack_lower for f in ['reggaeton 3', 'bigcayu', 'impact', 'fx']):
        return True
    if bus_name == 'vocal' and any(f in pack_lower for f in ['midilatino', 'sentimientolatino', 'reggaeton 3', 'vocal']):
        return True
    
    return False


def _normalize_dominant_packs(dominant_packs: Any) -> Dict[str, str]:
    """Normalize legacy single-pack values into the bus-aware dict shape."""
    if isinstance(dominant_packs, dict):
        normalized = dict(dominant_packs)
        fallback = normalized.get('overall') or normalized.get('music') or normalized.get('drums') or 'unknown'
        for bus_name in ('drums', 'music', 'fx', 'vocal'):
            normalized.setdefault(bus_name, fallback)
        normalized.setdefault('overall', fallback)
        return normalized

    single_pack = str(dominant_packs or 'unknown').strip() or 'unknown'
    return {
        'drums': single_pack,
        'music': single_pack,
        'fx': single_pack,
        'vocal': single_pack,
        'overall': single_pack,
    }

# Groove indicators in sample names - prioritize loops with real human feel
GROOVE_KEYWORDS = {
    'high_groove': [
        'groove', 'swing', 'shuffle', 'human', 'live', 'organic', 'funk', 'soul',
        'jazz', 'tribal', 'latin', 'afro', 'dembow', 'perreo', 'roots',
        'acoustic', 'real', 'played', 'session', 'jam', 'improv', 'natural'
    ],
    'medium_groove': [
        'loop', 'phrase', 'fill', 'variation', 'dynamic', 'moving', 'evolving',
        'layered', 'complex', 'rich', 'texture', 'rhythm', 'percussion'
    ],
    'low_groove': [
        'static', 'simple', 'basic', 'minimal', 'sine', 'synth', 'electronic',
        'machine', 'quantized', 'grid', 'click', 'metronome', 'robotic'
    ]
}

# Section-specific groove preferences
SECTION_GROOVE_PROFILES = {
    'intro': {'prefer': 'medium', 'penalty': 0.1, 'bonus': 0.15},
    'verse': {'prefer': 'high', 'penalty': 0.05, 'bonus': 0.20},
    'build': {'prefer': 'high', 'penalty': 0.05, 'bonus': 0.25},
    'drop': {'prefer': 'high', 'penalty': 0.0, 'bonus': 0.20},
    'break': {'prefer': 'medium', 'penalty': 0.08, 'bonus': 0.18},
    'outro': {'prefer': 'medium', 'penalty': 0.12, 'bonus': 0.15},
}

# Manual-only roles. User records vocals manually, so the generator must not
# spend coherence budget selecting or materializing them automatically.
MANUAL_RECORDING_ROLES = {'vocal_loop', 'vocal_build', 'vocal_peak', 'vocal_shot'}


def _is_manual_recording_role(role: str) -> bool:
    return str(role or '').lower() in MANUAL_RECORDING_ROLES


def _filter_manual_recording_role_map(role_map: Dict[str, Any]) -> Dict[str, Any]:
    return {
        role: value
        for role, value in dict(role_map or {}).items()
        if not _is_manual_recording_role(role)
    }


# Variation roles - these roles get different samples per section to avoid repetition
VARIATION_ROLES = {
    'perc_loop', 'top_loop', 'perc_alt', 'synth_peak',
    'atmos_fx', 'fill_fx'
}

# Section density profiles for sample selection
SECTION_DENSITY_PROFILES = {
    'intro': {'min_density': 0.1, 'max_density': 0.4, 'preferred_complexity': 'low'},
    'verse': {'min_density': 0.3, 'max_density': 0.7, 'preferred_complexity': 'medium'},
    'build': {'min_density': 0.5, 'max_density': 0.9, 'preferred_complexity': 'high'},
    'drop': {'min_density': 0.6, 'max_density': 1.0, 'preferred_complexity': 'high'},
    'break': {'min_density': 0.2, 'max_density': 0.5, 'preferred_complexity': 'low'},
    'outro': {'min_density': 0.1, 'max_density': 0.4, 'preferred_complexity': 'low'},
}


def _score_groove_factor(sample_name: str, section_kind: str = 'drop') -> float:
    """
    P3: Score a sample's groove factor based on name keywords and section context.
    
    Returns a multiplier (0.7 - 1.4) to boost/penalize samples based on groove quality.
    """
    name_lower = sample_name.lower()
    section_profile = SECTION_GROOVE_PROFILES.get(section_kind, SECTION_GROOVE_PROFILES['drop'])
    
    # Count groove keywords
    high_matches = sum(1 for kw in GROOVE_KEYWORDS['high_groove'] if kw in name_lower)
    medium_matches = sum(1 for kw in GROOVE_KEYWORDS['medium_groove'] if kw in name_lower)
    low_matches = sum(1 for kw in GROOVE_KEYWORDS['low_groove'] if kw in name_lower)
    
    # Calculate base groove score
    if high_matches > 0:
        base_score = 1.0 + (high_matches * 0.15)  # 1.15 - 1.40
    elif medium_matches > 0:
        base_score = 1.0 + (medium_matches * 0.08)  # 1.08 - 1.24
    elif low_matches > 0:
        base_score = 1.0 - (low_matches * 0.10)  # 0.70 - 0.90
    else:
        base_score = 1.0  # Neutral
    
    # Apply section preference
    preferred = section_profile.get('prefer', 'high')
    if preferred == 'high' and high_matches > 0:
        base_score += section_profile.get('bonus', 0.20)
    elif preferred == 'medium' and medium_matches > 0:
        base_score += section_profile.get('bonus', 0.15)
    elif preferred == 'high' and low_matches > 0:
        base_score -= section_profile.get('penalty', 0.05)
    
    return max(0.7, min(1.4, base_score))


def _get_section_complexity_preference(section_kind: str) -> str:
    """P3: Get preferred complexity level for a section type."""
    profile = SECTION_DENSITY_PROFILES.get(section_kind, {})
    return profile.get('preferred_complexity', 'medium')


def _score_complexity_match(sample_name: str, section_kind: str) -> float:
    """
    P3: Score how well a sample matches the section's complexity needs.
    
    Returns 0.8-1.2 multiplier based on complexity fit.
    """
    preferred = _get_section_complexity_preference(section_kind)
    name_lower = sample_name.lower()
    
    complexity_keywords = {
        'low': ['simple', 'minimal', 'basic', 'sparse', 'subtle', 'light', 'clean'],
        'medium': ['standard', 'normal', 'balanced', 'loop', 'phrase'],
        'high': ['complex', 'rich', 'full', 'heavy', 'layered', 'busy', 'dense', 'big']
    }
    
    matches = sum(1 for kw in complexity_keywords.get(preferred, []) if kw in name_lower)
    
    if matches > 0:
        return 1.0 + (matches * 0.06)  # 1.06 - 1.20
    
    # Check for mismatches
    other_complexity = set(complexity_keywords.keys()) - {preferred}
    mismatches = sum(
        1 for c in other_complexity 
        for kw in complexity_keywords[c] 
        if kw in name_lower
    )
    
    if mismatches > 0:
        return 1.0 - (mismatches * 0.04)  # 0.92 - 0.96
    
    return 1.0  # Neutral


# ============================================================================
# TRACK AND LAYER BUDGET SYSTEM
# ============================================================================

# Budget configuration per genre
TRACK_BUDGET = {
    'reggaeton': {
        'total_max': 12,
        'drums_core': 4,      # kick, clap/snare, hat, perc_main
        'bass_core': 1,
        'musical_core': 2,    # chords/pad + lead/pluck
        'vocal_fx_core': 2,   # max 1-2 useful
        'optional_slots': 3,  # only if they add real contrast
    },
    'techno': {
        'total_max': 10,
        'drums_core': 3,      # kick, snare/clap, hat
        'bass_core': 1,
        'musical_core': 2,    # synth + pad
        'vocal_fx_core': 1,
        'optional_slots': 3,
    },
    'house': {
        'total_max': 11,
        'drums_core': 4,      # kick, clap, hat, perc
        'bass_core': 1,
        'musical_core': 3,    # chords, lead, pad
        'vocal_fx_core': 1,
        'optional_slots': 2,
    },
    'tech-house': {
        'total_max': 11,
        'drums_core': 4,
        'bass_core': 1,
        'musical_core': 3,
        'vocal_fx_core': 1,
        'optional_slots': 2,
    },
    'default': {
        'total_max': 12,
        'drums_core': 4,
        'bass_core': 1,
        'musical_core': 2,
        'vocal_fx_core': 2,
        'optional_slots': 3,
    }
}

# Layer classification - CORE roles (must-haves)
CORE_ROLES = [
    'kick', 'snare', 'clap', 'hat', 'hat_closed', 'hat_open',
    'bass_loop', 'bass_main', 'sub_bass',
    'chords', 'pad', 'lead', 'pluck', 'synth_loop'
]

# Layer classification - OPTIONAL roles (only if budget remains)
OPTIONAL_ROLES = [
    'perc_alt', 'perc_loop', 'top_loop',
    'synth_peak', 'atmos_fx', 'atmos',
    'fill_fx', 'crash_fx', 'snare_roll',
    'noise_sweep', 'riser', 'downlifter'
]

SECTION_CONFIDENCE_THRESHOLDS = {
    'high': 0.75,
    'medium': 0.55,
    'low': 0.35,
    'ambiguous': 0.20,
}

# Spectral signatures for role detection
SPECTRAL_ROLE_SIGNATURES = {
    'kick': {'centroid_range': (50, 400), 'rolloff_range': (200, 2000), 'rms_spread': (0.4, 1.0), 'transient_score': (0.6, 1.0)},
    'clap': {'centroid_range': (800, 4000), 'rolloff_range': (2000, 8000), 'rms_spread': (0.2, 0.7), 'transient_score': (0.7, 1.0)},
    'hat': {'centroid_range': (4000, 12000), 'rolloff_range': (6000, 14000), 'rms_spread': (0.1, 0.4), 'transient_score': (0.5, 1.0)},
    'bass_loop': {'centroid_range': (60, 500), 'rolloff_range': (200, 2000), 'rms_spread': (0.5, 1.0), 'periodicity': (0.6, 1.0)},
    'vocal': {'centroid_range': (200, 3000), 'rolloff_range': (1000, 5000), 'rms_spread': (0.3, 0.8), 'harmonic_ratio': (0.4, 0.9)},
    'fx': {'centroid_range': (1000, 8000), 'rolloff_range': (3000, 12000), 'rms_spread': (0.2, 0.9), 'spectral_flux': (0.5, 1.0)},
}

# Roles elegibles para variacion por seccion
# Estos roles pueden usar diferentes samples en diferentes secciones
SECTION_VARIATION_ROLES = [
    'perc', 'perc_alt', 'top_loop', 'synth_peak', 'atmos'
]

# P0 Sprint v0.1.23: Roles armonicos que varian por seccion CON COHERENCIA
# DEBEN mantener mismo pack/family para no romper la armonia
HARMONIC_VARIATION_ROLES = [
    'synth_loop', 'bass_loop'
]

# P0: Secciones donde aplicar variacion armonica (no intro/outro)
HARMONIC_VARIATION_SECTIONS = {'drop', 'build', 'break'}

# Variaciones permitidas por tipo de seccion
SECTION_VARIANTS = {
    'intro': ['sparse', 'minimal'],
    'verse': ['standard', 'sparse'],
    'build': ['building', 'dense'],
    'drop': ['full', 'peak'],
    'break': ['sparse', 'atmospheric'],
    'outro': ['fading', 'minimal']
}

ROLE_VECTOR_TYPES = {
    'kick': set(),
    'snare': set(),
    'hat': set(),
    'bass_loop': {'bass'},
    'perc_loop': {'drum loop', 'top'},
    'top_loop': {'top', 'drum loop'},
    'synth_loop': {'synth loop', 'synth'},
    'vocal_loop': {'vocal'},
    'crash_fx': {'fx'},
    'fill_fx': {'fx'},
    'snare_roll': {'fx'},
    'atmos_fx': {'fx', 'synth'},
    'vocal_shot': {'vocal'},
}

ROLE_SEGMENT_SETTINGS = {
    'kick': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 10},
    'snare': {'windows': {1.0, 2.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 10},
    'hat': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 12},
    'bass_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8},
    'perc_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8},
    'top_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8},
    'synth_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'build', 'drop', 'break'}, 'top_k': 8},
    'vocal_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop', 'break'}, 'top_k': 8},
    'crash_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop', 'intro', 'outro'}, 'top_k': 6},
    'fill_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'break', 'drop'}, 'top_k': 6},
    'snare_roll': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop'}, 'top_k': 6},
    'atmos_fx': {'windows': {4.0, 8.0}, 'section_kinds': {'intro', 'break', 'outro'}, 'top_k': 6},
    'vocal_shot': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8},
}

ROLE_DURATION_WINDOWS = {
    'kick': (0.05, 2.5),
    'snare': (0.05, 3.0),
    'hat': (0.05, 2.0),
    'bass_loop': (0.75, 32.0),
    'perc_loop': (0.75, 32.0),
    'top_loop': (0.75, 32.0),
    'synth_loop': (0.75, 32.0),
    'vocal_loop': (0.75, 32.0),
    'crash_fx': (0.05, 12.0),
    'fill_fx': (0.15, 12.0),
    'snare_roll': (0.15, 12.0),
    'atmos_fx': (0.25, 32.0),
    'vocal_shot': (0.05, 3.5),
}

MICRO_STEM_TOKEN_HINTS = {
    "piano": ("piano", "keys", "keyzone", "steinway", "grand", "rhode", "rhodes"),
    "guitar": ("guitar", "nylon", "acoustic"),
    "pluck": ("pluck", "mallet", "kalimba", "bell", "glock"),
    "lead": ("lead", "hook", "melody", "synth"),
    "pad": ("pad", "texture", "ambient", "atmos", "drone"),
    "vocal": ("vocal", "vox", "phrase", "chop", "chant"),
    "reese": ("reese", "sub", "808", "bass"),
    "dembow": ("dembow", "perreo", "reggaeton", "latin", "latino"),
}

MICRO_STEM_ROLE_CANDIDATE_LIMITS = {
    'kick': 6,
    'snare': 6,
    'hat': 6,
    'bass_loop': 6,
    'perc_loop': 6,
    'top_loop': 6,
    'synth_loop': 8,
    'vocal_loop': 6,
    'crash_fx': 4,
    'fill_fx': 4,
    'snare_roll': 4,
    'atmos_fx': 4,
    'vocal_shot': 5,
}

MICRO_STEM_ROLE_BIAS_TOKENS = {
    'synth_loop': {'piano', 'guitar', 'pluck', 'lead', 'pad'},
    'vocal_loop': {'vocal'},
    'vocal_shot': {'vocal'},
    'bass_loop': {'reese'},
    'perc_loop': {'dembow'},
    'top_loop': {'dembow'},
    'atmos_fx': {'pad'},
}


def _safe_float(value: Any, default: float = 0.0) -> float:
    try:
        return float(np.atleast_1d(value)[0])
    except Exception:
        return float(default)


def _normalize_chroma(chroma: np.ndarray) -> np.ndarray:
    chroma = np.asarray(chroma, dtype=np.float32).reshape(12)
    total = float(np.sum(chroma))
    if total <= 1e-9:
        return chroma
    return chroma / total


def _adaptive_n_fft(audio_length: int, default_n_fft: int = 2048, min_n_fft: int = 512) -> int:
    """Calcula n_fft adaptativo basado en la longitud del audio."""
    max_n_fft = audio_length // 2
    adaptive = max(min_n_fft, min(default_n_fft, max_n_fft))
    if adaptive < default_n_fft:
        logger.debug("Using reduced n_fft=%d for short audio (len=%d)", adaptive, audio_length)
    return adaptive


def _detect_key(chroma: np.ndarray) -> Tuple[Optional[str], float]:
    chroma = _normalize_chroma(chroma)
    best_key = None
    best_score = -999.0

    for mode, profile in KEY_PROFILES.items():
        profile_array = np.asarray(profile, dtype=np.float32)
        for index in range(12):
            score = np.corrcoef(chroma, np.roll(profile_array, index))[0, 1]
            if np.isnan(score):
                continue
            if score > best_score:
                best_score = float(score)
                best_key = NOTE_NAMES[index] + ('m' if mode == 'minor' else '')

    return best_key, best_score if best_key else 0.0


def _key_distance(left: Optional[str], right: Optional[str]) -> int:
    if not left or not right:
        return 6

    def _index(key_name: str) -> int:
        base = key_name[:-1] if key_name.endswith('m') else key_name
        return NOTE_NAMES.index(base) if base in NOTE_NAMES else 0

    return min((_index(left) - _index(right)) % 12, (_index(right) - _index(left)) % 12)


class SectionDetector:
    """Detects structural sections from audio analysis with improved segmentation."""

    def __init__(self, hop_length: int = 512, sr: int = 22050):
        self.hop_length = hop_length
        self.sr = sr
        self.min_section_bars = 4
        self.max_section_bars = 64
        self.min_section_seconds = 6.0
        self.max_section_seconds = 120.0
        self.energy_smoothing_window = 2.0
        self.boundary_sensitivity = 0.65
        self.min_energy_diff_for_boundary = 0.08
        self.ambiguity_threshold = 0.25

    def _compute_segment_features(self, rms: np.ndarray, onset: np.ndarray,
                                   centroid: np.ndarray, start_frame: int,
                                   end_frame: int, rms_global_max: float = None) -> Dict[str, float]:
        """Compute normalized features for a segment."""
        rms_seg = rms[start_frame:end_frame]
        onset_seg = onset[start_frame:end_frame]
        centroid_seg = centroid[start_frame:end_frame]

        if len(rms_seg) == 0:
            return {'energy': 0.0, 'onset_density': 0.0, 'brightness': 0.0, 'flux': 0.0,
                    'energy_stability': 1.0, 'onset_variability': 0.0}

        rms_global_max = rms_global_max if rms_global_max is not None else float(np.max(rms))
        rms_global_max = max(rms_global_max, 0.001)

        energy = float(np.mean(rms_seg))
        onset_density = float(np.mean(onset_seg)) / 5.0
        brightness = float(np.mean(centroid_seg)) / 10000.0

        if len(centroid_seg) > 1:
            flux = float(np.mean(np.abs(np.diff(centroid_seg)))) / 2000.0
        else:
            flux = 0.0

        energy_stability = 1.0
        if len(rms_seg) > 1:
            energy_cv = float(np.std(rms_seg)) / max(float(np.mean(rms_seg)), 0.001)
            energy_stability = min(1.0, max(0.0, 1.0 - energy_cv * 2.0))

        onset_variability = 0.0
        if len(onset_seg) > 1:
            onset_std = float(np.std(onset_seg))
            onset_mean = max(float(np.mean(onset_seg)), 0.001)
            onset_variability = min(1.0, onset_std / onset_mean)

        return {
            'energy': min(1.0, max(0.0, (energy / rms_global_max) * 1.5)),
            'onset_density': min(1.0, max(0.0, onset_density)),
            'brightness': min(1.0, max(0.0, brightness)),
            'flux': min(1.0, max(0.0, flux)),
            'energy_stability': round(energy_stability, 3),
            'onset_variability': round(onset_variability, 3)
        }

    def _compute_richer_section_features(
        self,
        y: np.ndarray,
        sr: int,
        rms: np.ndarray,
        onset_env: np.ndarray,
        centroid: np.ndarray,
        start_time: float,
        end_time: float,
        hop_length: int = 512,
        n_fft: int = 2048
    ) -> Dict[str, float]:
        """
        Compute richer per-section features for better reference matching.

        Returns energy_mean, energy_peak, energy_slope, spectral_centroid_mean,
        spectral_centroid_std, onset_rate, low_energy_ratio, high_energy_ratio.
        """
        duration = end_time - start_time
        if duration < 1.0:
            return {
                'energy_mean': 0.0,
                'energy_peak': 0.0,
                'energy_slope': 0.0,
                'spectral_centroid_mean': 0.0,
                'spectral_centroid_std': 0.0,
                'onset_rate': 0.0,
                'low_energy_ratio': 0.0,
                'high_energy_ratio': 0.0,
            }

        frames_per_second = sr / hop_length
        start_frame = int(start_time * frames_per_second)
        end_frame = int(end_time * frames_per_second)

        start_frame = max(0, min(start_frame, len(rms) - 1))
        end_frame = max(start_frame + 1, min(end_frame, len(rms)))

        section_rms = rms[start_frame:end_frame]
        section_onset = onset_env[start_frame:end_frame]
        section_centroid = centroid[start_frame:end_frame]

        if len(section_rms) == 0:
            return {
                'energy_mean': 0.0,
                'energy_peak': 0.0,
                'energy_slope': 0.0,
                'spectral_centroid_mean': 0.0,
                'spectral_centroid_std': 0.0,
                'onset_rate': 0.0,
                'low_energy_ratio': 0.0,
                'high_energy_ratio': 0.0,
            }

        # Energy metrics (normalized 0-1)
        rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01
        energy_mean = float(np.mean(section_rms))
        energy_peak = float(np.max(section_rms))
        energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0)
        energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5)

        # Energy slope (trend within section)
        if len(section_rms) > 2:
            x = np.arange(len(section_rms))
            slope, _ = np.polyfit(x, section_rms, 1)
            energy_slope_norm = float(np.clip(slope * 100, -1.0, 1.0))
        else:
            energy_slope_norm = 0.0

        # Spectral centroid metrics
        centroid_mean = float(np.mean(section_centroid))
        centroid_std = float(np.std(section_centroid)) if len(section_centroid) > 1 else 0.0
        centroid_mean_norm = min(1.0, centroid_mean / 10000.0)
        centroid_std_norm = min(1.0, centroid_std / 6000.0)

        # Onset rate (onsets per second)
        onset_threshold = float(np.mean(section_onset)) + float(np.std(section_onset)) * 0.5
        onset_count = int(np.sum(section_onset > onset_threshold))
        onset_rate = onset_count / max(duration, 0.1)
        onset_rate_norm = min(1.0, onset_rate / 20.0)

        # Low and high energy ratios (STFT-based frequency analysis)
        start_sample = int(start_time * sr)
        end_sample = int(end_time * sr)
        start_sample = max(0, min(start_sample, len(y) - 1))
        end_sample = max(start_sample + 512, min(end_sample, len(y)))

        try:
            S = np.abs(librosa.stft(y[start_sample:end_sample], n_fft=n_fft))
            freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft)
            total_energy = float(np.sum(S ** 2)) + 1e-10

            low_mask = freqs < 300
            high_mask = freqs > 4000

            low_energy = float(np.sum(S[low_mask, :] ** 2))
            high_energy = float(np.sum(S[high_mask, :] ** 2))

            low_energy_ratio = min(1.0, low_energy / total_energy)
            high_energy_ratio = min(1.0, high_energy / total_energy)
        except Exception:
            low_energy_ratio = 0.0
            high_energy_ratio = 0.0

        return {
            'energy_mean': round(energy_mean_norm, 4),
            'energy_peak': round(energy_peak_norm, 4),
            'energy_slope': round(energy_slope_norm, 4),
            'spectral_centroid_mean': round(centroid_mean_norm, 4),
            'spectral_centroid_std': round(centroid_std_norm, 4),
            'onset_rate': round(onset_rate_norm, 4),
            'low_energy_ratio': round(low_energy_ratio, 4),
            'high_energy_ratio': round(high_energy_ratio, 4),
        }

    def _compute_section_kind_confidence(
        self,
        kind: str,
        features: Dict[str, float],
        position_ratio: float,
        prev_features: Optional[Dict[str, float]]
    ) -> Tuple[float, List[str]]:
        """
        Compute confidence score for section kind classification.

        Returns (confidence, alternatives) where:
        - confidence is 0.0-1.0 with clear semantic thresholds:
          - 0.75+: high confidence (section type is clear)
          - 0.55-0.75: medium confidence (likely correct but could be alternative)
          - 0.35-0.55: low confidence (ambiguous, check alternatives)
          - <0.35: very low confidence (section may be misclassified)
        - alternatives is list of 1-2 other plausible kinds

        Enhanced with energy trend, onset variability, positional context, and feature matching.
        """
        energy = features.get('energy', 0.5)
        onset_density = features.get('onset_density', 0.5)
        onset_var = features.get('onset_variability', 0.0)
        stability = features.get('energy_stability', 1.0)
        brightness = features.get('brightness', 0.5)

        energy_mean = features.get('energy_mean', energy)
        onset_rate = features.get('onset_rate', onset_density)

        energy_trend = features.get('energy_trend', 0.0)
        if energy_trend == 0.0 and prev_features:
            prev_energy = prev_features.get('energy', energy)
            energy_trend = energy - prev_energy

        profile = SECTION_PROFILES.get(kind, {})
        confidence = 0.35
        alternatives = []

        prev_energy = prev_features.get('energy', energy) if prev_features else energy
        energy_rising = energy_trend > 0.08
        energy_falling = energy_trend < -0.08

        def _match_range(value: float, range_tuple: Tuple[float, float]) -> float:
            if not range_tuple:
                return 0.5
            lo, hi = range_tuple
            if lo <= value <= hi:
                center = (lo + hi) / 2
                spread = (hi - lo) / 2
                dist_from_center = abs(value - center)
                return 1.0 - (dist_from_center / (spread * 2 + 0.01))
            elif value < lo:
                return max(0.0, 1.0 - (lo - value) * 2)
            else:
                return max(0.0, 1.0 - (value - hi) * 2)

        energy_match = _match_range(energy_mean, profile.get('energy_range', (0.0, 1.0)))
        onset_match = _match_range(onset_rate, profile.get('onset_density', (0.0, 1.0)))
        brightness_match = _match_range(brightness, profile.get('spectral_brightness', (0.0, 1.0)))
        stability_match = _match_range(stability, profile.get('energy_stability', (0.0, 1.0)))

        pos_range = profile.get('typical_position', (0.0, 1.0))
        position_match = _match_range(position_ratio, pos_range)

        base_feature_score = (energy_match * 0.35 + onset_match * 0.25 + brightness_match * 0.15 + stability_match * 0.15 + position_match * 0.10)

        if kind == 'intro':
            if prev_features is None:
                confidence = 0.85 + base_feature_score * 0.15
            elif position_ratio < 0.12 and energy_mean < 0.32:
                confidence = 0.78 + base_feature_score * 0.18
            elif position_ratio < 0.18 and energy_mean < 0.40:
                confidence = 0.62 + base_feature_score * 0.15
            elif position_ratio < 0.22 and energy_mean < 0.45:
                confidence = 0.48 + base_feature_score * 0.12
            else:
                confidence = 0.30 + base_feature_score * 0.10
            if energy_mean > 0.55:
                confidence -= 0.18
            if energy_rising and position_ratio > 0.1:
                confidence -= 0.10
            alternatives = ['verse', 'break', 'build']

        elif kind == 'outro':
            if position_ratio > 0.90:
                confidence = 0.88 + base_feature_score * 0.12
            elif position_ratio > 0.85 and energy_mean < 0.35:
                confidence = 0.75 + base_feature_score * 0.15
            elif position_ratio > 0.80 and energy_mean < 0.42:
                confidence = 0.58 + base_feature_score * 0.12
            else:
                confidence = 0.32 + base_feature_score * 0.08
            if energy_falling:
                confidence += 0.12
            if energy_mean > 0.55:
                confidence -= 0.12
            alternatives = ['break', 'verse', 'build']

        elif kind == 'drop':
            if energy_mean > 0.72 and onset_rate > 0.48 and stability > 0.55:
                confidence = 0.92 + (energy_mean - 0.72) * 0.3
            elif energy_mean > 0.62 and onset_rate > 0.40:
                confidence = 0.78 + base_feature_score * 0.15
            elif energy_mean > 0.52 and onset_rate > 0.35:
                confidence = 0.55 + base_feature_score * 0.12
            else:
                confidence = 0.30 + base_feature_score * 0.08
            if 0.25 < position_ratio < 0.75:
                confidence += 0.05
            if position_ratio < 0.18:
                confidence -= 0.15
            alternatives = ['build', 'verse']

        elif kind == 'build':
            slope_range = profile.get('energy_slope', (0.0, 1.0))
            slope_match = _match_range(energy_trend, slope_range) if slope_range else 0.5

            if energy_rising and 0.40 < energy_mean < 0.72:
                confidence = 0.82 + slope_match * 0.15
                if onset_var > 0.25:
                    confidence = min(confidence + 0.08, 0.95)
            elif energy_rising and 0.35 < energy_mean < 0.78:
                confidence = 0.62 + slope_match * 0.18
            elif 0.35 < energy_mean < 0.72 and not energy_falling:
                confidence = 0.45 + base_feature_score * 0.15
            else:
                confidence = 0.28 + base_feature_score * 0.08
            if position_ratio < 0.12 or position_ratio > 0.88:
                confidence -= 0.12
            alternatives = ['drop', 'verse', 'break']

        elif kind == 'break':
            if energy_mean < 0.35 and onset_rate < 0.30 and stability > 0.50:
                confidence = 0.85 + base_feature_score * 0.12
            elif energy_mean < 0.42 and onset_rate < 0.38:
                confidence = 0.65 + base_feature_score * 0.10
            elif energy_mean < 0.48 and onset_rate < 0.45:
                confidence = 0.42 + base_feature_score * 0.08
            else:
                confidence = 0.28 + base_feature_score * 0.06
            if 0.25 < position_ratio < 0.75:
                confidence += 0.06
            if brightness > 0.55:
                confidence -= 0.06
            alternatives = ['intro', 'outro', 'verse']

        elif kind == 'verse':
            if 0.25 < energy_mean < 0.58 and 0.25 < onset_rate < 0.65 and stability > 0.45:
                confidence = 0.72 + base_feature_score * 0.15
            elif 0.28 < energy_mean < 0.55:
                confidence = 0.52 + base_feature_score * 0.12
            else:
                confidence = 0.35 + base_feature_score * 0.08
            if 0.15 < position_ratio < 0.75:
                confidence += 0.05
            alternatives = ['build', 'drop', 'break']

        else:
            confidence = 0.40 + base_feature_score * 0.10
            alternatives = ['verse', 'drop']

        total_sections = features.get('total_sections', 4)
        if total_sections <= 2:
            confidence = min(confidence * 0.90, 0.95)
        elif total_sections >= 8:
            pass

        confidence = max(0.15, min(0.98, confidence))

        return round(confidence, 3), alternatives

    def _section_character_bonus(
        self,
        role: str,
        candidate_analysis: Dict[str, Any],
        section_features: Dict[str, Any]
    ) -> float:
        """
        Compute a character bonus for matching a candidate sample to a section.

        Returns a multiplier (1.0 = no change, max ~1.25) based on how well
        the candidate's features match the section's acoustic character.
        """
        if not section_features:
            return 1.0

        bonus = 1.0

        onset_rate = float(section_features.get('onset_rate', 0.5))
        low_energy_ratio = float(section_features.get('low_energy_ratio', 0.0))
        high_energy_ratio = float(section_features.get('high_energy_ratio', 0.0))
        energy_slope = float(section_features.get('energy_slope', 0.0))
        energy_mean = float(section_features.get('energy_mean', 0.5))

        candidate_centroid = float(candidate_analysis.get('spectral_centroid', 0.0) or 0.0)
        candidate_onset = float(candidate_analysis.get('onset_mean', 0.0) or 0.0)

        role_lower = role.lower()

        # High onset rate section + high onset density candidate = bonus
        if onset_rate > 0.4:
            candidate_onset_norm = min(1.0, candidate_onset / 5.0)
            if role_lower in {'hat', 'top_loop', 'perc_loop', 'perc'}:
                if candidate_onset_norm > 0.6:
                    bonus = max(bonus, 1.0 + (candidate_onset_norm - 0.5) * 0.25)

        # High low-energy ratio + bass role = bonus
        if low_energy_ratio > 0.4:
            candidate_low_centroid = max(0.0, 1.0 - candidate_centroid / 3000.0)
            if role_lower in {'bass_loop', 'sub_bass', 'bass'}:
                if candidate_low_centroid > 0.5:
                    bonus = max(bonus, 1.0 + candidate_low_centroid * 0.15)

        # High high-energy ratio + hat/top role = bonus
        if high_energy_ratio > 0.3:
            candidate_high_centroid = min(1.0, candidate_centroid / 10000.0)
            if role_lower in {'hat', 'top_loop', 'crash_fx'}:
                if candidate_high_centroid > 0.5:
                    bonus = max(bonus, 1.0 + candidate_high_centroid * 0.12)

        # Snare/clap should adapt to section aggression instead of always choosing the hardest transient.
        # P1 Sprint v0.1.29: Increased penalty for aggressive snares in soft sections
        if role_lower in {'snare', 'clap'}:
            candidate_centroid_norm = min(1.0, candidate_centroid / 8000.0)
            candidate_onset_norm = min(1.0, candidate_onset / 5.0)
            section_is_soft = energy_mean < 0.55 or high_energy_ratio < 0.25

            if section_is_soft:
                # Aggressive snare in soft section: heavy penalty (was 0.82, now 0.6)
                if candidate_centroid_norm > 0.75 and candidate_onset_norm > 0.8:
                    bonus *= 0.6  # 40% penalty for very aggressive snare in soft section
                elif 0.25 <= candidate_centroid_norm <= 0.65 and 0.45 <= candidate_onset_norm <= 0.85:
                    bonus = max(bonus, 1.08)
            else:
                # Aggressive section: aggressive snare gets bonus
                if candidate_centroid_norm > 0.55 and candidate_onset_norm > 0.7:
                    bonus = max(bonus, 1.06)

        # Building section (positive slope) + snare_roll/fill_fx = bonus
        if energy_slope > 0.1:
            if role_lower in {'snare_roll', 'fill_fx', 'riser'}:
                bonus = max(bonus, 1.0 + energy_slope * 0.25)

        # Low energy section + atmos_fx = bonus
        if energy_mean < 0.3:
            if role_lower in {'atmos_fx', 'atmos', 'pad'}:
                bonus = max(bonus, 1.0 + (0.3 - energy_mean) * 0.4)

        return min(1.25, max(0.4, round(bonus, 3)))

    def _get_role_section_features(
        self, role: str, reference_sections: List[Dict[str, Any]],
        role_segments: List[Dict[str, Any]]
    ) -> Dict[str, Any]:
        """Get the most relevant section features for a given role."""
        if not reference_sections:
            return {}

        role_lower = role.lower()

        preferred_kinds: Dict[str, List[str]] = {
            'kick': ['drop', 'build'],
            'snare': ['intro', 'build', 'drop', 'break'],
            'clap': ['intro', 'build', 'drop', 'break'],
            'hat': ['drop', 'verse'],
            'bass_loop': ['drop', 'build'],
            'sub_bass': ['drop', 'build'],
            'top_loop': ['drop', 'verse'],
            'perc_loop': ['drop', 'build'],
            'synth_loop': ['drop', 'verse'],
            'vocal_loop': ['drop', 'verse'],
            'vocal_shot': ['drop', 'verse'],
            'snare_roll': ['build', 'intro'],
            'fill_fx': ['build', 'break'],
            'riser': ['build', 'intro'],
            'crash_fx': ['drop', 'intro', 'outro'],
            'atmos_fx': ['break', 'intro', 'outro'],
            'atmos': ['break', 'intro', 'outro'],
            'pad': ['break', 'intro'],
        }

        kinds = preferred_kinds.get(role_lower, ['drop'])

        if role_lower in {'snare', 'clap'}:
            matched_features: List[Dict[str, Any]] = []
            for section in reference_sections:
                kind = str(section.get('kind', 'drop')).lower()
                if kind in kinds and isinstance(section.get('features'), dict):
                    matched_features.append(section.get('features', {}))

            if matched_features:
                aggregated: Dict[str, Any] = {}
                numeric_keys = {
                    key
                    for features in matched_features
                    for key, value in features.items()
                    if isinstance(value, (int, float))
                }
                for key in numeric_keys:
                    values = [
                        float(features.get(key))
                        for features in matched_features
                        if isinstance(features.get(key), (int, float))
                    ]
                    if values:
                        aggregated[key] = sum(values) / float(len(values))
                if aggregated:
                    return aggregated

        for section in reference_sections:
            kind = str(section.get('kind', 'drop')).lower()
            if kind in kinds:
                return section.get('features', {})

        if reference_sections:
            for section in reference_sections:
                if section.get('kind', 'drop') == 'drop':
                    return section.get('features', {})
            return reference_sections[0].get('features', {})

        return {}

    def _find_boundary_peaks(self, energy_diff: np.ndarray, onset_peaks: np.ndarray,
                              threshold: float, min_gap_frames: int) -> List[int]:
        """Find section boundary peaks combining energy changes and onset peaks with improved detection."""
        if len(energy_diff) == 0:
            return []

        threshold_val = float(threshold)

        energy_percentile = float(np.percentile(energy_diff, 75)) if len(energy_diff) > 10 else threshold_val
        onset_percentile = float(np.percentile(onset_peaks, 55))

        candidates = []
        for i in range(len(energy_diff)):
            energy_score = float(energy_diff[i])
            onset_score = float(onset_peaks[i])

            combined_score = energy_score * 0.6 + onset_score * 0.4

            if energy_score > threshold_val and onset_score > onset_percentile * 0.8:
                candidates.append((i, combined_score, 'both'))
            elif energy_score > energy_percentile and onset_score > onset_percentile * 0.5:
                candidates.append((i, combined_score * 0.7, 'energy'))
            elif onset_score > float(np.percentile(onset_peaks, 85)) and energy_score > threshold_val * 0.5:
                candidates.append((i, combined_score * 0.6, 'onset'))

        if not candidates:
            for i in range(len(energy_diff)):
                if float(energy_diff[i]) > threshold_val * 0.7:
                    candidates.append((i, float(energy_diff[i]), 'fallback'))

        candidates.sort(key=lambda x: x[1], reverse=True)

        boundaries = []
        for idx, score, method in candidates:
            is_valid = True
            for existing in boundaries:
                if abs(idx - existing) < min_gap_frames:
                    is_valid = False
                    break
            if is_valid:
                boundaries.append(idx)

        boundaries.sort()
        return boundaries

    def _validate_section_progression(self, sections: List[Dict[str, Any]],
                                       duration: float, tempo: float) -> List[Dict[str, Any]]:
        """Validate and fix section progression for musical coherence."""
        if not sections:
            return [{'kind': 'drop', 'start': 0.0, 'end': duration,
                     'duration': duration, 'bars': max(8, int(duration * tempo / 60 / 4)),
                     'kind_confidence': 0.3, 'features': {'energy': 0.5}}]

        beats_per_second = tempo / 60.0
        seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0

        result = []
        for i, section in enumerate(sections):
            kind = section.get('kind', 'drop')
            start = section.get('start', 0.0)
            end = section.get('end', duration)
            sec_duration = end - start

            estimated_bars = max(4, int(round(sec_duration / seconds_per_bar)))
            if estimated_bars > self.max_section_bars:
                kind = 'drop' if section.get('features', {}).get('energy', 0.5) > 0.6 else 'break'
            if estimated_bars < self.min_section_bars and i > 0:
                prev_section = result[-1] if result else None
                if prev_section and prev_section.get('kind') == kind:
                    prev_section['end'] = end
                    prev_section['duration'] = end - prev_section['start']
                    prev_section['bars'] += estimated_bars
                    continue

            section['bars'] = estimated_bars
            section['beats'] = estimated_bars * 4
            result.append(section)

        for i, section in enumerate(result):
            section['section_index'] = i
            section['total_sections'] = len(result)

        return result

    def _compute_energy_transitions(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Compute energy transition direction between sections."""
        if len(sections) < 2:
            return sections

        for i, section in enumerate(sections):
            next_section = sections[i + 1] if i < len(sections) - 1 else None
            prev_section = sections[i - 1] if i > 0 else None

            current_energy = section.get('features', {}).get('energy', 0.5)
            next_energy = next_section.get('features', {}).get('energy', current_energy) if next_section else current_energy
            prev_energy = prev_section.get('features', {}).get('energy', current_energy) if prev_section else current_energy

            energy_diff_next = next_energy - current_energy
            energy_diff_prev = current_energy - prev_energy

            if energy_diff_next > 0.15:
                section['energy_transition'] = 'rising'
            elif energy_diff_next < -0.15:
                section['energy_transition'] = 'falling'
            else:
                section['energy_transition'] = 'stable'

            section['energy_delta_next'] = round(energy_diff_next, 3)
            section['energy_delta_prev'] = round(energy_diff_prev, 3)

        return sections

    def detect_sections(self, rms: np.ndarray, onset: np.ndarray,
                        centroid: np.ndarray, duration: float,
                        min_section_seconds: float = 8.0) -> List[Dict[str, Any]]:
        """Detect sections from audio features with improved segmentation and edge case handling."""
        if len(rms) == 0 or duration < min_section_seconds * 1.5:
            default_bars = max(8, int(duration * 128 / 60 / 4)) if duration > 0 else 8
            return [{'kind': 'drop', 'start': 0.0, 'end': duration, 'bars': default_bars,
                     'duration': duration, 'kind_confidence': 0.35,
                     'confidence_level': 'low',
                     'features': {'energy': 0.5, 'onset_density': 0.5},
                     'detection_method': 'fallback_short_track'}]

        hop_time = self.hop_length / self.sr
        frames_per_section = max(1, int(min_section_seconds / hop_time))

        rms_global_max = float(np.max(rms)) if len(rms) > 0 else 0.01
        kernel_size = min(len(rms), max(1, int(self.energy_smoothing_window / hop_time)))

        if kernel_size > 1:
            smoothed_rms = np.convolve(rms, np.ones(kernel_size) / kernel_size, mode='same')
        else:
            smoothed_rms = rms

        if len(smoothed_rms) > 1:
            energy_diff = np.abs(np.diff(smoothed_rms))
            if len(energy_diff) > kernel_size:
                energy_diff = np.convolve(energy_diff, np.ones(kernel_size) / kernel_size, mode='same')
        else:
            energy_diff = np.zeros(1)

        onset_binary = (onset > np.percentile(onset, 65)).astype(float)
        onset_peaks = np.convolve(onset_binary, np.ones(kernel_size) / kernel_size, mode='same')

        base_threshold = max(float(np.percentile(energy_diff, 65)), 0.001) if len(energy_diff) > 10 else 0.001
        threshold = base_threshold * self.boundary_sensitivity

        primary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, float(threshold), frames_per_section)

        secondary_threshold = float(threshold) * 0.55
        secondary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, secondary_threshold, frames_per_section // 2)

        all_boundaries = sorted(set([0] + primary_boundaries + secondary_boundaries + [len(rms) - 1]))
        consolidated_boundaries = [all_boundaries[0]]
        for boundary in all_boundaries[1:]:
            min_gap = frames_per_section * 0.4
            if boundary - consolidated_boundaries[-1] >= min_gap:
                consolidated_boundaries.append(boundary)

        if len(consolidated_boundaries) < 3 and duration > min_section_seconds * 2:
            _ = smoothed_rms
            n_segments = max(3, min(6, int(duration / min_section_seconds)))
            segment_boundaries = [0]
            for i in range(1, n_segments):
                target_frame = int(i * len(rms) / n_segments)
                search_range = max(1, int(len(rms) / (n_segments * 2)))
                best_frame = target_frame
                best_diff = float('inf')
                for j in range(max(0, target_frame - search_range), min(len(energy_diff), target_frame + search_range)):
                    if float(energy_diff[j]) > best_diff * 0.8:
                        best_diff = float(energy_diff[j])
                        best_frame = j
                segment_boundaries.append(best_frame)
            segment_boundaries.append(len(rms) - 1)
            consolidated_boundaries = sorted(set(consolidated_boundaries + segment_boundaries))

        sections = []
        prev_features = None
        prev_energy_trend = None

        for i in range(len(consolidated_boundaries) - 1):
            start_frame = consolidated_boundaries[i]
            end_frame = consolidated_boundaries[i + 1]

            if end_frame <= start_frame:
                continue

            start_time = start_frame * hop_time
            end_time = end_frame * hop_time
            segment_duration = end_time - start_time

            min_duration = min_section_seconds * 0.2
            if segment_duration < min_duration:
                if sections:
                    sections[-1]['end'] = end_time
                    sections[-1]['duration'] = end_time - sections[-1]['start']
                    sections[-1]['merged_short'] = True
                continue

            max_duration = self.max_section_seconds
            if segment_duration > max_duration:
                mid_frame = (start_frame + end_frame) // 2
                consolidated_boundaries.insert(i + 1, mid_frame)
                end_frame = mid_frame
                end_time = end_frame * hop_time
                segment_duration = end_time - start_time

            features = self._compute_segment_features(
                rms, onset, centroid, start_frame, end_frame, rms_global_max
            )

            energy = features.get('energy', 0.5)
            if prev_features:
                energy_trend = energy - prev_features.get('energy', 0.5)
            else:
                energy_trend = 0.0
            features['energy_trend'] = round(energy_trend, 3)

            position_ratio = start_time / duration if duration > 0 else 0.0
            positional_weight = self._compute_positional_weight(position_ratio, len(consolidated_boundaries) - 1, i)

            kind = self._classify_segment_v2(
                features, position_ratio, prev_features, energy_trend, prev_energy_trend
            )

            estimated_bars = max(4, int(round(segment_duration * 128 / 60 / 4)))

            sections.append({
                'kind': kind,
                'start': round(start_time, 3),
                'end': round(end_time, 3),
                'duration': round(segment_duration, 3),
                'bars': estimated_bars,
                'features': features,
                'positional_weight': positional_weight,
            })

            prev_features = features
            prev_energy_trend = energy_trend

        merged = []
        for section in sections:
            if merged and merged[-1]['kind'] == section['kind'] and section['duration'] < min_section_seconds * 0.6:
                merged[-1]['end'] = section['end']
                merged[-1]['duration'] = round(section['end'] - merged[-1]['start'], 3)
                merged[-1]['bars'] += section.get('bars', 4)
                merged_features = merged[-1].get('features', {})
                new_features = section.get('features', {})
                merged_features['energy'] = (merged_features.get('energy', 0.5) + new_features.get('energy', 0.5)) / 2
                merged[-1]['merged_with_next'] = True
            else:
                merged.append(section)

        merged = self._validate_section_progression(merged, duration, 128.0)
        merged = self._compute_energy_transitions(merged)

        merged = self._add_confidence_levels(merged)

        if len(merged) < 2 and duration > min_section_seconds * 2:
            merged = self._create_fallback_sections(duration, 128.0, rms, onset)

        return merged

    def _add_confidence_levels(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """Add human-readable confidence levels to sections."""
        for section in sections:
            confidence = section.get('kind_confidence', 0.5)
            if confidence >= SECTION_CONFIDENCE_THRESHOLDS['high']:
                section['confidence_level'] = 'high'
            elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['medium']:
                section['confidence_level'] = 'medium'
            elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['low']:
                section['confidence_level'] = 'low'
            else:
                section['confidence_level'] = 'ambiguous'
        return sections

    def _create_fallback_sections(self, duration: float, tempo: float,
                                   rms: np.ndarray, onset: np.ndarray) -> List[Dict[str, Any]]:
        """Create fallback sections when detection fails."""
        sections = []
        beats_per_second = tempo / 60.0
        seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0

        total_bars = max(16, int(duration / seconds_per_bar))

        if duration < 60:
            sections = [
                {'kind': 'intro', 'start': 0.0, 'end': duration * 0.25,
                 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)),
                 'kind_confidence': 0.35, 'confidence_level': 'low',
                 'features': {'energy': 0.3}, 'detection_method': 'fallback'},
                {'kind': 'drop', 'start': duration * 0.25, 'end': duration * 0.75,
                 'duration': duration * 0.5, 'bars': max(8, int(total_bars * 0.5)),
                 'kind_confidence': 0.35, 'confidence_level': 'low',
                 'features': {'energy': 0.6}, 'detection_method': 'fallback'},
                {'kind': 'outro', 'start': duration * 0.75, 'end': duration,
                 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)),
                 'kind_confidence': 0.35, 'confidence_level': 'low',
                 'features': {'energy': 0.35}, 'detection_method': 'fallback'},
            ]
        else:
            n_sections = min(5, max(3, int(duration / 30)))
            section_duration = duration / n_sections

            energy_profile = []
            if len(rms) > n_sections:
                segment_size = len(rms) // n_sections
                for i in range(n_sections):
                    segment_rms = rms[i * segment_size:(i + 1) * segment_size]
                    energy_profile.append(float(np.mean(segment_rms)) if len(segment_rms) > 0 else 0.5)
                max_energy = max(energy_profile) if energy_profile else 0.5
                energy_profile = [e / max_energy for e in energy_profile]
            else:
                energy_profile = [0.3, 0.5, 0.7, 0.6, 0.4][:n_sections]

            kinds = ['intro', 'verse', 'build', 'drop', 'outro']
            for i in range(n_sections):
                kind = kinds[i] if i < len(kinds) else 'verse'
                if i == n_sections - 1:
                    kind = 'outro'
                elif i == 0:
                    kind = 'intro'
                elif i == n_sections - 2:
                    kind = 'drop'
                elif energy_profile[i] > 0.6 and i > 0 and i < n_sections - 1:
                    kind = 'drop'

                start = i * section_duration
                end = (i + 1) * section_duration if i < n_sections - 1 else duration

                sections.append({
                    'kind': kind,
                    'start': round(start, 3),
                    'end': round(end, 3),
                    'duration': round(end - start, 3),
                    'bars': max(4, int((end - start) / seconds_per_bar)),
                    'kind_confidence': 0.30,
                    'confidence_level': 'low',
                    'features': {'energy': energy_profile[i] if i < len(energy_profile) else 0.5},
                    'detection_method': 'fallback_energy_profile',
                })

        return sections

    def _compute_positional_weight(self, position_ratio: float, total_sections: int,
                                    section_index: int) -> float:
        """Compute positional weight for section classification confidence."""
        if total_sections <= 1:
            return 1.0

        if position_ratio < 0.15:
            return 1.2
        elif position_ratio > 0.85:
            return 1.2
        elif 0.35 < position_ratio < 0.65:
            return 0.9
        else:
            return 1.0

    def _classify_segment_v2(self, features: Dict[str, float], position_ratio: float,
                              prev_features: Optional[Dict[str, float]],
                              energy_trend: float, prev_energy_trend: Optional[float]) -> str:
        """Classify segment with improved energy trend and context awareness."""
        energy = features.get('energy', 0.5)
        onset = features.get('onset_density', 0.5)
        brightness = features.get('brightness', 0.5)
        stability = features.get('energy_stability', 1.0)
        onset_var = features.get('onset_variability', 0.0)

        is_rising = energy_trend > 0.08 or (prev_energy_trend is not None and prev_energy_trend > 0.05 and energy_trend >= 0)
        is_falling = energy_trend < -0.08 or (prev_energy_trend is not None and prev_energy_trend < -0.05)

        is_strong_rise = energy_trend > 0.15
        _ = energy_trend < -0.15

        scores = {}

        if position_ratio < 0.18:
            intro_energy_match = max(0, 0.5 - abs(energy - 0.22))
            intro_onset_match = max(0, 0.4 - abs(onset - 0.22))
            intro_pos_bonus = 0.65 * (0.18 - position_ratio)
            intro_stability_bonus = 0.15 if stability > 0.5 else 0
            scores['intro'] = intro_energy_match + intro_onset_match + intro_pos_bonus + intro_stability_bonus
        else:
            scores['intro'] = -0.5

        if position_ratio > 0.80:
            outro_energy_match = max(0, 0.5 - abs(energy - 0.22))
            outro_onset_match = max(0, 0.4 - abs(onset - 0.22))
            outro_pos_bonus = 0.55 * (position_ratio - 0.80)
            outro_falling_bonus = 0.25 if is_falling else (0.10 if not is_rising else -0.15)
            scores['outro'] = outro_energy_match + outro_onset_match + outro_pos_bonus + outro_falling_bonus
        else:
            scores['outro'] = -0.2

        if is_strong_rise and 0.38 < energy < 0.75:
            scores['build'] = 0.85 + (abs(energy_trend) * 1.5) + (onset * 0.25)
        elif is_rising and 0.35 < energy < 0.78:
            scores['build'] = 0.55 + (abs(energy_trend) * 2.0) + (onset * 0.15)
        elif 0.35 < energy < 0.72 and onset > 0.45 and position_ratio < 0.75:
            scores['build'] = 0.38 + (onset * 0.25)
        elif 0.38 < energy < 0.65 and onset_var > 0.2:
            scores['build'] = 0.32 + (onset_var * 0.3)
        else:
            scores['build'] = max(0, 0.15 - abs(energy_trend) * 2) if energy_trend < 0.05 else 0.08

        if energy > 0.68 and onset > 0.48 and stability > 0.55:
            brightness_bonus = 0.12 if brightness > 0.5 else 0
            scores['drop'] = (energy - 0.50) * 1.4 + (onset - 0.40) * 0.7 + brightness_bonus
        elif energy > 0.60 and onset > 0.42:
            scores['drop'] = (energy - 0.50) * 1.1 + onset * 0.45
        elif energy > 0.52:
            scores['drop'] = 0.35 + (energy - 0.52) * 1.5
        else:
            scores['drop'] = max(-0.3, (energy - 0.45) * 2)

        if energy < 0.40 and onset < 0.32 and stability > 0.45:
            scores['break'] = 0.75 + (0.40 - energy) * 0.55 + (0.32 - onset) * 0.45
        elif energy < 0.48 and onset < 0.38 and not is_rising:
            scores['break'] = 0.45 + (0.48 - energy) * 0.35 + (0.38 - onset) * 0.25
        elif energy < 0.45 and brightness < 0.45:
            scores['break'] = 0.35 + (0.45 - energy) * 0.3
        else:
            scores['break'] = max(0, 0.08 - abs(energy - 0.35) - abs(onset - 0.32))

        if 0.22 < energy < 0.60 and 0.22 < onset < 0.68 and stability > 0.40:
            scores['verse'] = 0.55 - abs(energy - 0.42) * 1.5 - abs(onset - 0.42) * 1.2
        elif 0.28 < energy < 0.52 and not is_rising and not is_falling:
            scores['verse'] = 0.38 - abs(energy - 0.40) * 1.0
        elif 0.25 < energy < 0.55:
            scores['verse'] = 0.25
        else:
            scores['verse'] = 0.12

        if not scores:
            return 'drop'

        best_kind, best_score = max(scores.items(), key=lambda x: x[1])

        if best_score < 0.10:
            if energy > 0.52:
                return 'drop'
            elif position_ratio < 0.18:
                return 'intro'
            elif position_ratio > 0.82:
                return 'outro'
            elif energy < 0.42:
                return 'break'
            elif is_rising:
                return 'build'
            else:
                return 'verse'

        second_best = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        if len(second_best) > 1:
            score_gap = second_best[0][1] - second_best[1][1]
            if score_gap < 0.12:
                if second_best[0][0] == 'drop' and second_best[1][0] == 'build':
                    if is_rising:
                        return 'build'

        return best_kind


def generate_segment_rag_summary(report: Dict[str, Any],
                                library_dir: Path) -> Dict[str, Any]:
    """
    Genera resumen enriquecido del indexado.

    Incluye:
    - Estadisticas basicas del report
    - Coverage por rol
    - Segmentos por archivo (avg, min, max)
    - Tiempo de procesamiento estimado
    - Salud del cache
    """
    manifest = report.get('manifest', [])

    # Calcular estadisticas
    segment_counts = [m.get('segments', 0) for m in manifest]

    # Coverage por rol
    role_segments: Dict[str, int] = defaultdict(int)
    for m in manifest:
        for role in m.get('roles', []):
            role_segments[role] += m.get('segments', 0)

    # Cache size
    cache_dir = library_dir / ".segment_rag"
    cache_size_bytes = sum(f.stat().st_size for f in cache_dir.glob("*.json.gz")) if cache_dir.exists() else 0

    return {
        **report,  # Incluir todos los campos originales

        # Estadisticas agregadas
        "summary_stats": {
            "avg_segments_per_file": sum(segment_counts) / len(segment_counts) if segment_counts else 0,
            "min_segments": min(segment_counts) if segment_counts else 0,
            "max_segments": max(segment_counts) if segment_counts else 0,
            "total_files_indexed": len(manifest),
        },

        # Coverage por rol
        "role_coverage": dict(role_segments),

        # Cache info
        "cache_info": {
            "cache_dir": str(cache_dir),
            "cache_size_bytes": cache_size_bytes,
            "cache_size_mb": round(cache_size_bytes / (1024 * 1024), 2),
        },

        # Timestamp
        "generated_at": time.time(),
        "generated_at_iso": time.strftime('%Y-%m-%dT%H:%M:%S'),
    }


# ============================================================================
# SELECTION AUDITOR - Records selection decisions for manifest
# ============================================================================

class SelectionAuditor:
    """Records selection decisions for manifest with detailed audit trail."""
    
    def __init__(self):
        self.selections: List[Dict[str, Any]] = []
        self.start_time = time.time()
    
    def record_selection(
        self, 
        role: str, 
        winner: Dict[str, Any], 
        all_candidates: List[Dict[str, Any]], 
        context: Dict[str, Any]
    ) -> Dict[str, Any]:
        """Record detailed selection info for manifest with P0 bus-aware support."""
        
        # Sort all candidates by final score
        sorted_candidates = sorted(
            all_candidates, 
            key=lambda x: x.get('final_score', 0), 
            reverse=True
        )
        
        # Build winner record
        winner_data = winner.get('candidate', {})
        
        # P0: Handle both single dominant_pack (legacy) and dominant_packs (bus-aware dict)
        dominant_packs = _normalize_dominant_packs(context.get('dominant_packs', {}))
        
        # Get bus for this role and corresponding dominant pack
        role_bus = winner.get('bus', _get_bus_for_role(role))
        dominant_pack_for_bus = dominant_packs.get(role_bus, dominant_packs.get('overall', 'unknown'))
        overall_dominant = dominant_packs.get('overall', dominant_pack_for_bus)
        
        candidate_pack = self._extract_pack_from_path(winner_data.get('path', ''))
        
        # Determine pack match status with bus awareness
        pack_match_status = 'unknown'
        if dominant_pack_for_bus and candidate_pack:
            if candidate_pack == dominant_pack_for_bus:
                pack_match_status = 'exact_match'
            elif _is_pack_in_bus_group(candidate_pack, role_bus):
                pack_match_status = 'bus_group_match'
            elif self._is_related_pack(candidate_pack, dominant_pack_for_bus):
                pack_match_status = 'sibling_match'
            else:
                pack_match_status = 'mismatch'
        
        winner_record = {
            'name': winner_data.get('file_name', winner_data.get('name', 'unknown')),
            'path': winner_data.get('path', ''),
            'family': self._extract_family_from_path(winner_data.get('path', '')),
            'pack': candidate_pack,
            'pack_match_status': pack_match_status,
            'bus': role_bus,
            'dominant_pack_for_bus': dominant_pack_for_bus,
            'dominant_packs': dominant_packs,  # Store full bus-aware dominant packs
            'scores': {
                'base': winner.get('base_score', 0),
                'joint': winner.get('joint_factor', 1.0),
                'family': winner.get('family_bonus', 1.0),
                'coherence': winner.get('coherence_score', 1.0),
                'piano_bonus': winner.get('piano_bonus', 1.0),
                'pack_bonus': winner.get('pack_bonus', 1.0),
                'final': winner.get('final_score', 0)
            },
            'reasons': winner.get('coherence_reasons', []),
            'rank': 1
        }
        if winner.get('piano_bonus', 1.0) > 1.0:
            winner_record['reasons'] = list(winner_record['reasons']) + ['piano_forward_bonus']
        
        # Build alternatives (top 3 rejected)
        alternatives = []
        for i, candidate in enumerate(sorted_candidates[1:4], start=2):
            cand_data = candidate.get('candidate', {})
            alt_record = {
                'rank': i,
                'name': cand_data.get('file_name', cand_data.get('name', 'unknown')),
                'path': cand_data.get('path', ''),
                'final_score': candidate.get('final_score', 0),
                'rejected_reason': self._explain_rejection(candidate, winner, i)
            }
            alternatives.append(alt_record)
        
        # Calculate margin to second place
        margin_to_second = 0.0
        if len(sorted_candidates) > 1:
            margin_to_second = winner.get('final_score', 0) - sorted_candidates[1].get('final_score', 0)
        
        # Build full record
        record = {
            'role': role,
            'timestamp': time.time(),
            'winner': winner_record,
            'alternatives': alternatives,
            'alternatives_considered': len(all_candidates),
            'margin_to_second': round(margin_to_second, 3),
            'selection_context': {
                'primary_harmonic_family': context.get('primary_harmonic_family'),
                'dominant_packs': dominant_packs,  # P0: Store bus-aware packs
                'target_key': context.get('target_key'),
                'preferred_secondary_families': list(context.get('preferred_secondary_families', [])),
                'already_selected_roles': list(context.get('selected_roles', [])),
                'already_selected_families': list(context.get('selected_families', [])),
                'bus': role_bus  # P0: Store bus for this selection
            }
        }
        
        self.selections.append(record)
        return record
    
    def _extract_family_from_path(self, path: str) -> str:
        """Extract family hint from sample path."""
        if not path:
            return 'unknown'
        family = _infer_semantic_family(path)
        return family or 'unknown'
    
    def _extract_pack_from_path(self, path: str) -> str:
        """Extract pack name from sample path."""
        if not path:
            return 'unknown'
        # Extract pack from path (e.g., "libreria/reggaeton/SentimientoLatino2025/...")
        parts = path.replace('\\', '/').split('/')
        for part in parts:
            if part in ['ss_rnbl', 'midilatino', 'bigcayu', 'sentimientolatino2025', 
                       'reggaeton 3', '16bloody', 'drumloops', 'perc loop']:
                return part
        return 'unknown'
    
    def _is_related_pack(self, pack1: str, pack2: str) -> bool:
        """Check if two packs are related/siblings."""
        # Define pack families
        pack_families = {
            'ss_rnbl': 'latin',
            'sentimientolatino2025': 'latin',
            'midilatino': 'latin',
            'reggaeton 3': 'latin',
            '16bloody': 'drum',
            'drumloops': 'drum',
            'bigcayu': 'fx',
        }
        return pack_families.get(pack1) == pack_families.get(pack2)
    
    def _explain_rejection(self, candidate: Dict[str, Any], winner: Dict[str, Any], rank: int) -> str:
        """Generate human-readable rejection reason."""
        reasons = []
        
        winner_score = winner.get('final_score', 0)
        cand_score = candidate.get('final_score', 0)
        
        if cand_score < winner_score:
            margin = winner_score - cand_score
            reasons.append(f"Lower score by {margin:.2f}")
        
        # Check coherence issues
        if not candidate.get('is_coherent', True):
            coherence_reasons = candidate.get('coherence_reasons', [])
            if coherence_reasons:
                reasons.append(f"Coherence: {coherence_reasons[0]}")
        
        # Check specific score components
        if candidate.get('coherence_score', 1.0) < 0.8:
            reasons.append("Low coherence score")
        
        if candidate.get('joint_factor', 1.0) < 1.0:
            reasons.append("Poor joint compatibility")
        
        if not reasons:
            reasons.append(f"Ranked #{rank}")
        
        return "; ".join(reasons)
    
    def get_selection_summary(self) -> Dict[str, Any]:
        """P0: Calculate summary metrics with per-bus pack coherence."""
        if not self.selections:
            return {
                'total_layers': 0,
                'layers_with_family_match': 0,
                'layers_with_pack_match': 0,
                'average_joint_score': 0.0,
                'average_final_score': 0.0,
                'per_bus_coherence': {}
            }
        
        total = len(self.selections)
        harmonic_roles = {
            'synth_loop', 'chords', 'lead', 'pad', 'harmonic_loop',
            'atmos_fx', 'atmos', 'pluck', 'keys', 'piano'
        }
        harmonic_total = 0

        # Calculate metrics
        family_matches = 0
        pack_matches = 0
        pack_siblings = 0
        pack_mismatches = 0
        bus_group_matches = 0
        joint_scores = []
        final_scores = []
        pack_bonuses = []
        
        # P0: Per-bus tracking
        per_bus_stats = {
            'drums': {'total': 0, 'matches': 0, 'bus_group': 0, 'siblings': 0, 'mismatches': 0},
            'music': {'total': 0, 'matches': 0, 'bus_group': 0, 'siblings': 0, 'mismatches': 0},
            'fx': {'total': 0, 'matches': 0, 'bus_group': 0, 'siblings': 0, 'mismatches': 0},
            'vocal': {'total': 0, 'matches': 0, 'bus_group': 0, 'siblings': 0, 'mismatches': 0},
        }
        
        for sel in self.selections:
            winner = sel.get('winner', {})
            scores = winner.get('scores', {})
            pack_match_status = winner.get('pack_match_status', 'unknown')
            bus = winner.get('bus', 'unknown')

            # Check family match
            if sel.get('role') in harmonic_roles:
                harmonic_total += 1
                if any('family_exact_match' in r or 'family_compatible' in r for r in winner.get('reasons', [])):
                    family_matches += 1
            
            # Check pack match status
            if pack_match_status == 'exact_match':
                pack_matches += 1
            elif pack_match_status == 'bus_group_match':
                bus_group_matches += 1
            elif pack_match_status == 'sibling_match':
                pack_siblings += 1
            elif pack_match_status == 'mismatch':
                pack_mismatches += 1
            
            # P0: Track per-bus stats
            if bus in per_bus_stats:
                per_bus_stats[bus]['total'] += 1
                if pack_match_status == 'exact_match':
                    per_bus_stats[bus]['matches'] += 1
                elif pack_match_status == 'bus_group_match':
                    per_bus_stats[bus]['bus_group'] += 1
                elif pack_match_status == 'sibling_match':
                    per_bus_stats[bus]['siblings'] += 1
                elif pack_match_status == 'mismatch':
                    per_bus_stats[bus]['mismatches'] += 1
            
            # Collect scores
            joint_scores.append(scores.get('joint', 1.0))
            final_scores.append(scores.get('final', 0))
            pack_bonuses.append(scores.get('pack_bonus', 1.0))
        
        # Calculate pack coherence ratio (dominant + bus_group + siblings / total)
        pack_coherence_ratio = (pack_matches + bus_group_matches + pack_siblings) / total if total > 0 else 0
        
        # P0: Calculate per-bus coherence ratios
        per_bus_coherence = {}
        for bus, stats in per_bus_stats.items():
            bus_total = stats['total']
            if bus_total > 0:
                coherent = stats['matches'] + stats['bus_group'] + stats['siblings']
                per_bus_coherence[bus] = {
                    'total_samples': bus_total,
                    'exact_matches': stats['matches'],
                    'bus_group_matches': stats['bus_group'],
                    'sibling_matches': stats['siblings'],
                    'mismatches': stats['mismatches'],
                    'coherence_ratio': round(coherent / bus_total, 3),
                    'status': 'OK' if coherent / bus_total > 0.6 else 'NEEDS_IMPROVEMENT'
                }
        
        return {
            'total_layers': total,
            'layers_with_family_match': family_matches,
            'layers_with_pack_match': pack_matches,
            'layers_with_bus_group_match': bus_group_matches,
            'layers_with_pack_sibling': pack_siblings,
            'layers_with_pack_mismatch': pack_mismatches,
            'harmonic_layers_evaluated': harmonic_total,
            'family_adherence_rate': round(family_matches / harmonic_total, 3) if harmonic_total > 0 else 0,
            'pack_adherence_rate': round(pack_matches / total, 3) if total > 0 else 0,
            'pack_coherence_ratio': round(pack_coherence_ratio, 3),
            'average_joint_score': round(sum(joint_scores) / len(joint_scores), 3) if joint_scores else 0,
            'average_final_score': round(sum(final_scores) / len(final_scores), 3) if final_scores else 0,
            'average_pack_bonus': round(sum(pack_bonuses) / len(pack_bonuses), 3) if pack_bonuses else 1.0,
            'per_bus_coherence': per_bus_coherence  # P0: New per-bus metrics
        }
    
    def to_manifest(self) -> Dict[str, Any]:
        """Export to manifest format."""
        summary = self.get_selection_summary()
        
        return {
            'layers': self.selections,
            'summary': summary,
            'audit_metadata': {
                'total_selections': len(self.selections),
                'audit_duration_seconds': round(time.time() - self.start_time, 3),
                'timestamp': time.time()
            }
        }


def format_selection_reason(record: Dict[str, Any]) -> str:
    """Create human-readable selection explanation."""
    winner = record.get('winner', {})
    reasons = winner.get('reasons', [])
    role = record.get('role', 'unknown')
    
    # Format as readable text
    readable = f"Selected {winner.get('name', 'unknown')} for {role}:\n"
    
    scores = winner.get('scores', {})
    readable += f"  Final score: {scores.get('final', 0):.2f}\n"
    readable += "  Factors:\n"
    
    for reason in reasons:
        if 'family_exact' in reason:
            readable += "    + Perfect family match with reference\n"
        elif 'family_compatible' in reason:
            readable += "    + Compatible family with reference\n"
        elif 'pack_match' in reason:
            readable += "    + Same pack as other layers\n"
        elif 'joint' in reason:
            readable += "    + Harmonically compatible with selected layers\n"
        elif 'key_match' in reason:
            readable += "    + Key matches project\n"
        elif 'key_compatible' in reason:
            readable += "    + Key is compatible with project\n"
    
    alternatives = record.get('alternatives', [])
    if alternatives:
        readable += f"\n  Beats {len(alternatives)} alternatives:\n"
        for alt in alternatives[:2]:
            readable += f"    - {alt.get('name', 'unknown')}: {alt.get('rejected_reason', '')}\n"
    
    return readable


PIANO_FORWARD_ROLES = {'chords', 'synth_loop', 'atmos_fx', 'pad', 'music_bed', 'texture', 'ambient'}
PIANO_FAMILIES = {'piano', 'keys', 'rhodes', 'keyboard', 'epiano', 'steinway', 'grand'}
PIANO_SUPPORT_TOKENS = (
    ' chord ', ' chords ', ' pad ', ' pads ', ' texture ', ' textures ',
    ' ambient ', ' atmos ', ' drone ', ' wash ', ' stab ', ' bell chord ',
)
PIANO_MELODY_TOKENS = (
    ' melody ', ' melodic ', ' hook ', ' riff ', ' motif ', ' lead ',
    ' solo ', ' arp ', ' arpeggio ', ' pluck ', ' plucks ', ' keys loop ',
    ' piano loop ', ' theme ',
)
SEMANTIC_FAMILY_ALIASES = (
    ('snare_roll', ('snare roll', 'snareroll', 'snare_roll')),
    ('fill_fx', ('transition fill', 'drum fill', 'fill fx', ' fill ')),
    ('crash_fx', ('crash', 'impact', 'downlifter', 'uplifter', 'riser', 'sweep')),
    ('vocal_shot', ('vocal chop', 'vocal shot', 'vox shot', 'one shot vocal', 'one shot vox', ' vocal chop ', ' chop ')),
    ('vocal_loop', ('vocal loop', 'vox loop', 'acapella', ' vocal ', ' vox ', ' vocals ')),
    ('top_loop', ('top loop', 'full drum', 'drum loop', 'full loop')),
    ('perc_loop', ('perc loop', 'percussion loop', 'conga', 'bongo', 'timbal', ' perc ')),
    ('hat', ('closed hat', 'open hat', 'hi hat', 'hihat', ' hat ', ' hats ')),
    ('clap', (' clap ',)),
    ('snare', (' snare ',)),
    ('kick', (' kick ', ' bass drum ', ' bd ')),
    ('bass', ('sub bass', 'subbass', 'reese', ' 808 ', ' bass loop ', ' bass ')),
    ('piano', ('steinway', 'grand piano', 'upright piano', ' piano ')),
    ('keys', ('electric piano', 'e piano', 'epiano', 'rhodes', 'keyboard', ' keys ', ' chord ', ' chords ')),
    ('pluck', ('arpeggio', ' arp ', ' arps ', ' pluck ', ' plucks ', 'harp', 'kalimba', 'mallet', 'bell', 'glock', 'chime')),
    ('pad', ('atmosphere', 'ambient', 'texture', 'textures', 'strings', 'drone', 'wash', ' pad ', ' pads ', ' atmos ')),
    ('lead', (' lead ', ' leads ', ' hook ', ' melody ', ' solo ', ' synth ')),
    ('guitar', (' guitar ', ' nylon ', ' acoustic ')),
)


def _normalize_semantic_family_text(value: Optional[str]) -> str:
    text = str(value or '').strip().lower()
    if not text:
        return ''
    text = text.replace('#', ' sharp ')
    text = text.replace('&', ' and ')
    text = re.sub(r'[^a-z0-9]+', ' ', text)
    text = re.sub(r'\s+', ' ', text).strip()
    return f" {text} " if text else ''


def _is_one_shot_source(*values: Optional[str]) -> bool:
    normalized = " ".join(
        str(value or "").strip().lower().replace("_", " ").replace("-", " ")
        for value in values
        if str(value or "").strip()
    )
    if not normalized:
        return False
    tokens = (
        "one shot",
        "one shots",
        "oneshot",
        "oneshots",
        "one shot vocal",
        "one shot vox",
    )
    return any(token in normalized for token in tokens)


def _score_piano_support_candidate(*values: Optional[str]) -> int:
    text = " ".join(_normalize_semantic_family_text(value).strip() for value in values if value)
    if not text:
        return 0
    score = 0
    if any(token in text for token in PIANO_SUPPORT_TOKENS):
        score += 3
    if ' loop ' in f" {text} ":
        score += 1
    return score


def _score_piano_melody_candidate(*values: Optional[str]) -> int:
    text = " ".join(_normalize_semantic_family_text(value).strip() for value in values if value)
    if not text:
        return 0
    padded = f" {text} "
    has_support = any(token in padded for token in PIANO_SUPPORT_TOKENS)
    has_melody = any(token in padded for token in PIANO_MELODY_TOKENS)
    if has_support and not has_melody:
        return 0
    score = 0
    if has_melody:
        score += 3
    if ' loop ' in padded:
        score += 1
    if ' piano ' in padded or ' keys ' in padded or ' rhodes ' in padded:
        score += 1
    return score


def _infer_semantic_family(*values: Optional[str]) -> str:
    for value in values:
        normalized = _normalize_semantic_family_text(value)
        if not normalized:
            continue
        for family, aliases in SEMANTIC_FAMILY_ALIASES:
            for alias in aliases:
                alias_normalized = _normalize_semantic_family_text(alias)
                if alias_normalized and alias_normalized in normalized:
                    return family
    return ''


def _normalize_family_name_token(value: Optional[str]) -> str:
    return str(value or '').strip().lower()


def _normalize_preferred_secondary_families(
    families: Optional[List[str]],
    primary_family: Optional[str] = None,
) -> List[str]:
    primary = _normalize_family_name_token(primary_family)
    normalized: List[str] = []
    for family in families or []:
        token = _normalize_family_name_token(family)
        if not token or token == primary or token in normalized:
            continue
        normalized.append(token)
    return normalized


def _derive_preferred_secondary_families(
    harmonic_instruments: Optional[Dict[str, Any]],
    primary_family: Optional[str],
) -> List[str]:
    """Prefer piano-like support families first, then other compatible secondaries."""
    primary = _normalize_family_name_token(primary_family)
    instruments = harmonic_instruments or {}
    preferred: List[str] = []

    for token in ('piano', 'keys', 'rhodes'):
        if token in instruments and token != primary and token not in preferred:
            preferred.append(token)

    if not preferred:
        for token in ('piano', 'keys'):
            if token != primary and token not in preferred:
                preferred.append(token)

    for token in instruments.keys():
        normalized = _normalize_family_name_token(token)
        if normalized and normalized != primary and normalized not in preferred:
            preferred.append(normalized)

    return preferred


def _get_piano_forward_bonus(
    role: str,
    candidate_family: Optional[str],
    preferred_secondary_families: Optional[List[str]] = None,
) -> Tuple[float, str]:
    """Return a piano-forward bonus only for piano-like families in support roles."""
    role_name = _normalize_family_name_token(role)
    family_name = _normalize_family_name_token(candidate_family)
    if role_name not in PIANO_FORWARD_ROLES:
        return 1.0, ""

    preferred = _normalize_preferred_secondary_families(preferred_secondary_families)
    eligible_piano_families = [family for family in preferred if family in PIANO_FAMILIES]
    if not eligible_piano_families:
        eligible_piano_families = ['piano', 'keys']

    if family_name in eligible_piano_families:
        return 1.4, "piano_bonus:1.4"
    return 1.0, ""


# ============================================================================
# HARMONIC COHERENCE VALIDATOR
# ============================================================================

class HarmonicCoherenceValidator:
    """
    Validates and scores layer coherence with reference.
    
    Ensures harmonic layers (synth, chords, pads, leads) maintain family
    coherence with the primary harmonic family identified from the reference.
    """
    
    # Roles that are CRITICAL for harmonic coherence
    CRITICAL_HARMONIC_ROLES = ['synth_loop', 'chords', 'lead', 'pad', 'harmonic_loop']
    
    # Family compatibility matrix - defines which families work well together
    FAMILY_COMPATIBILITY = {
        'pluck': ['pluck', 'keys', 'lead', 'bell', 'kalimba', 'harp'],
        'piano': ['piano', 'keys', 'rhodes', 'epiano', 'keyboard'],
        'pad': ['pad', 'atmosphere', 'ambient', 'texture', 'strings', 'drone'],
        'keys': ['keys', 'pluck', 'piano', 'bell', 'epiano'],
        'lead': ['lead', 'pluck', 'synth', 'bell'],
        'guitar': ['guitar', 'pluck', 'acoustic'],
        'atmosphere': ['atmosphere', 'pad', 'ambient', 'texture'],
        'bell': ['bell', 'pluck', 'keys', 'lead'],
        'rhodes': ['rhodes', 'piano', 'keys', 'epiano'],
        'strings': ['strings', 'pad', 'atmosphere'],
    }
    
    def __init__(self, primary_family: str, dominant_pack: str, target_key: str):
        self.primary_family = primary_family.lower() if primary_family else None
        self.dominant_pack = dominant_pack
        self.target_key = target_key
        self.logger = logging.getLogger("HarmonicCoherenceValidator")
        
    def validate_candidate(self, candidate: Dict[str, Any], role: str) -> Tuple[bool, float, List[str]]:
        """
        Validate candidate coherence. Returns (is_valid, score, reasons).
        
        Args:
            candidate: Sample candidate dictionary
            role: Role being selected for (e.g., 'synth_loop', 'bass_loop')
            
        Returns:
            Tuple of (is_valid, coherence_score, list_of_reasons)
        """
        score = 1.0
        reasons = []
        is_valid = True
        
        if not self.primary_family:
            # No primary family set, all candidates are neutral
            return True, 1.0, ["no_primary_family_set"]

        harmonic_role = role in self.CRITICAL_HARMONIC_ROLES or role in {'atmos_fx', 'atmos'}

        # 1. Check family compatibility
        candidate_family = self._extract_family(candidate.get('path', ''))
        candidate_family_lower = candidate_family.lower() if candidate_family else 'unknown'

        if harmonic_role:
            if candidate_family_lower == self.primary_family:
                score *= 1.5  # +50% for exact match
                reasons.append(f"family_exact_match:{candidate_family}")
            elif self._is_compatible_family(candidate_family_lower, self.primary_family):
                score *= 1.2  # +20% for compatible
                reasons.append(f"family_compatible:{candidate_family}_with_{self.primary_family}")
            else:
                score *= 0.5  # -50% for mismatch
                reasons.append(f"family_mismatch:{candidate_family}_vs_{self.primary_family}")

                # For harmonic roles, mismatches are critical
                if role in self.CRITICAL_HARMONIC_ROLES:
                    is_valid = False
                    reasons.append("CRITICAL:harmonic_role_family_mismatch")
        else:
            reasons.append("family_not_applicable:rhythmic_role")
        
        # 2. Check pack alignment
        candidate_pack = self._extract_pack(candidate.get('path', ''))
        if candidate_pack == self.dominant_pack:
            score *= 1.3  # +30% pack match
            reasons.append(f"pack_match:{candidate_pack}")
        elif self._is_sibling_pack(candidate_pack, self.dominant_pack):
            score *= 1.1  # +10% sibling pack
            reasons.append(f"pack_sibling:{candidate_pack}")
        else:
            score *= 0.7  # -30% pack mismatch
            reasons.append(f"pack_mismatch:{candidate_pack}_vs_{self.dominant_pack}")
        
        # 3. Check key compatibility
        candidate_key = candidate.get('key') or candidate.get('detected_key')
        if candidate_key:
            key_score, key_reason = self._score_key_compatibility(candidate_key)
            score *= key_score
            reasons.append(key_reason)
        
        # 4. Role-specific validation
        role_score, role_reason = self._validate_role_specific(candidate, role, candidate_family_lower)
        score *= role_score
        reasons.append(role_reason)
        
        # 5. Strict enforcement for critical roles
        if role in self.CRITICAL_HARMONIC_ROLES:
            enforce_valid, enforce_score, enforce_reason = self._enforce_harmonic_coherence(
                candidate, role, candidate_family_lower
            )
            if not enforce_valid:
                is_valid = False
                score *= enforce_score
                reasons.append(enforce_reason)
        
        return is_valid, score, reasons
    
    def _extract_family(self, sample_path: str) -> str:
        """Extract harmonic family hint from sample path."""
        family = _infer_semantic_family(sample_path)
        return family or 'unknown'
    
    def _extract_pack(self, sample_path: str) -> str:
        """Extract pack name from sample path."""
        return _extract_pack_from_path(sample_path)
    
    def _is_compatible_family(self, candidate_family: str, primary_family: str) -> bool:
        """Check if candidate family is compatible with primary family."""
        if candidate_family == primary_family:
            return True
        compatible = self.FAMILY_COMPATIBILITY.get(primary_family, [])
        return candidate_family in compatible
    
    def _is_sibling_pack(self, pack1: str, pack2: str) -> bool:
        """Check if two packs are siblings (same series/different variations)."""
        if pack1 == pack2:
            return True
        # Simple heuristic: check if they share common prefix
        pack1_lower = pack1.lower()
        pack2_lower = pack2.lower()
        common_prefix = 0
        for i in range(min(len(pack1_lower), len(pack2_lower))):
            if pack1_lower[i] == pack2_lower[i]:
                common_prefix += 1
            else:
                break
        # Consider siblings if they share >50% of name
        return common_prefix >= min(len(pack1_lower), len(pack2_lower)) * 0.5
    
    def _score_key_compatibility(self, candidate_key: str) -> Tuple[float, str]:
        """Score key compatibility between candidate and target."""
        if not self.target_key:
            return 1.0, "key:no_target_set"
        
        candidate_key = candidate_key.upper().replace('M', 'm')
        target_key = self.target_key.upper().replace('M', 'm')
        
        if candidate_key == target_key:
            return 1.2, f"key_match:{candidate_key}"
        
        # Check if compatible (relative major/minor, fifths, etc.)
        if self._is_compatible_key(candidate_key, target_key):
            return 1.0, f"key_compatible:{candidate_key}_with_{target_key}"
        else:
            return 0.6, f"key_conflict:{candidate_key}_vs_{target_key}"
    
    def _is_compatible_key(self, key1: str, key2: str) -> bool:
        """Check if two keys are harmonically compatible."""
        key1 = key1.upper().replace('M', 'm')
        key2 = key2.upper().replace('M', 'm')
        
        if key1 == key2:
            return True
        
        # Simple circle of fifths check
        circle_of_fifths_major = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'Db', 'Ab', 'Eb', 'Bb', 'F']
        circle_of_fifths_minor = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'Bbm', 'Fm', 'Cm', 'Gm', 'Dm']
        
        # Check if keys are adjacent on circle of fifths (within 1 position)
        def get_key_distance(k1, k2, circle):
            try:
                idx1 = circle.index(k1)
                idx2 = circle.index(k2)
                return abs(idx1 - idx2)
            except ValueError:
                return 999
        
        # Try major circle
        dist = get_key_distance(key1, key2, circle_of_fifths_major)
        if dist <= 2:
            return True
        
        # Try minor circle
        dist = get_key_distance(key1, key2, circle_of_fifths_minor)
        if dist <= 2:
            return True
        
        # Check relative major/minor
        relative_major_minor = {
            'C': 'Am', 'Am': 'C',
            'G': 'Em', 'Em': 'G',
            'D': 'Bm', 'Bm': 'D',
            'A': 'F#m', 'F#m': 'A',
            'E': 'C#m', 'C#m': 'E',
            'B': 'G#m', 'G#m': 'B',
            'F#': 'D#m', 'D#m': 'F#',
            'Db': 'Bbm', 'Bbm': 'Db',
            'Ab': 'Fm', 'Fm': 'Ab',
            'Eb': 'Cm', 'Cm': 'Eb',
            'Bb': 'Gm', 'Gm': 'Bb',
            'F': 'Dm', 'Dm': 'F',
        }
        
        if relative_major_minor.get(key1) == key2:
            return True
        
        return False
    
    def _validate_role_specific(self, candidate: Dict[str, Any], role: str, family: str) -> Tuple[float, str]:
        """Role-specific coherence checks."""
        family_lower = family.lower() if family else 'unknown'
        
        if role == 'synth_loop':
            # Synth loop must match primary family strongly
            if family_lower == self.primary_family:
                return 1.4, "synth:family_match"
            elif self._is_compatible_family(family_lower, self.primary_family):
                return 1.1, "synth:family_compatible"
            else:
                return 0.3, "synth:family_mismatch_CRITICAL"
        
        elif role == 'bass_loop':
            # Bass can be more flexible but prefer coherence
            return 1.0, "bass:flexible"
        
        elif role in ['chords', 'pad']:
            # Harmonic support should align
            if family_lower == self.primary_family:
                return 1.3, "harmonic:family_match"
            else:
                return 0.8, "harmonic:family_different"
        
        elif role == 'lead':
            # Lead can be flexible but prefer matching or compatible
            if family_lower == self.primary_family:
                return 1.2, "lead:family_match"
            elif self._is_compatible_family(family_lower, self.primary_family):
                return 1.0, "lead:family_compatible"
            else:
                return 0.7, "lead:family_mismatch"
        
        return 1.0, "default:neutral"
    
    def _enforce_harmonic_coherence(self, candidate: Dict[str, Any], role: str, family: str) -> Tuple[bool, float, str]:
        """Strict enforcement for harmonic layers."""
        family_lower = family.lower() if family else 'unknown'
        
        if role in self.CRITICAL_HARMONIC_ROLES:
            if family_lower != self.primary_family and not self._is_compatible_family(family_lower, self.primary_family):
                self.logger.error(
                    f"[HARMONIC_COHERENCE_VIOLATION] {role}: {family} vs primary {self.primary_family}"
                )
                # Reject unless no alternatives
                return False, 0.1, "REJECTED:harmonic_incoherence"
        
        return True, 1.0, "OK"
    
    def get_validation_summary(self) -> Dict[str, Any]:
        """Return summary of validator configuration."""
        return {
            'primary_family': self.primary_family,
            'dominant_pack': self.dominant_pack,
            'target_key': self.target_key,
            'critical_roles': self.CRITICAL_HARMONIC_ROLES,
            'compatibility_matrix_keys': list(self.FAMILY_COMPATIBILITY.keys()),
        }


class ReferenceAudioListener:
    # Improved role patterns with more comprehensive matching
    ROLE_PATTERNS = {
        'kick': ['**/*Kick*.wav', '**/*kick*.wav', '**/*KICK*.wav', '**/*Kick_*.wav', '**/*_Kick*.wav', '**/*BD*.wav', '**/*bd*.wav', '**/*bd_*.wav'],
        'snare': ['**/*Clap*Hit*.wav', '**/*Snare*.wav', '**/*snare*.wav', '**/*Clap*.wav', '**/*clap*.wav',
                  '**/*SNARE*.wav', '**/*CLAP*.wav', '**/*Clap_*.wav', '**/*Snare_*.wav', '**/*SD*.wav', '**/*sd*.wav'],
        'hat': ['**/*Closed Hat*.wav', '**/*Hat*.wav', '**/*hat*.wav', '**/*HAT*.wav', '**/*ClosedHat*.wav',
                '**/*Open Hat*.wav', '**/*OpenHat*.wav', '**/*cym*.wav', '**/*hihat*.wav', '**/*HiHat*.wav', '**/*HH*.wav', '**/*hh_*.wav'],
        'bass_loop': ['**/*Bass Loop*.wav', '**/*Bass_Loop*.wav', '**/*bass_loop*.wav', '**/*BassLoop*.wav',
                      '**/*BASS LOOP*.wav', '**/*Sub*Bass*.wav', '**/*Reese*.wav', '**/*808*.wav', '**/bass/*.wav'],
        'perc_loop': ['**/*Percussion Loop*.wav', '**/*Perc_Loop*.wav', '**/*perc_loop*.wav',
                      '**/*PercLoop*.wav', '**/*Perc*.wav', '**/*perc*.wav', '**/*Conga*.wav', '**/perc/*.wav'],
        'top_loop': ['**/*Top Loops*.wav', '**/*Top Loop*.wav', '**/*Full Drum*.wav', '**/*top_loop*.wav',
                     '**/*TopLoop*.wav', '**/*Drum Loop*.wav', '**/*DrumLoop*.wav', '**/*FullDrum*.wav', '**/hat/*.wav'],
        'synth_loop': ['**/*Synth Loop*.wav', '**/*Synth_Loop*.wav', '**/*synth_loop*.wav',
                       '**/*SynthLoop*.wav', '**/*Synth*.wav', '**/*synth*.wav', '**/*Chord*.wav', '**/*Pad*.wav', '**/synth/*.wav'],
        'vocal_loop': ['**/*Vocal Loop*.wav', '**/*Vox*.wav', '**/*vocal_loop*.wav', '**/*VocalLoop*.wav',
                       '**/*Vocal*.wav', '**/*vocal*.wav', '**/*VOCAL*.wav', '**/*VoxLoop*.wav', '**/*Chopped*.wav', '**/vocal/*.wav'],
        'crash_fx': ['**/*Crash*.wav', '**/*crash*.wav', '**/*CRASH*.wav', '**/*Impact*.wav', '**/*impact*.wav',
                     '**/*Cymbal*.wav', '**/*cymbal*.wav', '**/fx/*.wav'],
        'fill_fx': ['**/*Fill*.wav', '**/*fill*.wav', '**/*Tom Loop*.wav', '**/*Tom*.wav', '**/*tom*.wav',
                    '**/*Transition*.wav', '**/*FX*.wav'],
        'snare_roll': ['**/*Snareroll*.wav', '**/*Snare Roll*.wav', '**/*snare_roll*.wav', '**/*SnareRoll*.wav',
                       '**/*Roll*.wav', '**/*roll*.wav', '**/*Buildup*.wav'],
        'atmos_fx': ['**/*Atmos*.wav', '**/*atmos*.wav', '**/*Drone*.wav', '**/*drone*.wav', '**/*Ambient*.wav',
                     '**/*Noise*.wav', '**/*noise*.wav', '**/*Texture*.wav', '**/*Pad*.wav', '**/textures/*.wav'],
        'vocal_shot': ['**/*Vocal One Shot*.wav', '**/*Vocal Importante*.wav', '**/*vocal_shot*.wav',
                       '**/*VocalShot*.wav', '**/*OneShot*.wav', '**/*Shot*.wav', '**/*vocal chop*.wav'],
    }

    # Role bus assignments
    ROLE_TO_BUS = {
        'kick': 'drums', 'snare': 'drums', 'hat': 'drums',
        'bass_loop': 'bass',
        'perc_loop': 'drums', 'top_loop': 'drums',
        'synth_loop': 'music',
        'vocal_loop': 'vocal', 'vocal_shot': 'vocal',
        'crash_fx': 'fx', 'fill_fx': 'fx', 'snare_roll': 'fx', 'atmos_fx': 'fx',
    }

    # Patrones de exclusion fuerte por rol - estos NUNCA deben pasar
    ROLE_EXCLUSION_PATTERNS = {
        'kick': [
            'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix','demo', 'song', 'master', 'top loop', 'drum loop',
            'snare roll', 'fill', 'hat loop', 'vocal loop', 'complete kit','full kit', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'snare': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'snare roll', 'snare_roll', 'hat loop', 'kick loop',
            'top loop', 'drum loop', 'bass loop', 'complete kit', 'full kit','mixed', 'stems', 'bounce', 'preview'
        ],
        'hat': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'kick loop', 'snare loop', 'bass loop', 'vocal loop',
            'complete', 'full kit', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'bass_loop': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'top loop', 'vocal loop', 'vocal_loop', 'drum loop',
            'hat loop', 'snare loop', 'perc loop', 'fx loop', 'atmos','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'vocal_loop': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'one shot', 'oneshot', 'hit', 'stab', 'drum loop',
            'bass loop', 'top loop', 'hat loop', 'kick', 'snare','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'top_loop': [
            'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop','demo', 'song', 'master','synth loop', 'pad', 'atmos', 'riser', 'downlifter','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'fill_fx': [
            'kick', 'snare', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'loop', 'groove','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'snare_roll': [
            'kick', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'atmos', 'pad','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'atmos_fx': [
            'kick', 'snare', 'hat', 'clap', 'bass','full mix', 'demo', 'song', 'master', 'drum loop','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'synth_loop': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'drum loop', 'vocal loop', 'bass loop','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'crash_fx': [
            'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
        'vocal_shot': [
            'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview'
        ],
    }

    def __init__(self, library_dir: str, cache_path: Optional[str] = None):
        self.library_dir = Path(library_dir)
        self.cache_path = Path(cache_path) if cache_path else self.library_dir / ".reference_audio_cache.json"
        self.segment_index_dir = self.library_dir / ".segment_rag"
        self.segment_index_dir.mkdir(parents=True, exist_ok=True)
        self._cache: Dict[str, Any] = self._load_cache()
        self.device, self.device_name = self._resolve_device()
        self._recent_paths = deque(maxlen=64)  # Increased from 48
        self._recent_families = deque(maxlen=32)  # Increased from 24
        self._family_usage_count: Dict[str, int] = {}  # Track family usage for progressive penalty
        self._section_detector = SectionDetector()  # New section detector
        self.sample_index_path = self.library_dir / ".sample_index.json"
        self.vector_store_dir = self.library_dir.parent / "vector_store"
        self._sample_index_by_path = self._load_sample_index_metadata()
        self._vector_store_meta_by_path, self._vector_store_meta_by_name = self._load_vector_store_metadata()

    @staticmethod
    def _name_contains_any(name: str, tokens: Tuple[str, ...]) -> bool:
        return any(token in name for token in tokens)

    @staticmethod
    def _name_contains_none(name: str, tokens: Tuple[str, ...]) -> bool:
        return not any(token in name for token in tokens)

    def _resolve_device(self):
        if torch is not None and torch_directml is not None:
            try:
                return torch_directml.device(), "directml"
            except Exception:
                pass
        if torch is not None:
            return torch.device("cpu"), "cpu"
        return None, "numpy"

    def _load_cache(self) -> Dict[str, Any]:
        if not self.cache_path.exists():
            return {}
        try:
            return json.loads(self.cache_path.read_text(encoding="utf-8"))
        except Exception:
            return {}

    def _save_cache(self) -> None:
        try:
            self.cache_path.write_text(json.dumps(self._cache, indent=2), encoding="utf-8")
        except Exception:
            pass

    def _cache_key(self, path: Path) -> str:
        return str(path.resolve()).lower()

    def _fingerprint(self, path: Path) -> str:
        stat = path.stat()
        return f"{stat.st_size}:{stat.st_mtime_ns}"

    def _analysis_cache_key(self, path: Path, duration_limit: Optional[float] = None) -> str:
        suffix = "full" if duration_limit is None else f"{float(duration_limit):.3f}"
        return f"{self._cache_key(path)}|{suffix}"

    def _segment_index_cache_prefix(self, path: Path, windows: set) -> str:
        path_key = hashlib.sha1(self._cache_key(path).encode("utf-8")).hexdigest()[:16]
        fingerprint = hashlib.sha1(self._fingerprint(path).encode("utf-8")).hexdigest()[:12]
        windows_key = "-".join(f"{float(item):.2f}" for item in sorted(float(value) for value in windows)) or "full"
        return f"{path_key}__{fingerprint}__{windows_key}"

    def _segment_index_cache_path(self, path: Path, windows: set, duration_limit: float) -> Path:
        prefix = self._segment_index_cache_prefix(path, windows)
        duration_key = f"{float(duration_limit):.2f}"
        return self.segment_index_dir / f"{prefix}__{duration_key}.json.gz"

    def _get_segment_rag_state_path(self) -> Path:
        """Get the path to the segment RAG indexing state file."""
        return self.segment_index_dir / "indexing_state.json"

    def _save_segment_rag_state(self, state: Dict[str, Any]) -> None:
        """Save segment RAG indexing state to disk."""
        state_path = self._get_segment_rag_state_path()
        state_path.parent.mkdir(parents=True, exist_ok=True)
        with open(state_path, "w", encoding="utf-8") as f:
            json.dump(state, f, indent=2)

    def _load_segment_rag_state(self) -> Dict[str, Any]:
        """Load segment RAG indexing state from disk."""
        state_path = self._get_segment_rag_state_path()
        if not state_path.exists():
            return {}
        try:
            with open(state_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            logger.warning("Failed to load segment RAG state, starting fresh", exc_info=True)
            return {}

    def _load_segment_bank_from_disk(self, path: Path, windows: set, duration_limit: float) -> List[Dict[str, Any]]:
        cache_path = self._segment_index_cache_path(path, windows, duration_limit)
        candidate_paths = [cache_path]
        if not cache_path.exists():
            prefix = self._segment_index_cache_prefix(path, windows)
            candidate_paths = sorted(self.segment_index_dir.glob(f"{prefix}__*.json.gz"), reverse=True)
            if not candidate_paths:
                return []
        try:
            for candidate_path in candidate_paths:
                with gzip.open(candidate_path, "rt", encoding="utf-8") as handle:
                    payload = json.load(handle)
                # Handle new format with metadata
                if isinstance(payload, dict):
                    return payload.get("segments", []) or []
                # Handle old format (list of segments)
                if isinstance(payload, list):
                    return payload
        except Exception:
            logger.debug("Failed to load segment cache for %s", path, exc_info=True)
        return []

    def _save_segment_bank_to_disk(self, path: Path, windows: set, duration_limit: float, bank: List[Dict[str, Any]], metadata: Optional[Dict[str, Any]] = None) -> None:
        cache_path = self._segment_index_cache_path(path, windows, duration_limit)
        try:
            payload: Dict[str, Any] = {"segments": bank}
            if metadata:
                payload["metadata"] = {
                    "file_name": metadata.get("file_name") or path.name,
                    "path": metadata.get("path") or str(path),
                    "roles": metadata.get("roles") or [],
                    "windows": sorted(float(w) for w in windows) if windows else [],
                    "duration_limit": float(duration_limit),
                    "indexed_at": time.time(),
                }
            with gzip.open(cache_path, "wt", encoding="utf-8") as handle:
                json.dump(payload, handle)
        except Exception:
            logger.debug("Failed to save segment cache for %s", path, exc_info=True)

    def _load_vector_store_metadata(self) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
        by_path: Dict[str, Dict[str, Any]] = {}
        by_name: Dict[str, Dict[str, Any]] = {}
        metadata_path = self.vector_store_dir / "metadata.json"
        if not metadata_path.exists():
            return by_path, by_name

        try:
            payload = json.loads(metadata_path.read_text(encoding="utf-8"))
        except Exception as exc:
            logger.debug("No se pudo leer metadata del vector store: %s", exc)
            return by_path, by_name

        for item in payload if isinstance(payload, list) else []:
            if not isinstance(item, dict):
                continue
            file_name = str(item.get("filename", "") or "").strip().lower()
            actual_path = self.library_dir / str(item.get("filename", "") or "")
            if not actual_path.exists():
                actual_path = self.library_dir / Path(str(item.get("path", "") or "")).name
            if not actual_path.exists():
                continue
            normalized = str(actual_path.resolve()).lower()
            normalized_item = dict(item)
            normalized_item["resolved_path"] = str(actual_path)
            by_path[normalized] = normalized_item
            if file_name and file_name not in by_name:
                by_name[file_name] = normalized_item
        return by_path, by_name

    def _load_sample_index_metadata(self) -> Dict[str, Dict[str, Any]]:
        if not self.sample_index_path.exists():
            return {}

        try:
            payload = json.loads(self.sample_index_path.read_text(encoding="utf-8"))
        except Exception as exc:
            logger.debug("No se pudo leer sample index: %s", exc)
            return {}

        entries = payload.get("samples", []) if isinstance(payload, dict) else []
        by_path: Dict[str, Dict[str, Any]] = {}
        for item in entries if isinstance(entries, list) else []:
            if not isinstance(item, dict):
                continue
            file_path = Path(str(item.get("path", "") or ""))
            if not file_path.exists():
                continue
            by_path[str(file_path.resolve()).lower()] = dict(item)
        return by_path

    def _build_blocks(self, rms: np.ndarray, onset: np.ndarray, sr: int,
                      hop_length: int = 512, block_seconds: float = 8.0) -> List[Dict[str, float]]:
        block_size = max(1, int(round(block_seconds * sr / hop_length)))
        blocks: List[Dict[str, float]] = []
        for index in range(0, len(rms), block_size):
            block_rms = rms[index:index + block_size]
            block_onset = onset[index:index + block_size]
            if len(block_rms) == 0:
                continue
            start = index * hop_length / sr
            end = min(len(rms) * hop_length / sr, (index + block_size) * hop_length / sr)
            blocks.append({
                "start": round(float(start), 3),
                "end": round(float(end), 3),
                "rms": round(float(np.mean(block_rms)), 6),
                "onset": round(float(np.mean(block_onset)), 6),
                "energy": round(float(np.mean(block_rms) * 0.65 + np.mean(block_onset) * 0.35), 6),
            })
        return blocks

    def _vectorize_analysis(self, analysis: Dict[str, Any]) -> List[float]:
        chroma = list(analysis.get("chroma", [0.0] * 12))
        return [
            float(analysis.get("tempo", 0.0)) / 180.0,
            min(float(analysis.get("duration", 0.0)), 240.0) / 240.0,
            float(analysis.get("rms_mean", 0.0)),
            float(analysis.get("rms_std", 0.0)),
            min(float(analysis.get("onset_mean", 0.0)), 8.0) / 8.0,
            min(float(analysis.get("onset_std", 0.0)), 8.0) / 8.0,
            min(float(analysis.get("spectral_centroid", 0.0)), 10000.0) / 10000.0,
            min(float(analysis.get("spectral_rolloff", 0.0)), 14000.0) / 14000.0,
        ] + chroma

    def _compute_audio_descriptor(
        self,
        y: np.ndarray,
        sr: int,
        tempo_hint: float = 0.0,
        duration_hint: float = 0.0,
    ) -> Dict[str, Any]:
        y = np.asarray(y, dtype=np.float32)
        if y.size == 0:
            return {
                "deep_vector": [0.0] * 53,
                "harmonic_ratio": 0.5,
                "percussive_ratio": 0.5,
                "spectral_bandwidth": 0.0,
                "spectral_bandwidth_std": 0.0,
                "spectral_flatness": 0.0,
                "spectral_flatness_std": 0.0,
                "zero_crossing_rate": 0.0,
                "zero_crossing_rate_std": 0.0,
                "mfcc": [0.0] * 13,
                "spectral_contrast": [0.0] * 7,
            }

        if y.size < 512:
            y = np.pad(y, (0, 512 - y.size))

        hop_length = 256 if y.size < sr * 2 else 512
        n_fft = _adaptive_n_fft(len(y), default_n_fft=2048, min_n_fft=256)

        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
            rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
            centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0]
            rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0]
            bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0]
            flatness = librosa.feature.spectral_flatness(y=y, n_fft=n_fft, hop_length=hop_length)[0]
            zcr = librosa.feature.zero_crossing_rate(y, hop_length=hop_length)[0]
            try:
                chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
            except Exception:
                chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)
            mfcc = librosa.feature.mfcc(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, n_mfcc=13)
            contrast = librosa.feature.spectral_contrast(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)

        try:
            harmonic, percussive = librosa.effects.hpss(y)
            total_energy = float(np.sum(np.abs(y))) or 1.0
            harmonic_ratio = float(np.sum(np.abs(harmonic)) / total_energy)
            percussive_ratio = float(np.sum(np.abs(percussive)) / total_energy)
        except Exception:
            harmonic_ratio = 0.5
            percussive_ratio = 0.5

        chroma_avg = _normalize_chroma(np.mean(chroma, axis=1))
        mfcc_avg = np.mean(mfcc, axis=1)
        contrast_avg = np.mean(contrast, axis=1)
        duration = float(duration_hint or librosa.get_duration(y=y, sr=sr))

        deep_vector = [
            min(float(tempo_hint or 0.0), 220.0) / 220.0,
            min(duration, 240.0) / 240.0,
            min(float(np.mean(rms)), 1.0),
            min(float(np.std(rms)), 1.0),
            min(float(np.mean(onset_env)), 8.0) / 8.0,
            min(float(np.std(onset_env)), 8.0) / 8.0,
            min(float(np.mean(centroid)), 12000.0) / 12000.0,
            min(float(np.std(centroid)), 6000.0) / 6000.0,
            min(float(np.mean(rolloff)), 16000.0) / 16000.0,
            min(float(np.std(rolloff)), 8000.0) / 8000.0,
            min(float(np.mean(bandwidth)), 8000.0) / 8000.0,
            min(float(np.std(bandwidth)), 4000.0) / 4000.0,
            min(float(np.mean(flatness)), 1.0),
            min(float(np.std(flatness)), 1.0),
            min(float(np.mean(zcr)), 1.0),
            min(float(np.std(zcr)), 1.0),
            min(max(harmonic_ratio, 0.0), 1.0),
            min(max(percussive_ratio, 0.0), 1.0),
        ] + [float(item) for item in chroma_avg.tolist()] \
            + [float(np.clip(item / 100.0, -1.0, 1.0)) for item in mfcc_avg.tolist()] \
            + [min(float(item), 80.0) / 80.0 for item in contrast_avg.tolist()]

        return {
            "deep_vector": [round(float(item), 6) for item in deep_vector],
            "harmonic_ratio": round(float(harmonic_ratio), 6),
            "percussive_ratio": round(float(percussive_ratio), 6),
            "spectral_bandwidth": round(float(np.mean(bandwidth)), 3),
            "spectral_bandwidth_std": round(float(np.std(bandwidth)), 3),
            "spectral_flatness": round(float(np.mean(flatness)), 6),
            "spectral_flatness_std": round(float(np.std(flatness)), 6),
            "zero_crossing_rate": round(float(np.mean(zcr)), 6),
            "zero_crossing_rate_std": round(float(np.std(zcr)), 6),
            "mfcc": [round(float(item), 6) for item in mfcc_avg.tolist()],
            "spectral_contrast": [round(float(item), 6) for item in contrast_avg.tolist()],
        }

    def _section_kind_at_time(self, sections: List[Dict[str, Any]], seconds: float) -> str:
        for section in sections:
            start = float(section.get("start", 0.0) or 0.0)
            end = float(section.get("end", start) or start)
            if start <= seconds < end:
                return str(section.get("kind", "verse") or "verse").lower()
        return str(sections[-1].get("kind", "verse") if sections else "verse").lower()

    def _build_reference_segment_bank(
        self,
        reference_path: str,
        reference: Dict[str, Any],
        sections: List[Dict[str, Any]],
    ) -> List[Dict[str, Any]]:
        path = Path(reference_path)
        fingerprint = self._fingerprint(path)
        cache_key = f"segments::{self._cache_key(path)}::{fingerprint}"
        cached = self._cache.get(cache_key)
        if isinstance(cached, list) and cached:
            return cached

        y, sr = librosa.load(str(path), sr=22050, mono=True)
        duration = float(librosa.get_duration(y=y, sr=sr))
        tempo = float(reference.get("tempo", 0.0) or 0.0)
        bank: List[Dict[str, Any]] = []

        for window_seconds in (1.0, 2.0, 4.0, 8.0):
            hop_seconds = max(0.25, window_seconds / 2.0)
            cursor = 0.0
            while cursor + 0.25 <= duration:
                end = min(duration, cursor + window_seconds)
                start_sample = int(cursor * sr)
                end_sample = max(start_sample + 256, int(end * sr))
                segment_audio = y[start_sample:end_sample]
                if segment_audio.size < 256:
                    cursor += hop_seconds
                    continue
                descriptor = self._compute_audio_descriptor(
                    segment_audio,
                    sr,
                    tempo_hint=tempo,
                    duration_hint=end - cursor,
                )
                midpoint = cursor + ((end - cursor) / 2.0)
                bank.append({
                    "start": round(float(cursor), 3),
                    "end": round(float(end), 3),
                    "window_seconds": round(float(end - cursor), 3),
                    "kind": self._section_kind_at_time(sections, midpoint),
                    "vector": descriptor.get("deep_vector", []),
                    "rms_mean": descriptor.get("deep_vector", [0.0, 0.0, 0.0])[2] if descriptor.get("deep_vector") else 0.0,
                    "onset_mean": descriptor.get("deep_vector", [0.0] * 5)[4] if descriptor.get("deep_vector") else 0.0,
                    "spectral_centroid": round(float(descriptor.get("deep_vector", [0.0] * 7)[6] * 12000.0), 3) if descriptor.get("deep_vector") else 0.0,
                    "spectral_rolloff": round(float(descriptor.get("deep_vector", [0.0] * 9)[8] * 16000.0), 3) if descriptor.get("deep_vector") else 0.0,
                    "harmonic_ratio": descriptor.get("harmonic_ratio", 0.5),
                    "percussive_ratio": descriptor.get("percussive_ratio", 0.5),
                    "spectral_flatness": descriptor.get("spectral_flatness", 0.0),
                    "zero_crossing_rate": descriptor.get("zero_crossing_rate", 0.0),
                })
                cursor += hop_seconds

        self._cache[cache_key] = bank
        self._save_cache()
        return bank

    def _build_candidate_segment_bank(
        self,
        candidate_path: str,
        windows: set,
        duration_limit: float = 32.0,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> List[Dict[str, Any]]:
        path = Path(candidate_path)
        if not path.exists():
            return []

        fingerprint = self._fingerprint(path)
        windows_key = ",".join(str(item) for item in sorted(float(value) for value in windows)) or "full"
        cache_key = f"candidate_segments::{self._cache_key(path)}::{fingerprint}::{windows_key}::{float(duration_limit):.3f}"
        cached = self._cache.get(cache_key)
        if isinstance(cached, list) and cached:
            return cached
        disk_cached = self._load_segment_bank_from_disk(path, windows, duration_limit)
        if disk_cached:
            self._cache[cache_key] = disk_cached
            return disk_cached

        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit)

        file_duration = float(librosa.get_duration(y=y, sr=sr))
        bank: List[Dict[str, Any]] = []

        for window_seconds in sorted(float(value) for value in windows if float(value) > 0.0):
            if file_duration <= 0.0:
                continue
            hop_seconds = max(0.25, window_seconds / 2.0)
            cursor = 0.0
            while cursor + 0.25 <= file_duration:
                end = min(file_duration, cursor + window_seconds)
                start_sample = int(cursor * sr)
                end_sample = max(start_sample + 256, int(end * sr))
                segment_audio = y[start_sample:end_sample]
                if segment_audio.size < 256:
                    cursor += hop_seconds
                    continue
                descriptor = self._compute_audio_descriptor(
                    segment_audio,
                    sr,
                    duration_hint=end - cursor,
                )
                bank.append({
                    "start": round(float(cursor), 3),
                    "end": round(float(end), 3),
                    "window_seconds": round(float(end - cursor), 3),
                    "vector": descriptor.get("deep_vector", []),
                })
                cursor += hop_seconds

        self._cache[cache_key] = bank
        self._save_segment_bank_to_disk(path, windows, duration_limit, bank, metadata=metadata)
        self._save_cache()
        return bank

    def analyze_file(self, file_path: str, duration_limit: Optional[float] = None) -> Dict[str, Any]:
        if librosa is None:
            raise RuntimeError("librosa no estÃƒÂ¡ disponible")

        path = Path(file_path)
        cache_key = self._analysis_cache_key(path, duration_limit)
        legacy_key = self._cache_key(path)
        fingerprint = self._fingerprint(path)
        cached = self._cache.get(cache_key)
        if not isinstance(cached, dict) and duration_limit is None:
            cached = self._cache.get(legacy_key)
        if isinstance(cached, dict) and cached.get("fingerprint") == fingerprint:
            return dict(cached["analysis"])

        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit)
            hop_length = 512
            n_fft = _adaptive_n_fft(len(y))
            onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
            rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
            centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0]
            rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft)[0]
            try:
                chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
            except Exception:
                chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)
        chroma_avg = _normalize_chroma(np.mean(chroma, axis=1))
        key, key_score = _detect_key(chroma_avg)
        tempo = _safe_float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median))

        analysis = {
            "path": str(path),
            "file_name": path.name,
            "duration": round(float(librosa.get_duration(y=y, sr=sr)), 3),
            "tempo": round(float(tempo), 3),
            "key": key,
            "key_confidence": round(float(key_score), 6),
            "rms_mean": round(float(np.mean(rms)), 6),
            "rms_std": round(float(np.std(rms)), 6),
            "onset_mean": round(float(np.mean(onset_env)), 6),
            "onset_std": round(float(np.std(onset_env)), 6),
            "spectral_centroid": round(float(np.mean(centroid)), 3),
            "spectral_rolloff": round(float(np.mean(rolloff)), 3),
            "chroma": [round(float(item), 6) for item in chroma_avg.tolist()],
            "blocks": self._build_blocks(rms, onset_env, sr, hop_length=hop_length),
        }
        analysis["vector"] = self._vectorize_analysis(analysis)
        analysis.update(self._compute_audio_descriptor(
            y,
            sr,
            tempo_hint=float(analysis.get("tempo", 0.0) or 0.0),
            duration_hint=float(analysis.get("duration", 0.0) or 0.0),
        ))

        self._cache[cache_key] = {
            "fingerprint": fingerprint,
            "analysis": analysis,
        }
        if duration_limit is None:
            self._cache[legacy_key] = self._cache[cache_key]
        self._save_cache()
        return dict(analysis)

    def analyze_reference(self, reference_path: str) -> Dict[str, Any]:
        global _reference_spectral_profile, _reference_perc_centroid, _reference_bass_centroid
        
        analysis = self.analyze_file(reference_path)
        energies = [float(block.get("energy", 0.0)) for block in analysis.get("blocks", [])]
        if energies:
            max_energy = max(energies) or 1.0
            for block in analysis["blocks"]:
                block["energy_norm"] = round(float(block["energy"]) / max_energy, 6)
        analysis["device"] = self.device_name
        
        # T031-T037: Spectral analysis integration
        if SPECTRAL_ENGINE_AVAILABLE:
            try:
                eng = get_spectral_engine()
                profile = eng.analyze(reference_path)
                if profile:
                    # T031: Store spectral profile
                    _reference_spectral_profile = {
                        "centroid_mean": profile.centroid_mean,
                        "rolloff_85": profile.rolloff_85,
                        "spectral_flatness": profile.spectral_flatness,
                        "rms": profile.rms,
                        "duration": profile.duration,
                    }
                    analysis["spectral_profile"] = _reference_spectral_profile
                    
                    # T035-T036: Compute per-stem centroids from blocks
                    blocks = analysis.get("blocks", [])
                    perc_centroids = []
                    bass_centroids = []
                    
                    for block in blocks:
                        block_centroid = float(block.get("spectral_centroid", 0.0) or 0.0)
                        block_energy = float(block.get("energy", 0.0) or 0.0)
                        roles = block.get("roles", [])
                        
                        if block_energy > 0.1:
                            if any(r in ("hat", "perc", "top") for r in roles):
                                perc_centroids.append(block_centroid)
                            elif any(r in ("bass", "kick", "sub") for r in roles):
                                bass_centroids.append(block_centroid)
                    
                    if perc_centroids:
                        _reference_perc_centroid = sum(perc_centroids) / len(perc_centroids)
                        analysis["reference_perc_centroid"] = _reference_perc_centroid
                    
                    if bass_centroids:
                        _reference_bass_centroid = sum(bass_centroids) / len(bass_centroids)
                        analysis["reference_bass_centroid"] = _reference_bass_centroid
                    
            except Exception as e:
                logger.warning(f"[SPECTRAL] Error analyzing reference: {e}")
        
        return analysis

    def _is_excluded_full_track(self, path: Path, sample_meta: Optional[Dict[str, Any]], vector_meta: Optional[Dict[str, Any]]) -> bool:
        vector_type = str((vector_meta or {}).get("type", "") or "").lower()
        if vector_type == "full_track":
            return True

        duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0)
        category = str((sample_meta or {}).get("category", "") or "").lower()
        if path.suffix.lower() == ".mp3" and duration_estimate >= 45.0:
            return True

        if path.suffix.lower() == ".mp3" and path.exists():
            size_bytes = int((vector_meta or {}).get("size_bytes", (sample_meta or {}).get("size", 0)) or 0)
            if size_bytes >= 5_000_000 and category in {"unknown", "loop"}:
                return True
        return False

    def _duration_estimate(
        self,
        path: Path,
        sample_meta: Optional[Dict[str, Any]],
        vector_meta: Optional[Dict[str, Any]],
    ) -> float:
        duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0)
        if duration_estimate > 0.0:
            return duration_estimate
        sample_duration = float((sample_meta or {}).get("duration", 0.0) or 0.0)
        if sample_duration > 0.0:
            return sample_duration
        sample_size = int((sample_meta or {}).get("size", 0) or 0)
        if sample_size > 0:
            return min(32.0, max(0.1, sample_size / 176400.0))
        try:
            return min(32.0, max(0.1, path.stat().st_size / 176400.0))
        except Exception:
            return 0.0

    def _catalog_role_match(
        self,
        role: str,
        path: Path,
        sample_meta: Optional[Dict[str, Any]],
        vector_meta: Optional[Dict[str, Any]],
    ) -> bool:
        if self._is_excluded_full_track(path, sample_meta, vector_meta):
            return False

        name = path.name
        stem = path.stem.lower()
        name_match = self._matches_role_name(role, name)

        category = str((sample_meta or {}).get("category", "") or "").lower()
        vector_type = str((vector_meta or {}).get("type", "") or "").lower()
        duration_estimate = self._duration_estimate(path, sample_meta, vector_meta)
        one_shot_source = _is_one_shot_source(path.as_posix(), stem, category, vector_type)

        role_categories = {
            "kick": {"kick"},
            "snare": {"snare", "clap"},
            "hat": {"hat"},
            "bass_loop": {"bass"},
            "perc_loop": {"perc"},
            "top_loop": {"loop"},
            "synth_loop": {"synth"},
            "vocal_loop": {"vocal"},
            "crash_fx": {"fx"},
            "fill_fx": {"fx"},
            "snare_roll": {"fx"},
            "atmos_fx": {"fx", "synth"},
            "vocal_shot": {"vocal"},
        }
        role_types = ROLE_VECTOR_TYPES.get(role, set())
        min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role, (0.0, 999.0))
        duration_ok = duration_estimate <= 0.0 or (min_dur <= duration_estimate <= max_dur)
        loopish_name = self._name_contains_any(stem, ("loop", "groove", "full drum", "full mix", "drum loop", "top loop"))

        if name_match:
            return duration_ok
        if role in {"kick", "snare", "hat"}:
            return bool(category and category in role_categories.get(role, set()) and duration_ok)
        if role == "bass_loop":
            if one_shot_source:
                return False
            if category == "bass" and duration_ok:
                return True
            if vector_type and vector_type in role_types and duration_ok and self._name_contains_none(stem, ("drum loop", "full mix", "top loop", "vocal")):
                return True
            return False
        if role == "perc_loop":
            if one_shot_source and not loopish_name:
                return False
            if category == "perc" and duration_ok and loopish_name:
                return True
            if vector_type and vector_type in role_types and duration_ok and loopish_name:
                return True
            return False
        if role == "top_loop":
            if one_shot_source and not loopish_name:
                return False
            if category == "loop" and duration_ok and loopish_name and self._name_contains_none(stem, ("bass loop", "vocal", "synth loop")):
                return True
            if vector_type and vector_type in role_types and duration_ok and loopish_name:
                return True
            return False
        if role == "synth_loop":
            synthish_name = self._name_contains_any(stem, ("synth", "lead", "hook", "pluck", "pad", "chord", "arp", "melod"))
            non_vocal = self._name_contains_none(stem, ("vocal", "vox", "acapella", "phrase", "chant"))
            if one_shot_source and not loopish_name:
                return False
            if category == "synth" and duration_ok and synthish_name and non_vocal:
                return True
            if vector_type and vector_type in role_types and duration_ok and synthish_name and non_vocal:
                return True
            return False
        if role == "vocal_loop":
            if one_shot_source:
                return False
            vocalish_loop = self._name_contains_any(stem, ("vocal loop", "vox", "acapella", "chant", "phrase", "vocal"))
            if category == "vocal" and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")):
                return True
            if vector_type and vector_type in role_types and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")):
                return True
            return False
        if role == "crash_fx":
            return False
        if role == "fill_fx":
            if category == "fx" and duration_ok and self._name_contains_any(stem, ("fill", "transition", "tom loop", "drum fill", "break fill")):
                return True
            return False
        if role == "snare_roll":
            if category == "fx" and duration_ok and self._name_contains_any(stem, ("snareroll", "snare roll", "roll", "buildup")):
                return True
            return False
        if role == "atmos_fx":
            atmosish_name = self._name_contains_any(stem, ("atmos", "drone", "ambient", "noise", "texture", "downfilter", "sweep", "wash"))
            if category in role_categories.get(role, set()) and duration_ok and atmosish_name:
                return True
            if vector_type and vector_type in role_types and duration_ok and atmosish_name:
                return True
            return False
        if role == "vocal_shot":
            if category == "vocal" and duration_ok and duration_estimate <= 3.0:
                return True
            if vector_type in role_types and duration_ok and duration_estimate <= 3.0:
                return True
            return False
        return False

    def prewarm_library_matching_cache(
        self,
        roles: Optional[List[str]] = None,
        max_files: Optional[int] = None,
        duration_limit: float = 32.0,
    ) -> Dict[str, Any]:
        target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS]
        assets = self._list_assets()
        windows_by_role = {
            role: set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set())
            for role in target_roles
        }

        files: Dict[str, Tuple[Path, set]] = {}
        for role in target_roles:
            for file_path in assets.get(role, []):
                normalized = str(file_path.resolve()).lower()
                if normalized not in files:
                    files[normalized] = (file_path, set())
                files[normalized][1].update(windows_by_role.get(role, set()))

        ordered_files = list(files.values())
        if max_files is not None:
            ordered_files = ordered_files[: max(0, int(max_files))]

        analyzed = 0
        segmented = 0
        errors = 0
        for file_path, windows in ordered_files:
            try:
                analysis = self.analyze_file(str(file_path), duration_limit=duration_limit)
                analyzed += 1
                if float(analysis.get("duration", 0.0) or 0.0) > max(windows or {4.0}) * 1.5:
                    self._build_candidate_segment_bank(str(file_path), windows or {4.0}, duration_limit=duration_limit)
                    segmented += 1
            except Exception:
                errors += 1

        return {
            "roles": target_roles,
            "files_considered": len(ordered_files),
            "analyzed": analyzed,
            "segmented": segmented,
            "errors": errors,
            "cache_path": str(self.cache_path),
            "device": self.device_name,
        }

    def build_segment_rag_index(
        self,
        roles: Optional[List[str]] = None,
        max_files: Optional[int] = None,
        duration_limit: float = 32.0,
        force: bool = False,
        offset: int = 0,
        batch_size: Optional[int] = None,
        resume: bool = False,
    ) -> Dict[str, Any]:
        target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS]
        assets = self._list_assets()
        files: Dict[str, Dict[str, Any]] = {}

        for role in target_roles:
            for file_path in assets.get(role, []):
                normalized = str(file_path.resolve()).lower()
                if normalized not in files:
                    files[normalized] = {
                        "path": file_path,
                        "roles": set(),
                        "windows": set(),
                    }
                files[normalized]["roles"].add(role)
                files[normalized]["windows"].update(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set())

        ordered_files = sorted(
            files.values(),
            key=lambda item: (
                -len(item["roles"]),
                -sum(float(value) for value in item["windows"]),
                item["path"].name.lower(),
            ),
        )

        state = self._load_segment_rag_state()
        indexed_entries = dict(state.get("indexed_entries", {}) or {})
        if resume:
            indexed_paths = set(state.get("indexed_paths", []) or [])
            if indexed_paths:
                before_resume = len(ordered_files)
                ordered_files = [
                    entry for entry in ordered_files
                    if str(entry["path"].resolve()).lower() not in indexed_paths
                ]
                logger.info(
                    "Resume mode: skipped %d already indexed files, %d remaining",
                    before_resume - len(ordered_files),
                    len(ordered_files),
                )

        total_available = len(ordered_files)
        if offset > 0:
            ordered_files = ordered_files[offset:]

        limit = batch_size if batch_size is not None else max_files
        if limit is not None:
            ordered_files = ordered_files[: max(0, int(limit))]

        files_remaining = max(0, total_available - offset - len(ordered_files))

        built = 0
        reused = 0
        skipped = 0
        errors = 0
        total_segments = 0
        manifest: List[Dict[str, Any]] = []

        for entry in ordered_files:
            path = entry["path"]
            windows = entry["windows"] or {4.0}
            normalized = str(path.resolve()).lower()
            sample_meta = self._sample_index_by_path.get(normalized)
            vector_meta = self._vector_store_meta_by_path.get(normalized)
            estimated_duration = self._duration_estimate(path, sample_meta, vector_meta)
            effective_duration_limit = min(max(estimated_duration, 0.5), duration_limit) if estimated_duration > 0.0 else duration_limit
            disk_cached = self._load_segment_bank_from_disk(path, windows, effective_duration_limit)
            cache_prefix = self._segment_index_cache_prefix(path, windows)
            if disk_cached and not force:
                reused += 1
                total_segments += len(disk_cached)
                entry_report = {
                    "file_name": path.name,
                    "path": str(path),
                    "roles": sorted(entry["roles"]),
                    "segments": len(disk_cached),
                    "cached": True,
                    "cache_prefix": cache_prefix,
                }
                manifest.append(entry_report)
                indexed_entries[normalized] = entry_report
                continue
            try:
                analysis = self.analyze_file(str(path), duration_limit=duration_limit)
                duration = float(analysis.get("duration", 0.0) or 0.0)
                if duration < 0.5:
                    skipped += 1
                    continue
                segment_metadata = {
                    "file_name": path.name,
                    "path": str(path),
                    "roles": sorted(entry["roles"]),
                }
                bank = self._build_candidate_segment_bank(str(path), windows, duration_limit=min(max(duration, 0.5), duration_limit), metadata=segment_metadata)
                built += 1
                total_segments += len(bank)
                entry_report = {
                    "file_name": path.name,
                    "path": str(path),
                    "roles": sorted(entry["roles"]),
                    "segments": len(bank),
                    "cached": False,
                    "cache_prefix": cache_prefix,
                }
                manifest.append(entry_report)
                indexed_entries[normalized] = entry_report

                if (built + reused) % 10 == 0:
                    periodic_state = {
                        "indexed_files": [item["file_name"] for item in indexed_entries.values()],
                        "indexed_paths": list(indexed_entries.keys()),
                        "indexed_entries": indexed_entries,
                        "last_offset": offset + (built + reused),
                        "total_processed": len(indexed_entries),
                        "timestamp": time.time(),
                    }
                    self._save_segment_rag_state(periodic_state)
                    logger.debug("Saved segment RAG state after %d processed files", built + reused)
            except Exception:
                errors += 1
                logger.debug("Failed to build segment index for %s", path, exc_info=True)

        final_state = {
            "indexed_files": [item["file_name"] for item in indexed_entries.values()],
            "indexed_paths": list(indexed_entries.keys()),
            "indexed_entries": indexed_entries,
            "last_offset": offset + (built + reused),
            "total_processed": len(indexed_entries),
            "timestamp": time.time(),
            "complete": files_remaining == 0,
        }
        self._save_segment_rag_state(final_state)

        return {
            "roles": target_roles,
            "files_targeted": len(ordered_files),
            "total_available": total_available,
            "built": built,
            "reused": reused,
            "skipped": skipped,
            "errors": errors,
            "total_segments": total_segments,
            "segment_index_dir": str(self.segment_index_dir),
            "device": self.device_name,
            "manifest": manifest,
            "offset": offset,
            "batch_size": batch_size,
            "files_remaining": files_remaining,
            "resumed": resume,
        }

    def _list_assets(self) -> Dict[str, List[Path]]:
        assets: Dict[str, List[Path]] = {role: [] for role in self.ROLE_PATTERNS}
        if not self.library_dir.exists():
            return assets

        for role, patterns in self.ROLE_PATTERNS.items():
            seen = set()
            indexed_paths = set(self._sample_index_by_path.keys()) | set(self._vector_store_meta_by_path.keys())

            for normalized_path in sorted(indexed_paths):
                path = Path(normalized_path)
                if not path.exists() or not path.is_file():
                    continue
                if path.suffix.lower() not in {'.wav', '.aif', '.aiff', '.mp3'}:
                    continue
                sample_meta = self._sample_index_by_path.get(normalized_path)
                vector_meta = self._vector_store_meta_by_path.get(normalized_path)
                if not self._catalog_role_match(role, path, sample_meta, vector_meta):
                    continue
                if normalized_path in seen:
                    continue
                seen.add(normalized_path)
                assets[role].append(path)

            for pattern in patterns:
                for match in sorted(self.library_dir.glob(pattern)):
                    if match.is_file() and match.suffix.lower() in {'.wav', '.aif', '.aiff', '.mp3'}:
                        normalized_match = str(match.resolve()).lower()
                        sample_meta = self._sample_index_by_path.get(normalized_match)
                        vector_meta = self._vector_store_meta_by_path.get(normalized_match)
                        if not self._catalog_role_match(role, match, sample_meta, vector_meta):
                            continue
                        if normalized_match in seen:
                            continue
                        seen.add(normalized_match)
                        assets[role].append(match)
        return assets

    def _apply_role_exclusions(self, role: str, file_name: str) -> Tuple[bool, str]:
        """
        Aplica exclusiones fuertes por rol.

        Retorna:
            (should_reject, reason) - True si debe rechazar, False si pasa
        """
        role_lower = role.lower()
        if role_lower not in self.ROLE_EXCLUSION_PATTERNS:
            return False, ""

        name_lower = file_name.lower()
        exclusions = self.ROLE_EXCLUSION_PATTERNS[role_lower]

        for excl in exclusions:
            if excl in name_lower:
                return True, f"excluded pattern '{excl}' for role '{role}'"

        return False, ""

    def _validate_role_requirement(self, role: str, item: Dict[str, Any]) -> Tuple[bool, float, str]:
        """
        Validates that a candidate sample meets role requirements.

        Returns:
            (passes, score_modifier, reason) - True if passes, score modifier (0-1), reason string
        """
        role_lower = role.lower()
        file_name = str(item.get("file_name", "") or "").lower()
        duration = float(item.get("duration", 0.0) or 0.0)

        min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role_lower, (0.0, 999.0))

        if duration > 0.0 and not (min_dur <= duration <= max_dur):
            return False, 0.0, f"duration {duration:.1f}s outside range [{min_dur}, {max_dur}] for role {role}"

        if role_lower in {'kick', 'snare', 'hat', 'clap', 'hat_closed', 'hat_open'}:
            if 'loop' in file_name and 'full' not in file_name:
                if duration > 4.0:
                    return False, 0.3, f"one-shot role {role} has loop-like file (duration={duration:.1f}s)"

        if role_lower in {'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop'}:
            if duration < 1.0:
                return False, 0.2, f"loop role {role} has very short duration ({duration:.1f}s)"

        if role_lower == 'synth_loop':
            if any(token in file_name for token in ('vocal', 'vox', 'acapella', 'phrase', 'chant')):
                return False, 0.0, f"synth role {role} cannot use vocal-like file"

        must_contain = {
            'kick': ['kick', 'bd', 'bass_drum', '808'],
            'snare': ['snare', 'snr', 'sd', 'rim'],
            'clap': ['clap', 'clp', 'hand'],
            'hat': ['hat', 'hh', 'hihat', 'cymbal'],
            'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline'],
            'vocal_loop': ['vocal', 'vox', 'voice', 'chant', 'acapella'],
            'top_loop': ['top', 'perc', 'drum', 'full'],
            'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck'],
            'crash_fx': ['crash', 'cymbal', 'impact', 'ride'],
            'fill_fx': ['fill', 'transition', 'tom'],
            'snare_roll': ['roll', 'snare', 'build'],
            'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise'],
            'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab'],
        }

        if role_lower in must_contain:
            found = any(kw in file_name for kw in must_contain[role_lower])
            if not found:
                return True, 0.65, f"no role keyword for {role}"

        return True, 1.0, "passes role validation"

    def _matches_role_name(self, role: str, file_name: str) -> bool:
        name = Path(file_name).stem.lower()
        # Check exclusions first
        should_reject, reason = self._apply_role_exclusions(role, name)
        if should_reject:
            logger.debug("ROLE_EXCLUSION: %s", reason)
            return False
        if role == 'kick':
            return 'kick' in name and 'loop' not in name
        if role == 'snare':
            return ('snare' in name or 'clap' in name) and 'roll' not in name and 'loop' not in name
        if role == 'hat':
            return 'hat' in name and 'loop' not in name and 'full mix' not in name
        if role == 'bass_loop':
            return self._name_contains_any(name, ('bass loop', 'bass_loop', 'bassline', 'sub bass', 'sub_bass', 'reese', '808')) \
                and self._name_contains_none(name, ('drum loop', 'full mix', 'top loop', 'vocal'))
        if role == 'perc_loop':
            return (
                self._name_contains_any(name, ('perc loop', 'perc_loop', 'percussion loop', 'drum loop', 'drum_loop', 'groove'))
                or ('perc' in name and 'loop' in name)
                or (self._name_contains_any(name, ('shaker', 'bongo', 'conga', 'timbale')) and 'loop' in name)
            ) and self._name_contains_none(name, ('full mix', 'one shot', 'shot', 'vocal'))
        if role == 'top_loop':
            return self._name_contains_any(name, ('top loop', 'top_loop', 'top loops', 'full drum', 'full mix', 'drum loop', 'drum_loop')) \
                and self._name_contains_none(name, ('bass loop', 'vocal', 'synth loop'))
        if role == 'synth_loop':
            return (
                self._name_contains_any(name, ('synth loop', 'synth_loop', 'lead loop', 'lead_loop', 'hook', 'melody loop', 'melodic loop'))
                or ('synth' in name and 'loop' in name)
                or (self._name_contains_any(name, ('chord', 'pad', 'pluck', 'arp')) and 'loop' in name)
            ) and self._name_contains_none(name, ('drum loop', 'full mix', 'vocal'))
        if role == 'vocal_loop':
            return (
                self._name_contains_any(name, ('vocal loop', 'vox loop', 'vox_', 'acapella', 'chant loop'))
                or ('vocal' in name and 'loop' in name)
            ) and self._name_contains_none(name, ('one shot', 'shot', 'importante', 'stab', 'hit'))
        if role == 'crash_fx':
            return self._name_contains_any(name, ('crash', 'cymbal', 'riser', 'downlifter', 'sweep', 'uplifter')) or ' impact ' in f" {name} "
        if role == 'fill_fx':
            return self._name_contains_any(name, ('fill', 'transition', 'tom loop', 'drum fill', 'break fill'))
        if role == 'snare_roll':
            return self._name_contains_any(name, ('snareroll', 'snare roll', 'roll', 'buildup')) and 'one shot' not in name
        if role == 'atmos_fx':
            return self._name_contains_any(name, ('atmos', 'drone', 'ambient', 'noise', 'texture', 'downfilter', 'wash', 'sweep'))
        if role == 'vocal_shot':
            return self._name_contains_any(name, ('vocal one shot', 'one shot', 'shot', 'importante', 'vocal chop', 'vocal stab'))
        return True

    def _cosine_scores(self, reference_vector: List[float], candidate_vectors: List[List[float]]) -> List[float]:
        if not candidate_vectors:
            return []

        ref = np.asarray(reference_vector, dtype=np.float32)
        candidates = np.asarray(candidate_vectors, dtype=np.float32)
        if torch is None or self.device is None or F is None:
            ref_norm = np.linalg.norm(ref) or 1.0
            cand_norm = np.linalg.norm(candidates, axis=1)
            cand_norm[cand_norm == 0] = 1.0
            return (candidates @ ref / (cand_norm * ref_norm)).astype(float).tolist()

        ref_tensor = torch.tensor(ref, dtype=torch.float32, device=self.device)
        candidate_tensor = torch.tensor(candidates, dtype=torch.float32, device=self.device)
        scores = F.cosine_similarity(candidate_tensor, ref_tensor.unsqueeze(0), dim=1)
        return scores.detach().cpu().numpy().astype(float).tolist()

    def _cosine_matrix(self, left_vectors: List[List[float]], right_vectors: List[List[float]]) -> np.ndarray:
        if not left_vectors or not right_vectors:
            return np.zeros((0, 0), dtype=np.float32)

        left = np.asarray(left_vectors, dtype=np.float32)
        right = np.asarray(right_vectors, dtype=np.float32)

        if torch is None or self.device is None or F is None:
            left_norm = np.linalg.norm(left, axis=1, keepdims=True)
            right_norm = np.linalg.norm(right, axis=1, keepdims=True)
            left_norm[left_norm == 0] = 1.0
            right_norm[right_norm == 0] = 1.0
            return (left / left_norm) @ (right / right_norm).T

        left_tensor = torch.tensor(left, dtype=torch.float32, device=self.device)
        right_tensor = torch.tensor(right, dtype=torch.float32, device=self.device)
        left_tensor = F.normalize(left_tensor, p=2, dim=1)
        right_tensor = F.normalize(right_tensor, p=2, dim=1)
        return (left_tensor @ right_tensor.T).detach().cpu().numpy().astype(np.float32)

    def _tempo_score(self, candidate_tempo: float, reference_tempo: float) -> float:
        if candidate_tempo <= 0 or reference_tempo <= 0:
            return 0.5
        variants = [
            candidate_tempo,
            candidate_tempo * 2.0,
            candidate_tempo / 2.0,
            candidate_tempo * 4.0,
            candidate_tempo / 4.0,
        ]
        diff = min(abs(item - reference_tempo) for item in variants)
        return math.exp(-diff / 10.0)

    def _vector_store_entry(self, candidate: Dict[str, Any]) -> Optional[Dict[str, Any]]:
        path_key = str(candidate.get("path", "") or "").strip().lower()
        if path_key and path_key in self._vector_store_meta_by_path:
            return self._vector_store_meta_by_path[path_key]
        file_name = str(candidate.get("file_name", "") or Path(path_key).name).strip().lower()
        if file_name and file_name in self._vector_store_meta_by_name:
            return self._vector_store_meta_by_name[file_name]
        return None

    def _role_segment_relevance(self, role: str, segment: Dict[str, Any], reference: Dict[str, Any]) -> float:
        kind = str(segment.get("kind", "verse") or "verse").lower()
        centroid = float(segment.get("spectral_centroid", 0.0) or 0.0)
        onset = float(segment.get("onset_mean", 0.0) or 0.0)
        harmonic = float(segment.get("harmonic_ratio", 0.5) or 0.5)
        percussive = float(segment.get("percussive_ratio", 0.5) or 0.5)
        flatness = float(segment.get("spectral_flatness", 0.0) or 0.0)
        zcr = float(segment.get("zero_crossing_rate", 0.0) or 0.0)
        rms = float(segment.get("rms_mean", 0.5) or 0.5)
        score = 0.0

        if role == 'kick':
            transient = min(1.0, onset / 3.0)
            low_centroid = max(0.0, 1.0 - (centroid / 3000.0))
            score = transient * 0.35 + percussive * 0.30 + low_centroid * 0.20 + rms * 0.15
        elif role == 'snare':
            transient = min(1.0, onset / 4.5)
            mid_centroid = min(1.0, max(0.0, (centroid - 800) / 4000.0))
            score = transient * 0.32 + percussive * 0.28 + mid_centroid * 0.25
        elif role == 'hat':
            high_centroid = min(1.0, centroid / 10000.0)
            transient = min(1.0, onset / 4.0)
            score = high_centroid * 0.38 + transient * 0.32 + zcr * 0.15 + percussive * 0.15
        elif role == 'bass_loop':
            low_centroid = max(0.0, 1.0 - (centroid / 2200.0))
            harmonic_content = harmonic * 0.35
            low_flat = max(0.0, 1.0 - flatness * 1.5)
            score = harmonic_content + low_centroid * 0.30 + low_flat * 0.20 + rms * 0.15
        elif role in {'perc_loop', 'top_loop'}:
            transient = min(1.0, onset / 4.0)
            mid_high_centroid = min(1.0, max(0.0, centroid / 8500.0))
            score = transient * 0.35 + percussive * 0.30 + mid_high_centroid * 0.20 + rms * 0.15
        elif role == 'synth_loop':
            harmonic_content = harmonic * 0.38
            mid_centroid = min(1.0, max(0.0, (centroid - 500) / 7000.0))
            low_flat = max(0.0, 1.0 - flatness * 1.2)
            score = harmonic_content + mid_centroid * 0.22 + low_flat * 0.25
        elif role == 'vocal_loop':
            harmonic_content = harmonic * 0.32
            mid_centroid = min(1.0, max(0.0, (centroid - 200) / 4000.0))
            low_flat = max(0.0, 1.0 - flatness * 1.5)
            score = harmonic_content + mid_centroid * 0.18 + low_flat * 0.25 + rms * 0.25
        elif role == 'crash_fx':
            high_centroid = min(1.0, centroid / 12000.0)
            transient = min(1.0, onset / 3.5)
            high_flat = min(1.0, flatness * 2.5)
            score = high_centroid * 0.30 + transient * 0.25 + high_flat * 0.25
        elif role == 'fill_fx':
            transient = min(1.0, onset / 4.0)
            percussive_content = percussive * 0.35
            mid_centroid = min(1.0, max(0.0, centroid / 7000.0))
            score = transient * 0.30 + percussive_content + mid_centroid * 0.20
        elif role == 'snare_roll':
            transient = min(1.0, onset / 4.5)
            percussive_content = percussive * 0.38
            mid_centroid = min(1.0, max(0.0, (centroid - 1000) / 5000.0))
            score = transient * 0.35 + percussive_content + mid_centroid * 0.15
        elif role == 'atmos_fx':
            harmonic_content = harmonic * 0.28
            low_onset = max(0.0, 1.0 - onset * 2.0)
            high_flat = min(1.0, flatness * 2.0)
            score = harmonic_content + low_onset * 0.22 + high_flat * 0.25 + rms * 0.25
        elif role == 'vocal_shot':
            harmonic_content = harmonic * 0.30
            transient = min(1.0, onset / 4.0)
            mid_centroid = min(1.0, max(0.0, (centroid - 300) / 4500.0))
            score = harmonic_content + transient * 0.22 + mid_centroid * 0.28

        section_bonus_map = {
            'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': 0.02},
            'snare': {'intro': -0.06, 'verse': 0.06, 'build': 0.10, 'drop': 0.14, 'break': 0.03, 'outro': -0.04},
            'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.14, 'drop': 0.12, 'break': -0.04, 'outro': 0.02},
            'bass_loop': {'intro': -0.12, 'verse': 0.06, 'build': 0.12, 'drop': 0.20, 'break': -0.10, 'outro': -0.06},
            'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.18, 'break': 0.06, 'outro': 0.00},
            'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.16, 'drop': 0.18, 'break': 0.02, 'outro': 0.00},
            'synth_loop': {'intro': 0.06, 'verse': 0.04, 'build': 0.14, 'drop': 0.20, 'break': 0.12, 'outro': 0.02},
            'vocal_loop': {'intro': -0.06, 'verse': 0.14, 'build': 0.08, 'drop': 0.16, 'break': 0.10, 'outro': -0.02},
            'crash_fx': {'intro': 0.10, 'verse': 0.02, 'build': 0.16, 'drop': 0.10, 'break': -0.06, 'outro': 0.10},
            'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.20, 'drop': 0.12, 'break': 0.10, 'outro': 0.02},
            'snare_roll': {'intro': -0.08, 'verse': 0.02, 'build': 0.26, 'drop': 0.14, 'break': 0.06, 'outro': -0.10},
            'atmos_fx': {'intro': 0.22, 'verse': 0.04, 'build': 0.02, 'drop': -0.06, 'break': 0.24, 'outro': 0.18},
            'vocal_shot': {'intro': -0.06, 'verse': 0.10, 'build': 0.12, 'drop': 0.16, 'break': 0.08, 'outro': -0.04},
        }
        score += section_bonus_map.get(role, {}).get(kind, 0.0)
        return max(0.0, min(1.0, score))

    def _select_role_reference_segments(
        self,
        role: str,
        reference: Dict[str, Any],
        segment_bank: List[Dict[str, Any]],
    ) -> List[Dict[str, Any]]:
        if not segment_bank:
            return []
        settings = ROLE_SEGMENT_SETTINGS.get(role, {})
        allowed_windows = settings.get("windows", set())
        allowed_kinds = settings.get("section_kinds", set())
        filtered = [
            segment for segment in segment_bank
            if (not allowed_windows or round(float(segment.get("window_seconds", 0.0)), 1) in allowed_windows)
            and (not allowed_kinds or str(segment.get("kind", "")).lower() in allowed_kinds)
        ]
        if not filtered:
            filtered = segment_bank
        ranked = sorted(
            filtered,
            key=lambda item: self._role_segment_relevance(role, item, reference),
            reverse=True,
        )
        return ranked[:int(settings.get("top_k", 6) or 6)]

    def _role_segment_similarity(
        self,
        role: str,
        candidate: Dict[str, Any],
        role_segments: List[Dict[str, Any]],
    ) -> float:
        role_vectors = [list(segment.get("vector", []) or []) for segment in role_segments if segment.get("vector")]
        if not role_vectors:
            return 0.0

        candidate_vectors: List[List[float]] = []
        candidate_vector = list(candidate.get("deep_vector", []) or [])
        if candidate_vector:
            candidate_vectors.append(candidate_vector)

        candidate_path = str(candidate.get("path", "") or "")
        candidate_duration = float(candidate.get("duration", 0.0) or 0.0)
        windows = set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set())
        if candidate_path and candidate_duration > max(windows or {4.0}) * 1.5:
            segment_bank = self._build_candidate_segment_bank(candidate_path, windows, duration_limit=min(max(candidate_duration, 0.0), 32.0))
            candidate_vectors.extend(
                list(segment.get("vector", []) or [])
                for segment in segment_bank
                if segment.get("vector")
            )

        if not candidate_vectors:
            return 0.0

        matrix = self._cosine_matrix(candidate_vectors, role_vectors)
        if matrix.size == 0:
            return 0.0
        best_per_candidate = matrix.max(axis=1).tolist()
        best_per_candidate.sort(reverse=True)
        top = best_per_candidate[: min(3, len(best_per_candidate))]
        return float(sum(top) / len(top))

    def _micro_stem_segment_features(self, segment: Dict[str, Any]) -> Dict[str, float]:
        rms = float(segment.get("rms_mean", 0.0) or 0.0)
        onset = float(segment.get("onset_mean", 0.0) or 0.0)
        centroid = float(segment.get("spectral_centroid", 0.0) or 0.0)
        return {
            "energy": max(0.0, min(1.0, rms / 0.18)),
            "onset_density": max(0.0, min(1.0, onset / 4.5)),
            "brightness": max(0.0, min(1.0, centroid / 9000.0)),
            "harmonic_ratio": float(segment.get("harmonic_ratio", 0.5) or 0.5),
            "percussive_ratio": float(segment.get("percussive_ratio", 0.5) or 0.5),
        }

    def _infer_micro_stem_roles(self, segment: Dict[str, Any]) -> List[str]:
        kind = str(segment.get("kind", "verse") or "verse").lower()
        features = self._micro_stem_segment_features(segment)
        energy = float(features["energy"])
        onset = float(features["onset_density"])
        brightness = float(features["brightness"])
        harmonic = float(features["harmonic_ratio"])
        percussive = float(features["percussive_ratio"])
        centroid = float(segment.get("spectral_centroid", 0.0) or 0.0)
        roles: List[str] = []

        for role in self._detect_roles_for_segment(features, kind):
            if role not in roles:
                roles.append(role)

        if percussive >= 0.58 and centroid <= 2200.0 and onset >= 0.18 and "kick" not in roles:
            roles.insert(0, "kick")
        if percussive >= 0.50 and 900.0 <= centroid <= 6000.0 and onset >= 0.20 and "snare" not in roles:
            roles.append("snare")
        if percussive >= 0.42 and centroid >= 5200.0 and onset >= 0.18 and "hat" not in roles:
            roles.append("hat")
        if harmonic >= 0.58 and centroid <= 2600.0 and energy >= 0.22 and "bass_loop" not in roles:
            roles.append("bass_loop")
        if harmonic >= 0.55 and 700.0 <= centroid <= 7600.0 and energy >= 0.20 and "synth_loop" not in roles:
            roles.append("synth_loop")
        if kind in {"intro", "break", "outro"} and onset <= 0.35 and harmonic >= 0.35 and "atmos_fx" not in roles:
            roles.append("atmos_fx")
        if kind == "build":
            if "fill_fx" not in roles:
                roles.append("fill_fx")
            if onset >= 0.28 and "snare_roll" not in roles:
                roles.append("snare_roll")
        unique_roles: List[str] = []
        for role in roles:
            if role not in unique_roles:
                unique_roles.append(role)
        return unique_roles[:4]

    def _micro_stem_segment_importance(self, segment: Dict[str, Any]) -> float:
        features = self._micro_stem_segment_features(segment)
        window = float(segment.get("window_seconds", 0.0) or 0.0)
        energy = float(features["energy"])
        onset = float(features["onset_density"])
        harmonic = float(features["harmonic_ratio"])
        percussive = float(features["percussive_ratio"])
        balance = 1.0 - min(1.0, abs(harmonic - percussive))
        if window <= 1.05:
            window_factor = 0.92
        elif window <= 2.05:
            window_factor = 1.00
        elif window <= 4.05:
            window_factor = 0.96
        else:
            window_factor = 0.82
        score = (
            energy * 0.32 +
            onset * 0.24 +
            max(harmonic, percussive) * 0.24 +
            balance * 0.20
        )
        return float(score * window_factor)

    def _micro_stem_candidate_tokens(self, candidate: Dict[str, Any]) -> List[str]:
        sample_text = " ".join(
            str(candidate.get(key, "") or "")
            for key in ("file_name", "path")
        ).lower()
        tokens: List[str] = []
        for token, hints in MICRO_STEM_TOKEN_HINTS.items():
            if any(hint in sample_text for hint in hints):
                tokens.append(token)
        return tokens

    def _micro_stem_similarity(
        self,
        segment: Dict[str, Any],
        candidate: Dict[str, Any],
        windows: set,
        reference_tempo: float,
        reference_key: str,
        bank_cache: Dict[str, List[Dict[str, Any]]],
    ) -> float:
        segment_vector = list(segment.get("vector", []) or [])
        candidate_path = str(candidate.get("path", "") or "")
        if not segment_vector or not candidate_path:
            return 0.0

        cache_key = f"{candidate_path.lower()}::{','.join(str(v) for v in sorted(float(v) for v in windows))}"
        bank = bank_cache.get(cache_key)
        if bank is None:
            duration_limit = min(max(float(candidate.get("duration", 0.0) or 0.0), 0.5), 32.0)
            bank = self._build_candidate_segment_bank(candidate_path, windows, duration_limit=duration_limit)
            bank_cache[cache_key] = bank

        candidate_vectors = [list(item.get("vector", []) or []) for item in bank if item.get("vector")]
        if not candidate_vectors:
            candidate_vector = list(candidate.get("vector", []) or [])
            if candidate_vector:
                candidate_vectors = [candidate_vector]
        if not candidate_vectors:
            return 0.0

        cosine_scores = self._cosine_scores(segment_vector, candidate_vectors)
        segment_similarity = max(cosine_scores) if cosine_scores else 0.0
        tempo_score = self._tempo_score(float(candidate.get("tempo", 0.0) or 0.0), reference_tempo)
        key_distance = _key_distance(reference_key, str(candidate.get("key", "") or ""))
        key_score = max(0.0, 1.0 - (key_distance / 6.0))
        return float(segment_similarity * 0.82 + tempo_score * 0.10 + key_score * 0.08)

    def _build_micro_stem_plan(
        self,
        reference_path: str,
        reference: Dict[str, Any],
        sections: List[Dict[str, Any]],
        matches: Dict[str, List[Dict[str, Any]]],
    ) -> Dict[str, Any]:
        segment_bank = self._build_reference_segment_bank(reference_path, reference, sections)
        if not segment_bank:
            return {"segments": [], "summary": {}}

        reference_tempo = float(reference.get("tempo", 0.0) or 0.0)
        reference_key = str(reference.get("key", "") or "")
        duration = float(reference.get("duration", 0.0) or 0.0)
        candidate_bank_cache: Dict[str, List[Dict[str, Any]]] = {}

        segment_candidates: List[Tuple[float, Dict[str, Any]]] = []
        for segment in segment_bank:
            window = float(segment.get("window_seconds", 0.0) or 0.0)
            if window <= 0.0 or window > 4.05:
                continue
            importance = self._micro_stem_segment_importance(segment)
            roles = self._infer_micro_stem_roles(segment)
            if not roles:
                continue
            enriched = dict(segment)
            enriched["roles"] = roles
            enriched["importance"] = round(float(importance), 6)
            segment_candidates.append((importance, enriched))

        if not segment_candidates:
            return {"segments": [], "summary": {}}

        segment_candidates.sort(key=lambda item: item[0], reverse=True)
        max_segments = min(40, max(20, int(max(duration, 32.0) / 8.0)))
        per_kind_cap = max(4, max_segments // 3)
        selected_segments: List[Dict[str, Any]] = []
        kind_counts: Dict[str, int] = defaultdict(int)
        occupied_slots: set = set()

        for _, segment in segment_candidates:
            slot_key = (str(segment.get("kind", "verse")), int(round(float(segment.get("start", 0.0) or 0.0) * 2.0)))
            kind = str(segment.get("kind", "verse") or "verse").lower()
            if slot_key in occupied_slots:
                continue
            if kind_counts[kind] >= per_kind_cap:
                continue
            occupied_slots.add(slot_key)
            kind_counts[kind] += 1
            selected_segments.append(segment)
            if len(selected_segments) >= max_segments:
                break

        selected_segments.sort(key=lambda item: (float(item.get("start", 0.0) or 0.0), float(item.get("window_seconds", 0.0) or 0.0)))

        role_path_counts: Dict[str, defaultdict] = defaultdict(lambda: defaultdict(int))
        role_family_counts: Dict[str, defaultdict] = defaultdict(lambda: defaultdict(int))
        role_token_counts: Dict[str, defaultdict] = defaultdict(lambda: defaultdict(int))
        global_family_counts: defaultdict = defaultdict(int)
        global_token_counts: defaultdict = defaultdict(int)
        micro_segments: List[Dict[str, Any]] = []

        for index, segment in enumerate(selected_segments):
            segment_roles = list(segment.get("roles", []) or [])
            role_matches: Dict[str, List[Dict[str, Any]]] = {}
            for role in segment_roles:
                role_candidates = list(matches.get(role, []) or [])[: int(MICRO_STEM_ROLE_CANDIDATE_LIMITS.get(role, 5))]
                if not role_candidates:
                    continue
                windows = set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", {float(segment.get("window_seconds", 2.0) or 2.0)}) or {float(segment.get("window_seconds", 2.0) or 2.0)})
                ranked_matches: List[Dict[str, Any]] = []
                for candidate in role_candidates:
                    score = self._micro_stem_similarity(
                        segment,
                        candidate,
                        windows,
                        reference_tempo,
                        reference_key,
                        candidate_bank_cache,
                    )
                    if score <= 0.45:
                        continue
                    family = self._candidate_family(candidate)
                    token_hints = self._micro_stem_candidate_tokens(candidate)
                    ranked_matches.append({
                        "path": candidate.get("path"),
                        "file_name": candidate.get("file_name"),
                        "family": family,
                        "score": round(float(score), 6),
                        "tempo": candidate.get("tempo"),
                        "key": candidate.get("key"),
                        "token_hints": token_hints,
                    })
                ranked_matches.sort(key=lambda item: item["score"], reverse=True)
                ranked_matches = ranked_matches[:3]
                if not ranked_matches:
                    continue
                role_matches[role] = ranked_matches
                top_match = ranked_matches[0]
                top_path = str(top_match.get("path", "") or "")
                top_family = str(top_match.get("family", "") or "")
                role_path_counts[role][top_path] += 1
                if top_family:
                    role_family_counts[role][top_family] += 1
                    global_family_counts[top_family] += 1
                for token in top_match.get("token_hints", []) or []:
                    role_token_counts[role][token] += 1
                    global_token_counts[token] += 1

            if not role_matches:
                continue

            features = self._micro_stem_segment_features(segment)
            micro_segments.append({
                "index": index,
                "start": round(float(segment.get("start", 0.0) or 0.0), 3),
                "end": round(float(segment.get("end", 0.0) or 0.0), 3),
                "window_seconds": round(float(segment.get("window_seconds", 0.0) or 0.0), 3),
                "kind": str(segment.get("kind", "verse") or "verse").lower(),
                "importance": round(float(segment.get("importance", 0.0) or 0.0), 6),
                "features": {
                    "energy": round(float(features["energy"]), 6),
                    "onset_density": round(float(features["onset_density"]), 6),
                    "brightness": round(float(features["brightness"]), 6),
                    "harmonic_ratio": round(float(features["harmonic_ratio"]), 6),
                    "percussive_ratio": round(float(features["percussive_ratio"]), 6),
                },
                "roles": segment_roles,
                "matches": role_matches,
            })

        summary = {
            "segments_considered": len(segment_candidates),
            "segments_selected": len(micro_segments),
            "dominant_families": [
                {"family": family, "count": int(count)}
                for family, count in sorted(global_family_counts.items(), key=lambda item: item[1], reverse=True)[:6]
            ],
            "dominant_tokens": [
                {"token": token, "count": int(count)}
                for token, count in sorted(global_token_counts.items(), key=lambda item: item[1], reverse=True)[:8]
            ],
            "role_focus": {
                role: {
                    "top_paths": [
                        {"path": path, "count": int(count)}
                        for path, count in sorted(path_counts.items(), key=lambda item: item[1], reverse=True)[:3]
                    ],
                    "top_families": [
                        {"family": family, "count": int(count)}
                        for family, count in sorted(role_family_counts.get(role, {}).items(), key=lambda item: item[1], reverse=True)[:3]
                    ],
                    "token_hints": [
                        {"token": token, "count": int(count)}
                        for token, count in sorted(role_token_counts.get(role, {}).items(), key=lambda item: item[1], reverse=True)[:4]
                    ],
                }
                for role, path_counts in role_path_counts.items()
            },
        }

        return {
            "segments": micro_segments,
            "summary": summary,
        }

    def _apply_micro_stem_bias(
        self,
        matches: Dict[str, List[Dict[str, Any]]],
        micro_stem_plan: Dict[str, Any],
    ) -> Dict[str, List[Dict[str, Any]]]:
        summary = micro_stem_plan.get("summary", {}) or {}
        role_focus = summary.get("role_focus", {}) or {}
        global_family_bonus = {
            str(item.get("family", "") or ""): int(item.get("count", 0) or 0)
            for item in summary.get("dominant_families", []) or []
            if item.get("family")
        }
        if not role_focus:
            return matches

        biased_matches: Dict[str, List[Dict[str, Any]]] = {}
        for role, candidates in matches.items():
            focus = role_focus.get(role, {}) or {}
            top_paths = {
                str(item.get("path", "") or ""): int(item.get("count", 0) or 0)
                for item in focus.get("top_paths", []) or []
                if item.get("path")
            }
            top_families = {
                str(item.get("family", "") or ""): int(item.get("count", 0) or 0)
                for item in focus.get("top_families", []) or []
                if item.get("family")
            }
            token_hints = {
                str(item.get("token", "") or ""): int(item.get("count", 0) or 0)
                for item in focus.get("token_hints", []) or []
                if item.get("token")
            }

            ranked: List[Dict[str, Any]] = []
            for candidate in candidates:
                updated = dict(candidate)
                path_key = str(updated.get("path", "") or "")
                family = self._candidate_family(updated)
                bias = 0.0

                if path_key in top_paths:
                    bias += min(0.16, top_paths[path_key] * 0.04)
                if family and family in top_families:
                    bias += min(0.12, top_families[family] * 0.03)
                if family and family in global_family_bonus:
                    bias += min(0.08, global_family_bonus[family] * 0.01)

                candidate_tokens = set(self._micro_stem_candidate_tokens(updated))
                for token in candidate_tokens:
                    if token in token_hints:
                        bias += min(0.10, token_hints[token] * 0.02)

                preferred_tokens = MICRO_STEM_ROLE_BIAS_TOKENS.get(role, set())
                if preferred_tokens:
                    bias += min(0.08, len(candidate_tokens & preferred_tokens) * 0.03)

                updated["micro_stem_bias"] = round(float(bias), 6)
                updated["score"] = round(float(updated.get("score", 0.0) or 0.0) + bias, 6)
                ranked.append(updated)

            ranked.sort(key=lambda item: float(item.get("score", 0.0) or 0.0), reverse=True)
            biased_matches[role] = ranked

        return biased_matches

    def resolve_harmonic_instruments(self, micro_summary: Dict[str, Any], midi_preset_index: Dict[str, Any]) -> Dict[str, Any]:
        """Resolve detected tokens from micro_stem_summary to instrument candidates.

        Maps detected harmonic tokens (pluck, pad, piano, etc.) to actual instrument
        candidates from the MIDI preset index, considering pack coherence.

        Args:
            micro_summary: The micro_stem_summary dict with dominant_tokens
            midi_preset_index: Index of available MIDI presets with families

        Returns:
            Dict mapping token to resolved instrument candidates
        """
        # Map tokens to instrument families in order of preference
        TOKEN_TO_FAMILY = {
            'pluck': ['Pluck', 'Keys'],
            'pad': ['Pad', 'Atmosphere'],
            'piano': ['Piano', 'Keys'],
            'keys': ['Keys', 'Piano'],
            'harp': ['Pluck', 'Keys'],
            'guitar': ['Guitar', 'Pluck'],
            'lead': ['Lead', 'Pluck', 'Keys'],
            'reese': ['Bass'],  # Special handling - goes to bass
        }

        resolved = {}

        # Get dominant tokens from summary
        dominant_tokens = micro_summary.get('dominant_tokens', [])
        if not dominant_tokens:
            logger.debug("HARMONIC_RESOLVE: No dominant tokens found in micro_summary")
            return resolved

        # Get dominant pack for coherence scoring
        dominant_pack = micro_summary.get('dominant_pack', '')

        logger.info("HARMONIC_RESOLVE: Resolving %d dominant tokens (pack=%s)",
                   len(dominant_tokens), dominant_pack or 'none')

        for token_entry in dominant_tokens:
            if isinstance(token_entry, dict):
                token = token_entry.get('token', '')
                count = token_entry.get('count', 0)
            else:
                # Handle tuple format
                token = token_entry[0] if isinstance(token_entry, (tuple, list)) else str(token_entry)
                count = token_entry[1] if isinstance(token_entry, (tuple, list)) and len(token_entry) > 1 else 1

            if token not in TOKEN_TO_FAMILY:
                continue

            families = TOKEN_TO_FAMILY[token]

            # Find instruments in those families
            candidates = []
            for family in families:
                family_items = midi_preset_index.get('by_family', {}).get(family, [])

                for item in family_items:
                    # Score based on pack coherence
                    score = 1.0
                    if dominant_pack and dominant_pack in item.get('path', ''):
                        score = 2.0  # Boost same pack

                    candidates.append({
                        'item': item,
                        'score': score,
                        'family': family
                    })

            if candidates:
                # Sort by score and pick best
                candidates.sort(key=lambda x: x['score'], reverse=True)
                resolved[token] = {
                    'primary_candidate': candidates[0],
                    'alternatives': candidates[1:3],
                    'family': families[0],
                    'count': count,
                }
                logger.debug("HARMONIC_RESOLVE: Token '%s' -> family '%s' with %d candidates",
                           token, families[0], len(candidates))

        return resolved

    def _load_midi_preset_index(self) -> Dict[str, Any]:
        """Load MIDI/preset index and ensure it contains real family entries."""
        if get_midi_preset_indexer is None:
            logger.debug("HARMONIC_RESOLVE: midi_preset_indexer no disponible")
            return {"by_family": {}, "stats": {}}

        try:
            indexer = get_midi_preset_indexer(str(self.library_dir))
            stats = indexer.get_stats()
            total_items = int(stats.get("total_midi", 0) or 0) + int(stats.get("total_presets", 0) or 0)
            if total_items == 0:
                logger.info("HARMONIC_RESOLVE: MIDI/preset index vacio, escaneando libreria...")
                indexer.scan_library(str(self.library_dir))
                stats = indexer.get_stats()

            by_family: Dict[str, List[Dict[str, Any]]] = {}
            for family in indexer.get_families():
                family_items = indexer.get_by_family(family)
                combined = list(family_items.get("midi", []) or []) + list(family_items.get("presets", []) or [])
                if combined:
                    by_family[family] = combined

            return {"by_family": by_family, "stats": stats}
        except Exception as exc:
            logger.warning("HARMONIC_RESOLVE: No se pudo cargar indice MIDI/preset: %s", exc)
            return {"by_family": {}, "stats": {}}

    def _candidate_matches_harmonic_family(self, candidate: Dict[str, Any], family: str) -> bool:
        family_lower = str(family or "").strip().lower()
        if not family_lower:
            return False

        family_terms = {
            "piano": ("piano", "keys", "rhodes", "rhode", "epiano", "steinway", "grand"),
            "keys": ("keys", "keyboard", "rhodes", "rhode", "epiano", "piano"),
            "pad": ("pad", "texture", "ambient", "atmos", "drone", "strings"),
            "atmosphere": ("pad", "texture", "ambient", "atmos", "drone", "strings"),
            "pluck": ("pluck", "harp", "bell", "kalimba", "mallet", "arp", "arpeggio"),
            "lead": ("lead", "hook", "melody", "synth"),
            "guitar": ("guitar", "nylon", "acoustic"),
            "bass": ("bass", "reese", "sub", "808"),
        }

        sample_text = " ".join(
            str(candidate.get(key, "") or "")
            for key in ("file_name", "path")
        ).lower()
        terms = family_terms.get(family_lower, (family_lower,))
        return any(term in sample_text for term in terms)

    def _select_harmonic_layer(self, role: str, matches: List[Dict[str, Any]],
                               plan: Dict[str, Any]) -> Optional[Dict[str, Any]]:
        """Select harmonic layer using instrument hints from plan.

        When selecting synth_loop or related harmonic roles, uses the instrument
        hints derived from micro_stem_summary to prefer matching families.

        Args:
            role: The role being selected (e.g., 'synth_loop')
            matches: List of candidate samples
            plan: The arrangement plan with harmonic_instrument_hints

        Returns:
            Selected sample or None if no matches
        """
        if not matches:
            return None

        # Get hint for this role type
        hint_key = f'{role}_hint'
        hint = plan.get(hint_key)

        # If no specific hint, try synth_loop_hint for harmonic roles
        if not hint and role in ('synth_loop', 'pad', 'lead', 'pluck'):
            hint = plan.get('synth_loop_hint')

        if hint:
            family = hint.get('family', '')

            # Filter candidates matching the hint family
            family_matches = []
            other_matches = []

            for match in matches:
                if self._candidate_matches_harmonic_family(match, family):
                    family_matches.append(match)
                else:
                    other_matches.append(match)

            if family_matches:
                logger.debug("HARMONIC_SELECT: Role '%s' using family '%s' (%d/%d matches)",
                           role, family, len(family_matches), len(matches))
                return self._select_best(family_matches)

        # Fallback to normal selection
        return self._select_best(matches)

    def _vector_store_role_score(self, role: str, candidate: Dict[str, Any], reference: Dict[str, Any]) -> float:
        entry = self._vector_store_entry(candidate)
        if not entry:
            return 0.5

        entry_type = str(entry.get("type", "") or "").lower()
        duration = float(entry.get("duration_estimate", candidate.get("duration", 0.0)) or 0.0)
        tags = [str(tag).lower() for tag in entry.get("tags", []) if tag]
        file_name = str(candidate.get("file_name", entry.get("filename", "")) or "").lower()

        type_score = 0.6 if not entry_type else (1.0 if entry_type in ROLE_VECTOR_TYPES.get(role, set()) else 0.35)
        duration_score = self._duration_score(role, duration, file_name)
        tag_score = self._naming_score(role, " ".join(tags + [file_name]))
        tempo_score = self._tempo_score(float(entry.get("bpm", candidate.get("tempo", 0.0)) or 0.0), float(reference.get("tempo", 0.0) or 0.0))
        score = type_score * 0.34 + duration_score * 0.28 + tag_score * 0.26 + tempo_score * 0.12
        if role == 'crash_fx' and any(marker in file_name for marker in ['top loop', 'top loops', 'hat', 'snare']):
            score *= 0.25
        return max(0.0, min(1.0, score))

    def _role_score(
        self,
        role: str,
        reference: Dict[str, Any],
        candidate: Dict[str, Any],
        cosine_score: float,
        segment_score: float = 0.0,
        catalog_score: float = 0.5,
    ) -> float:
        if segment_score > 0:
            if role in {'kick', 'snare', 'hat', 'crash_fx', 'fill_fx', 'snare_roll', 'vocal_shot'}:
                cosine_score = (float(cosine_score) * 0.28) + (float(segment_score) * 0.72)
            elif role in {'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx'}:
                cosine_score = (float(cosine_score) * 0.42) + (float(segment_score) * 0.58)
            else:
                cosine_score = (float(cosine_score) * 0.5) + (float(segment_score) * 0.5)
        tempo_score = self._tempo_score(float(candidate.get("tempo", 0.0)), float(reference.get("tempo", 0.0)))
        key_distance = _key_distance(reference.get("key"), candidate.get("key"))
        key_score = max(0.0, 1.0 - (key_distance / 6.0))
        duration = float(candidate.get("duration", 0.0))
        onset = float(candidate.get("onset_mean", 0.0))
        rms = float(candidate.get("rms_mean", 0.0))
        file_name = str(candidate.get("file_name", "") or "").lower()
        duration_score = self._duration_score(role, duration, file_name)
        naming_score = self._naming_score(role, file_name)
        spectral_score = self._spectral_role_score(role, candidate)

        if role in ['kick', 'snare', 'hat']:
            base_score = (
                cosine_score * 0.18 +
                tempo_score * 0.10 +
                min(1.0, onset / 4.0) * 0.20 +
                duration_score * 0.22 +
                naming_score * 0.18 +
                spectral_score * 0.12
            )
        elif role == 'bass_loop':
            base_score = (
                cosine_score * 0.24 +
                tempo_score * 0.20 +
                key_score * 0.20 +
                duration_score * 0.16 +
                min(1.0, rms / 0.5) * 0.08 +
                spectral_score * 0.12
            )
        elif role in ['perc_loop', 'top_loop']:
            base_score = (
                cosine_score * 0.24 +
                tempo_score * 0.26 +
                key_score * 0.06 +
                duration_score * 0.16 +
                min(1.0, onset / 3.5) * 0.16 +
                spectral_score * 0.12
            )
        elif role == 'synth_loop':
            base_score = (
                cosine_score * 0.24 +
                tempo_score * 0.16 +
                key_score * 0.22 +
                duration_score * 0.16 +
                naming_score * 0.10 +
                spectral_score * 0.12
            )
            if any(token in file_name for token in ('vocal', 'vox', 'acapella', 'phrase', 'chant')):
                base_score *= 0.25
        elif role == 'vocal_loop':
            base_score = (
                cosine_score * 0.26 +
                tempo_score * 0.20 +
                key_score * 0.06 +
                duration_score * 0.18 +
                naming_score * 0.18 +
                spectral_score * 0.12
            )
        elif role == 'crash_fx':
            base_score = (
                cosine_score * 0.14 +
                tempo_score * 0.06 +
                duration_score * 0.28 +
                naming_score * 0.32 +
                min(1.0, onset / 3.0) * 0.08 +
                spectral_score * 0.12
            )
        elif role == 'fill_fx':
            base_score = (
                cosine_score * 0.16 +
                tempo_score * 0.16 +
                duration_score * 0.22 +
                naming_score * 0.22 +
                min(1.0, onset / 3.0) * 0.12 +
                spectral_score * 0.12
            )
        elif role == 'snare_roll':
            base_score = (
                cosine_score * 0.14 +
                tempo_score * 0.12 +
                duration_score * 0.20 +
                naming_score * 0.28 +
                min(1.0, onset / 2.5) * 0.14 +
                spectral_score * 0.12
            )
        elif role == 'atmos_fx':
            base_score = (
                cosine_score * 0.28 +
                tempo_score * 0.06 +
                key_score * 0.16 +
                duration_score * 0.22 +
                naming_score * 0.16 +
                spectral_score * 0.12
            )
        elif role == 'vocal_shot':
            base_score = (
                cosine_score * 0.20 +
                tempo_score * 0.10 +
                key_score * 0.12 +
                duration_score * 0.20 +
                naming_score * 0.26 +
                spectral_score * 0.12
            )
        else:
            base_score = cosine_score * 0.5 + tempo_score * 0.3 + key_score * 0.2

        return float(base_score) * (0.82 + (0.24 * float(catalog_score)))

    def _spectral_role_score(self, role: str, candidate: Dict[str, Any]) -> float:
        """Score candidate based on spectral characteristics for the role."""
        centroid = float(candidate.get("spectral_centroid", 0.0))
        rolloff = float(candidate.get("spectral_rolloff", 0.0))
        rms_std = float(candidate.get("rms_std", 0.0))
        onset_mean = float(candidate.get("onset_mean", 0.0))
        rms_mean = float(candidate.get("rms_mean", 0.0))

        # Compute spectral spread indicator
        rms_spread = min(1.0, rms_std / max(0.01, rms_mean)) if rms_mean > 0 else 0.5

        # Transient score based on onset
        transient_score = min(1.0, onset_mean / 3.0)

        # Get expected signature for role
        sig = SPECTRAL_ROLE_SIGNATURES.get(role)
        if not sig:
            return 0.5

        score = 0.0

        # Centroid match
        centroid_min, centroid_max = sig.get('centroid_range', (0, 20000))
        if centroid_min <= centroid <= centroid_max:
            score += 0.25
        else:
            # Partial score for being close
            dist = min(abs(centroid - centroid_min), abs(centroid - centroid_max))
            score += 0.25 * math.exp(-dist / 2000)

        # Rolloff match
        rolloff_min, rolloff_max = sig.get('rolloff_range', (0, 20000))
        if rolloff_min <= rolloff <= rolloff_max:
            score += 0.25
        else:
            dist = min(abs(rolloff - rolloff_min), abs(rolloff - rolloff_max))
            score += 0.25 * math.exp(-dist / 3000)

        # RMS spread match (for one-shots vs loops)
        spread_min, spread_max = sig.get('rms_spread', (0.0, 1.0))
        if spread_min <= rms_spread <= spread_max:
            score += 0.25
        else:
            dist = min(abs(rms_spread - spread_min), abs(rms_spread - spread_max))
            score += 0.25 * math.exp(-dist / 0.3)

        # Transient score match
        trans_min, trans_max = sig.get('transient_score', (0.0, 1.0))
        if trans_min <= transient_score <= trans_max:
            score += 0.25
        else:
            dist = min(abs(transient_score - trans_min), abs(transient_score - trans_max))
            score += 0.25 * math.exp(-dist / 0.3)

        return min(1.0, max(0.0, score))

    def _duration_score(self, role: str, duration: float, file_name: str) -> float:
        """Improved duration scoring with better one-shot vs loop detection."""
        file_lower = file_name.lower()

        # One-shot roles: kick, snare/clap, hat
        if role in ['kick', 'snare', 'hat']:
            # Ideal one-shot duration: 0.1 - 1.5 seconds
            is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower
            is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower

            if is_explicit_shot and duration < 3.0:
                return 1.0
            if is_explicit_loop:
                return 0.35

            # Duration-based scoring for one-shots
            if duration < 0.1:
                return 0.4  # Too short, probably artifact
            if duration < 2.0:
                # Sweet spot for one-shots
                peak = 0.5 if role == 'kick' else (0.8 if role == 'hat' else 0.6)
                score = math.exp(-abs(duration - peak) / 1.0)
                return max(0.0, min(1.0, score))
            if duration < 4.0:
                # Could be a roll or extended hit
                return 0.5 if 'roll' in file_lower else 0.3
            return 0.2  # Too long for one-shot

        # Loop roles: bass, perc, top, synth, vocal
        if role in ['bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop']:
            is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower
            is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower

            if is_explicit_shot:
                return 0.25  # One-shot marked as loop role

            # Ideal loop duration: 2 - 16 seconds (typically 4 or 8 bars)
            if duration < 0.5:
                return 0.2  # Too short for a proper loop
            if duration < 2.0:
                # Short loop, acceptable but not ideal
                base_score = duration / 2.0
                if is_explicit_loop:
                    base_score += 0.2
                return min(1.0, base_score)
            if duration < 12.0:
                # Sweet spot for loops (2-8 bars typically)
                score = min(1.0, duration / 6.0)
                if is_explicit_loop:
                    score = min(1.0, score + 0.15)
                return score
            if duration < 20.0:
                # Longer loop, still acceptable
                return 0.75 if is_explicit_loop else 0.6
            return 0.5  # Very long loop

        # FX roles
        if role == 'crash_fx':
            # Crashes: 0.5 - 4 seconds
            if any(marker in file_lower for marker in ['loop', 'top', 'hat', 'snare']):
                return 0.15
            if duration < 0.3:
                return 0.3
            if duration < 5.0:
                return math.exp(-abs(duration - 2.0) / 2.5)
            return 0.4

        if role in ['fill_fx', 'snare_roll']:
            # Fills/rolls: 1 - 8 seconds
            if duration < 0.5:
                return 0.3
            if duration < 8.0:
                return math.exp(-abs(duration - 4.0) / 3.0)
            return 0.5

        if role == 'atmos_fx':
            # Atmos: longer, sustained sounds
            if duration < 2.0:
                return 0.4
            if duration < 30.0:
                return min(1.0, duration / 12.0)
            return 0.8

        if role == 'vocal_shot':
            # Vocal shots: short one-shots
            if duration < 0.2:
                return 0.5
            if duration < 2.0:
                return math.exp(-abs(duration - 0.8) / 1.2)
            if duration < 4.0:
                return 0.4
            return 0.25

        return 0.5

    def _naming_score(self, role: str, file_name: str) -> float:
        if role == 'kick':
            if 'loop' in file_name:
                return 0.45
            return 1.0 if 'kick' in file_name else 0.7
        if role == 'snare':
            if 'roll' in file_name:
                return 0.4
            if 'clap' in file_name or 'snare' in file_name:
                return 1.0
            return 0.7
        if role == 'hat':
            if 'loop' in file_name:
                return 0.7
            if 'closed' in file_name or 'hat' in file_name:
                return 1.0
            return 0.75
        if role == 'vocal_loop':
            if 'vocal' in file_name or 'vox' in file_name:
                return 1.0
            return 0.7
        if role == 'top_loop':
            if 'top' in file_name or 'full drum' in file_name:
                return 1.0
            if 'perc' in file_name:
                return 0.58
            return 0.85 if 'loop' in file_name else 0.65
        if role in ['bass_loop', 'perc_loop', 'synth_loop']:
            return 1.0 if 'loop' in file_name else 0.72
        if role == 'crash_fx':
            if 'crash' in file_name:
                return 1.0
            if 'impact' in file_name:
                return 0.9
            if any(marker in file_name for marker in ['top loop', 'top loops', 'closed hat', 'open hat', 'snare', 'roll']):
                return 0.2
            return 0.65
        if role == 'fill_fx':
            if 'fill' in file_name:
                return 1.0
            if 'tom' in file_name or 'roll' in file_name:
                return 0.84
            return 0.62
        if role == 'snare_roll':
            if 'roll' in file_name:
                return 1.0
            if 'snare' in file_name or 'fill' in file_name:
                return 0.82
            return 0.55
        if role == 'atmos_fx':
            if 'atmos' in file_name:
                return 1.0
            if 'drone' in file_name or 'noise' in file_name:
                return 0.82
            return 0.64
        if role == 'vocal_shot':
            if 'vocal' in file_name or 'importante' in file_name:
                return 1.0
            if 'shot' in file_name:
                return 0.88
            return 0.64
        return 0.8

    def _candidate_path(self, item: Optional[Dict[str, Any]]) -> str:
        if not isinstance(item, dict):
            return ""
        return str(item.get("path", "") or "").strip().lower()

    def _candidate_family(self, item: Optional[Dict[str, Any]]) -> str:
        if not isinstance(item, dict):
            return ""
        family = _infer_semantic_family(
            str(item.get("family", "") or ""),
            str(item.get("file_name", "") or ""),
            str(item.get("path", "") or ""),
        )
        return family or ""

    def _remember_candidate(self, item: Optional[Dict[str, Any]]) -> None:
        path_key = self._candidate_path(item)
        family_key = self._candidate_family(item)
        if path_key:
            self._recent_paths.append(path_key)
            if hasattr(self, '_generation_path_usage'):
                self._generation_path_usage[path_key] += 1
        if family_key:
            self._recent_families.append(family_key)
            # Track usage count for progressive penalty
            self._family_usage_count[family_key] = self._family_usage_count.get(family_key, 0) + 1
            if hasattr(self, '_generation_family_usage'):
                self._generation_family_usage[family_key] += 1

    def _get_family_penalty(self, family_key: str) -> float:
        """Calculate progressive penalty for repeated families."""
        if not family_key:
            return 1.0

        if family_key in self._recent_families:
            return 0.08

        usage_count = self._family_usage_count.get(family_key, 0)
        if usage_count == 0:
            return 1.0
        if usage_count == 1:
            return 0.45
        if usage_count == 2:
            return 0.22
        if usage_count >= 3:
            return 0.08

        return 1.0

    def _get_cross_generation_family_penalty(self, family_key: str) -> float:
        """Penaliza familias usadas en generaciones previas de referencia."""
        if not family_key:
            return 1.0
        usage_count = int(_cross_generation_reference_family_memory.get(family_key, 0) or 0)
        if usage_count <= 0:
            return 1.0
        if usage_count == 1:
            return 0.55
        if usage_count == 2:
            return 0.30
        if usage_count >= 3:
            return 0.08
        return max(0.08, 1.0 - (usage_count * 0.18))

    def _get_cross_generation_path_penalty(self, path_key: str) -> float:
        """Penaliza paths usados en generaciones previas de referencia."""
        if not path_key:
            return 1.0
        usage_count = int(_cross_generation_reference_path_memory.get(path_key, 0) or 0)
        if usage_count <= 0:
            return 1.0
        if usage_count == 1:
            return 0.40
        if usage_count >= 2:
            return 0.15
        return max(0.25, 1.0 - (usage_count * 0.20))

    def _select_candidate(self, role: str, items: List[Dict[str, Any]], rng: random.Random,
                          section_kind: str = "", section_energy: float = 0.5,
                          selected_context: Optional[Dict[str, Any]] = None,
                          sample_selector: Optional[Any] = None) -> Optional[Dict[str, Any]]:
        """
        Select candidate using joint scoring context when available.
        
        Args:
            role: The role to select for
            items: List of candidate items
            rng: Random number generator
            section_kind: Current section kind (intro, build, drop, etc.)
            section_energy: Energy level of the section
            selected_context: Dict of already selected samples for joint scoring
            sample_selector: SampleSelector instance for joint score calculation
        """
        if not items:
            return None

        pool_sizes = {
            "kick": 16,
            "snare": 16,
            "hat": 18,
            "bass_loop": 14,
            "perc_loop": 16,
            "top_loop": 14,
            "synth_loop": 14,
            "vocal_loop": 12,
            "crash_fx": 10,
            "fill_fx": 12,
            "snare_roll": 10,
            "atmos_fx": 10,
            "vocal_shot": 12,
        }
        pool_size = min(pool_sizes.get(role, 10), len(items))
        candidates = list(items[:pool_size])

        section_bonus = {
            'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': -0.06, 'outro': 0.02},
            'snare': {'intro': -0.08, 'verse': 0.06, 'build': 0.10, 'drop': 0.12, 'break': 0.04, 'outro': -0.06},
            'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.12, 'drop': 0.10, 'break': -0.04, 'outro': 0.02},
            'bass_loop': {'intro': -0.10, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': -0.04},
            'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.04, 'outro': 0.00},
            'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.02, 'outro': 0.00},
            'synth_loop': {'intro': 0.04, 'verse': 0.06, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.02},
            'vocal_loop': {'intro': -0.04, 'verse': 0.12, 'build': 0.08, 'drop': 0.14, 'break': 0.08, 'outro': -0.02},
            'crash_fx': {'intro': 0.08, 'verse': 0.02, 'build': 0.14, 'drop': 0.08, 'break': -0.04, 'outro': 0.08},
            'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.16, 'drop': 0.10, 'break': 0.08, 'outro': 0.02},
            'snare_roll': {'intro': -0.06, 'verse': 0.02, 'build': 0.22, 'drop': 0.12, 'break': 0.04, 'outro': -0.08},
            'atmos_fx': {'intro': 0.20, 'verse': 0.04, 'build': 0.02, 'drop': -0.04, 'break': 0.20, 'outro': 0.16},
            'vocal_shot': {'intro': -0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': 0.06, 'outro': -0.02},
        }

        weighted: List[Tuple[float, Dict[str, Any]]] = []

        for index, item in enumerate(candidates):
            score = max(0.001, float(item.get("score", 0.001)))
            rank_penalty = max(0.30, 1.0 - (index * 0.055))

            passes_validation, validation_mod, validation_reason = self._validate_role_requirement(role, item)
            if not passes_validation:
                continue

            score *= validation_mod

            # Apply joint scoring if context available
            joint_factor = 1.0
            if sample_selector and selected_context and hasattr(sample_selector, '_calculate_joint_score'):
                try:
                    joint_factor = sample_selector._calculate_joint_score(
                        item, role, selected_context
                    )
                    score *= joint_factor
                except Exception as e:
                    logger.debug(f"JOINT_SCORE_ERROR in _select_candidate [{role}]: {e}")

            # P3: Apply human feel / groove scoring based on section context
            file_name = str(item.get("file_name", "") or "").lower()
            groove_factor = _score_groove_factor(file_name, section_kind)
            complexity_factor = _score_complexity_match(file_name, section_kind)
            score *= groove_factor * complexity_factor

            path_key = self._candidate_path(item)
            path_penalty = 0.12 if path_key in self._recent_paths else 1.0

            family_key = self._candidate_family(item)
            family_penalty = self._get_family_penalty(family_key)
            cross_family_penalty = self._get_cross_generation_family_penalty(family_key)
            cross_path_penalty = self._get_cross_generation_path_penalty(path_key)

            section_bonus_val = section_bonus.get(role.lower(), {}).get(section_kind.lower(), 0.0)
            if section_kind.lower() in {'drop', 'build'} and section_energy > 0.7:
                section_bonus_val *= 1.2
            elif section_kind.lower() in {'break', 'intro'} and section_energy < 0.4:
                section_bonus_val *= 1.2

            energy_mod = 1.0
            rms = float(item.get("rms_mean", 0.0) or 0.0)
            if role.lower() in {"kick", "snare", "bass_loop"}:
                if rms > 0.08:
                    energy_mod = min(1.15, 1.0 + (rms - 0.08) * 2.0)
                elif rms < 0.03 and section_kind.lower() not in {"intro", "break"}:
                    energy_mod = 0.85

            role_randomness = 0.88 + (rng.random() * 0.24)

            weight = (
                (score ** 1.7)
                * rank_penalty
                * path_penalty
                * family_penalty
                * cross_family_penalty
                * cross_path_penalty
                * role_randomness
                * energy_mod
            )

            if section_bonus_val > 0:
                weight *= (1.0 + section_bonus_val)
            elif section_bonus_val < 0:
                weight *= (1.0 + section_bonus_val * 0.5)

            weighted.append((max(0.001, weight), item))

        if not weighted:
            weighted = [(max(0.001, float(item.get("score", 0.001))), item) for item in candidates]

        total = sum(weight for weight, _ in weighted)
        if total <= 0:
            return candidates[0] if candidates else None

        pivot = rng.random() * total
        running = 0.0
        for weight, item in weighted:
            running += weight
            if pivot <= running:
                return item

        return weighted[0][1]

    def _select_distinct_candidate(
        self,
        role: str,
        items: List[Dict[str, Any]],
        rng: random.Random,
        used_paths: set,
        used_families: set,
        section_kind: str = "",
        section_energy: float = 0.5,
        selected_context: Optional[Dict[str, Any]] = None,
        sample_selector: Optional[Any] = None,
    ) -> Optional[Dict[str, Any]]:
        """
        Select distinct candidate using joint scoring when available.
        P1.3 Sprint v0.1.39: Enhanced to allow more variation while maintaining coherence.
        
        Changes:
        - Allow multiple valid candidates per role (not just top 1)
        - Add section-specific alternates
        - Add timbral variation within same family/pack/bus
        - Keep hard constraints (family lock, no random packs)
        
        Args:
            role: The role to select for
            items: List of candidate items
            rng: Random number generator
            used_paths: Set of already used paths (for diversity)
            used_families: Set of already used families (for diversity)
            section_kind: Current section kind
            section_energy: Energy level of the section
            selected_context: Dict of already selected samples for joint scoring
            sample_selector: SampleSelector instance for joint score calculation
        """
        if not items:
            return None

        filtered = [
            item for item in items
            if self._candidate_path(item) not in used_paths
        ]

        family_filtered = [
            item for item in filtered
            if self._candidate_family(item) not in used_families
        ]

        pool = family_filtered if family_filtered else filtered if filtered else items

        # P1.3: Instead of just selecting one, score all candidates
        if sample_selector and hasattr(sample_selector, '_calculate_joint_score'):
            scored_candidates = []
            
            for candidate in pool:
                base_score = candidate.get('score', 1.0)
                
                try:
                    selected_context_dict = {}
                    if selected_context:
                        selected_context_dict = selected_context
                    
                    joint_factor = sample_selector._calculate_joint_score(
                        candidate, role, selected_context_dict
                    )
                except Exception:
                    joint_factor = 1.0
                
                final_score = base_score * joint_factor
                
                # P1.3: Add timbral variation bonus for diversity
                candidate_family = self._candidate_family(candidate)
                timbral_bonus = 1.0
                
                # If this family hasn't been used much, give it a slight bonus
                family_usage = self._family_usage_count.get(candidate_family, 0)
                if family_usage == 0:
                    timbral_bonus = 1.15  # 15% bonus for fresh timbre
                elif family_usage == 1:
                    timbral_bonus = 1.05  # 5% bonus for second use
                
                final_score *= timbral_bonus
                
                scored_candidates.append({
                    'candidate': candidate,
                    'base_score': base_score,
                    'joint_factor': joint_factor,
                    'timbral_bonus': timbral_bonus,
                    'final_score': final_score
                })
            
            # Sort by final score
            scored_candidates.sort(key=lambda x: x['final_score'], reverse=True)
            
            # P1.3: Select from top candidates with weighted random choice
            # This allows variation while still preferring higher scores
            if scored_candidates:
                # Take top 3-5 candidates (depending on pool size)
                top_n = min(5, len(scored_candidates))
                top_candidates = scored_candidates[:top_n]
                
                # P1.3: If scores are close (within 15%), use weighted random selection
                # This allows variation while respecting scoring
                top_score = top_candidates[0]['final_score']
                close_candidates = [
                    c for c in top_candidates
                    if c['final_score'] >= top_score * 0.85
                ]
                
                if len(close_candidates) > 1:
                    # Weighted random selection among close candidates
                    weights = [c['final_score'] for c in close_candidates]
                    total_weight = sum(weights)
                    r = rng.random() * total_weight
                    cumulative = 0
                    for i, c in enumerate(close_candidates):
                        cumulative += c['final_score']
                        if r <= cumulative:
                            selected = c['candidate']
                            logger.debug(
                                "[P1.3_VARIATION] Selected %s (rank %d, score %.2f) from %d close candidates for %s",
                                selected.get('file_name', 'unknown'),
                                i + 1,
                                c['final_score'],
                                len(close_candidates),
                                role
                            )
                            break
                    else:
                        selected = close_candidates[0]['candidate']
                else:
                    selected = close_candidates[0]['candidate']
            else:
                selected = None
        else:
            # Fallback to original selection
            selected = self._select_candidate(
                role, pool, rng, section_kind, section_energy,
                selected_context=selected_context,
                sample_selector=sample_selector
            )
        
        selected_path = self._candidate_path(selected)
        selected_family = self._candidate_family(selected)

        if selected_path:
            used_paths.add(selected_path)
        if selected_family:
            used_families.add(selected_family)

        self._remember_candidate(selected)
        return selected

    def reset_family_tracking(self) -> None:
        """Reset family usage tracking for a new generation."""
        self._family_usage_count.clear()
        self._recent_families.clear()
        self._recent_paths.clear()

    def start_generation_tracking(self) -> None:
        """Inicia tracking de paths/familias para una generaciÃƒÂ³n nueva."""
        self._generation_family_usage = defaultdict(int)
        self._generation_path_usage = defaultdict(int)

    def end_generation_tracking(self) -> None:
        """Actualiza memoria cross-generation de la ruta de referencia."""
        for key in list(_cross_generation_reference_family_memory.keys()):
            _cross_generation_reference_family_memory[key] = max(0, _cross_generation_reference_family_memory[key] - 1)
        for key in list(_cross_generation_reference_path_memory.keys()):
            _cross_generation_reference_path_memory[key] = max(0, _cross_generation_reference_path_memory[key] - 1)

        for family, count in dict(getattr(self, '_generation_family_usage', {})).items():
            if family:
                _cross_generation_reference_family_memory[family] += int(count)
        for path_key, count in dict(getattr(self, '_generation_path_usage', {})).items():
            if path_key:
                _cross_generation_reference_path_memory[path_key] += int(count)

        for key in list(_cross_generation_reference_family_memory.keys()):
            if _cross_generation_reference_family_memory[key] <= 0:
                del _cross_generation_reference_family_memory[key]
        for key in list(_cross_generation_reference_path_memory.keys()):
            if _cross_generation_reference_path_memory[key] <= 0:
                del _cross_generation_reference_path_memory[key]

        if hasattr(self, '_generation_family_usage'):
            delattr(self, '_generation_family_usage')
        if hasattr(self, '_generation_path_usage'):
            delattr(self, '_generation_path_usage')

    def reset_cross_generation_tracking(self) -> None:
        """Resetea la memoria de diversidad entre generaciones para referencia."""
        _cross_generation_reference_family_memory.clear()
        _cross_generation_reference_path_memory.clear()

    def reset_recent_sample_diversity_memory(self) -> None:
        """Resetea la memoria de diversidad de samples recientes por rol."""
        global _recent_sample_diversity_memory
        _recent_sample_diversity_memory.clear()

    # ============================================================================
    # PALETTE DOMINANCE ENFORCEMENT (New)
    # ============================================================================

    def _extract_pack(self, sample_path: str) -> str:
        """Extract pack name from sample path."""
        return _extract_pack_from_path(sample_path)

    def _is_sibling_pack(self, pack1: str, pack2: str) -> bool:
        """Check if two packs belong to the same broader family."""
        pack1_norm = str(pack1 or "").strip().lower()
        pack2_norm = str(pack2 or "").strip().lower()
        if not pack1_norm or not pack2_norm:
            return False
        if pack1_norm == pack2_norm:
            return True

        pack_families = {
            'ss_rnbl': 'latin',
            'sentimientolatino2025': 'latin',
            'midilatino': 'latin',
            'reggaeton 3': 'latin',
            '16bloody': 'drum',
            'drumloops': 'drum',
            'bigcayu': 'fx',
        }
        if pack_families.get(pack1_norm) and pack_families.get(pack1_norm) == pack_families.get(pack2_norm):
            return True

        common_prefix = 0
        for idx in range(min(len(pack1_norm), len(pack2_norm))):
            if pack1_norm[idx] == pack2_norm[idx]:
                common_prefix += 1
            else:
                break
        return common_prefix >= max(3, int(min(len(pack1_norm), len(pack2_norm)) * 0.5))

    def select_dominant_palette(self, candidates_by_role: Dict[str, List[Dict[str, Any]]],
                                genre: str = 'reggaeton') -> Dict[str, str]:
        """
        P0: Select dominant packs per bus for bus-aware coherence.
        
        Returns a dict mapping bus -> dominant_pack:
        {
            'drums': 'ss_rnbl',      # or '16bloody', 'drumloops'
            'music': 'midilatino_*', # or 'sentimientolatino2025'
            'fx': 'reggaeton 3',     # or 'bigcayu', 'impact'
            'vocal': 'midilatino_*'    # vocals often share with music
        }
        
        Each bus gets its own dominant pack based on which packs serve that bus's roles best.
        """
        # Score each pack by how well it serves each bus's roles
        pack_scores = {}
        
        for role, candidates in candidates_by_role.items():
            bus = _get_bus_for_role(role)
            for candidate in candidates:
                pack = self._extract_pack(candidate.get('path', ''))
                if pack not in pack_scores:
                    pack_scores[pack] = {'score': 0.0, 'roles': [], 'bus_scores': {}}
                
                # Weight by score and role importance
                candidate_score = candidate.get('score', 0.0)
                role_weight = 1.0
                if role in ['kick', 'snare', 'bass_loop']:
                    role_weight = 2.0  # Core roles weighted higher
                
                pack_scores[pack]['score'] += candidate_score * role_weight
                if role not in pack_scores[pack]['roles']:
                    pack_scores[pack]['roles'].append(role)
                
                # Track bus-specific scores
                if bus not in pack_scores[pack]['bus_scores']:
                    pack_scores[pack]['bus_scores'][bus] = 0.0
                pack_scores[pack]['bus_scores'][bus] += candidate_score * role_weight
        
        if not pack_scores:
            logger.warning("DOMINANT_PALETTE: No packs found in candidates")
            return {
                'drums': 'unknown',
                'music': 'unknown',
                'fx': 'unknown',
                'vocal': 'unknown',
                'overall': 'unknown'
            }
        
        # Select dominant pack per bus
        dominant_packs = {}
        
        for bus_name in ['drums', 'music', 'fx', 'vocal']:
            dominant_pack = _get_dominant_pack_for_bus(pack_scores, bus_name, candidates_by_role)
            dominant_packs[bus_name] = dominant_pack
            
            # Log selection details
            if dominant_pack in pack_scores:
                bus_score = pack_scores[dominant_pack]['bus_scores'].get(bus_name, 0.0)
                roles = pack_scores[dominant_pack]['roles']
                bus_roles = set(BUS_PACK_GROUPS.get(bus_name, {}).get('roles', []))
                covered_bus_roles = bus_roles.intersection(set(roles))
                logger.info(f"DOMINANT_PALETTE [{bus_name}]: '{dominant_pack}' "
                           f"(score={bus_score:.2f}, covers {len(covered_bus_roles)}/{len(bus_roles)} roles)")
        
        # Determine overall dominant pack (highest total score across all buses)
        sorted_packs = sorted(
            pack_scores.items(),
            key=lambda x: x[1]['score'],
            reverse=True
        )
        dominant_packs['overall'] = sorted_packs[0][0]
        
        logger.info(f"DOMINANT_PALETTE: Bus-aware selection complete - drums={dominant_packs['drums']}, "
                   f"music={dominant_packs['music']}, fx={dominant_packs['fx']}, "
                   f"vocal={dominant_packs.get('vocal', dominant_packs['music'])}")
        
        return dominant_packs

    def _select_with_pack_constraint(self,
                                      role: str,
                                      candidates: List[Dict[str, Any]],
                                      dominant_pack: str,
                                      strict: bool = True) -> Optional[Dict[str, Any]]:
        """Select sample, preferring dominant pack."""

        if not candidates:
            return None

        # Filter to dominant pack first
        dominant_candidates = [c for c in candidates
                             if dominant_pack.lower() in c.get('path', '').lower()]

        if dominant_candidates and strict:
            # Strict mode: ONLY use dominant pack
            selected = self._select_best(dominant_candidates)
            if selected:
                logger.debug(f"PACK_STRICT [{role}]: Selected from {dominant_pack} - "
                           f"{selected.get('file_name', 'unknown')}")
            return selected

        elif dominant_candidates:
            # Soft mode: Prefer dominant, allow others with penalty
            selected = self._select_best(candidates, prefer_pack=dominant_pack)
            if selected:
                selected_pack = self._extract_pack(selected.get('path', ''))
                if selected_pack != dominant_pack:
                    logger.warning(f"PACK_FALLBACK [{role}]: Using non-dominant pack "
                                 f"({selected_pack} instead of {dominant_pack}) - "
                                 f"{selected.get('file_name', 'unknown')}")
                else:
                    logger.debug(f"PACK_SOFT [{role}]: Selected from dominant pack - "
                               f"{selected.get('file_name', 'unknown')}")
            return selected

        else:
            # No dominant pack match - omit or use fallback
            if strict:
                logger.error(f"PACK_OMIT [{role}]: No match in {dominant_pack}, omitting layer")
                return None  # Don't add this layer
            else:
                logger.warning(f"PACK_FALLBACK [{role}]: No dominant match, using best available")
                return self._select_best(candidates)

    def _select_best(self, candidates: List[Dict[str, Any]],
                     prefer_pack: Optional[str] = None) -> Optional[Dict[str, Any]]:
        """Select best candidate, optionally preferring a specific pack."""
        if not candidates:
            return None

        # If prefer_pack is specified, boost scores for that pack
        if prefer_pack:
            scored_candidates = []
            for c in candidates:
                score = c.get('score', 0.0)
                pack = self._extract_pack(c.get('path', ''))
                if pack == prefer_pack:
                    score *= 1.5  # 50% bonus for preferred pack
                scored_candidates.append((score, c))

            scored_candidates.sort(reverse=True, key=lambda x: x[0])
            return scored_candidates[0][1] if scored_candidates else None

        # Default: return highest scored candidate
        return max(candidates, key=lambda x: x.get('score', 0.0))

    def verify_pack_coherence(self, selections: Dict[str, Optional[Dict[str, Any]]],
                              dominant_packs: Dict[str, str],
                              primary_harmonic_family: Optional[str] = None) -> Dict[str, Any]:
        """
        P0: Verify pack coherence per bus.
        
        Calculates coherence metrics for each bus independently:
        - What % of layers in that bus come from the bus's dominant group
        - Overall coherence = weighted average across buses
        
        Args:
            selections: role -> selected sample mapping
            dominant_packs: bus -> dominant_pack mapping (from select_dominant_palette)
            primary_harmonic_family: Optional harmonic family for additional validation
            
        Returns:
            Coherence report with per-bus metrics
        """
        dominant_packs = _normalize_dominant_packs(dominant_packs)

        # Initialize per-bus counters
        bus_stats = {
            'drums': {'from_dominant': 0, 'total': 0, 'packs': set()},
            'music': {'from_dominant': 0, 'total': 0, 'packs': set()},
            'fx': {'from_dominant': 0, 'total': 0, 'packs': set()},
            'vocal': {'from_dominant': 0, 'total': 0, 'packs': set()},
        }
        
        # Track harmonic coherence separately
        harmonic_matches = 0
        harmonic_compatible = 0
        harmonic_mismatches = 0
        harmonic_roles_checked = 0
        
        # Analyze each selection
        for role, selected in selections.items():
            if not selected:
                continue
                
            bus = _get_bus_for_role(role)
            selected_pack = self._extract_pack(selected.get('path', ''))
            dominant_pack = dominant_packs.get(bus, dominant_packs.get('overall', 'unknown'))
            
            # Count for this bus
            if bus in bus_stats:
                bus_stats[bus]['total'] += 1
                bus_stats[bus]['packs'].add(selected_pack)
                
                # Check if from dominant pack or compatible pack in same bus group
                if selected_pack == dominant_pack:
                    bus_stats[bus]['from_dominant'] += 1
                elif _is_pack_in_bus_group(selected_pack, bus):
                    # Pack is from same bus group - count as coherent
                    bus_stats[bus]['from_dominant'] += 1
            
            # Check harmonic coherence for critical roles
            if primary_harmonic_family and role in HarmonicCoherenceValidator.CRITICAL_HARMONIC_ROLES:
                harmonic_roles_checked += 1
                sample_path = selected.get('path', '')
                validator = HarmonicCoherenceValidator(primary_harmonic_family, dominant_pack, '')
                family = validator._extract_family(sample_path)
                
                if family.lower() == primary_harmonic_family.lower():
                    harmonic_matches += 1
                elif validator._is_compatible_family(family.lower(), primary_harmonic_family.lower()):
                    harmonic_compatible += 1
                else:
                    harmonic_mismatches += 1
        
        # Calculate per-bus coherence ratios
        per_bus_coherence = {}
        total_from_dominant = 0
        total_samples = 0
        
        # P2 Sprint v0.1.17: Music bus target threshold
        MUSIC_COHERENCE_TARGET = 0.65
        
        for bus, stats in bus_stats.items():
            total = stats['total']
            from_dominant = stats['from_dominant']
            ratio = from_dominant / total if total > 0 else 0.0
            
            # P2: Stricter threshold for music bus (0.65 vs 0.6 for others)
            threshold = MUSIC_COHERENCE_TARGET if bus == 'music' else 0.6
            
            per_bus_coherence[bus] = {
                'dominant_pack': dominant_packs.get(bus, dominant_packs.get('overall', 'unknown')),
                'samples_from_dominant': from_dominant,
                'total_samples': total,
                'ratio': round(ratio, 3),
                'unique_packs': len(stats['packs']),
                'packs': list(stats['packs']),
                'target_threshold': threshold,
                'status': 'OK' if ratio >= threshold else 'NEEDS_IMPROVEMENT' if total > 0 else 'N/A'
            }
            
            total_from_dominant += from_dominant
            total_samples += total
            
            if total > 0:
                target_str = f" target>={threshold:.0%}" if bus == 'music' else ""
                logger.info(f"PACK_COHERENCE [{bus}]: {from_dominant}/{total} from dominant group "
                           f"({ratio:.0%}){target_str} - packs: {stats['packs']}")
        
        # Calculate overall weighted coherence
        overall_ratio = total_from_dominant / total_samples if total_samples > 0 else 0.0
        
        logger.info(f"PACK_COHERENCE [OVERALL]: {total_from_dominant}/{total_samples} coherent "
                   f"({overall_ratio:.0%})")
        
        # Build harmonic coherence report
        harmonic_report = None
        if primary_harmonic_family and harmonic_roles_checked > 0:
            total_harmonic = harmonic_matches + harmonic_compatible + harmonic_mismatches
            if total_harmonic > 0:
                harmonic_ratio = (harmonic_matches + harmonic_compatible * 0.5) / total_harmonic
                logger.info(f"HARMONIC_COHERENCE: {harmonic_matches} exact, {harmonic_compatible} compatible, "
                           f"{harmonic_mismatches} mismatches for family '{primary_harmonic_family}'")
                logger.info(f"HARMONIC_RATIO: {harmonic_ratio:.0%}")
                harmonic_report = {
                    'primary_family': primary_harmonic_family,
                    'roles_checked': harmonic_roles_checked,
                    'exact_matches': harmonic_matches,
                    'compatible': harmonic_compatible,
                    'mismatches': harmonic_mismatches,
                    'ratio': round(harmonic_ratio, 3),
                    'status': 'OK' if harmonic_mismatches == 0 else 'NEEDS_IMPROVEMENT'
                }
        
        # Determine overall status
        overall_status = 'OK' if overall_ratio > 0.6 else 'NEEDS_IMPROVEMENT'
        
        # P2 Sprint v0.1.17: Explicit music bus coherence in manifest
        music_coherence = per_bus_coherence.get('music', {})
        music_ratio = music_coherence.get('ratio', 0.0)
        
        return {
            'overall': {
                'samples_from_dominant': total_from_dominant,
                'total_samples': total_samples,
                'ratio': round(overall_ratio, 3),
                'status': overall_status,
            },
            'per_bus': per_bus_coherence,
            'music': {  # P2: Explicit music bus coherence metric
                'ratio': round(music_ratio, 3),
                'target': MUSIC_COHERENCE_TARGET,
                'meets_target': music_ratio >= MUSIC_COHERENCE_TARGET,
                'dominant_pack': dominant_packs.get('music', dominant_packs.get('overall', 'unknown')),
                'samples_from_dominant': music_coherence.get('samples_from_dominant', 0),
                'total_samples': music_coherence.get('total_samples', 0),
                'unique_packs': music_coherence.get('unique_packs', 0),
                'packs': music_coherence.get('packs', []),
                'status': music_coherence.get('status', 'N/A'),
            },
            'dominant_packs': dominant_packs,
            'harmonic_coherence': harmonic_report
        }

    def _select_layers_with_budget(
        self,
        matches: Dict[str, List[Dict[str, Any]]],
        genre: str = 'reggaeton',
        project_bpm: float = 128.0,
        dominant_packs: Optional[Dict[str, str]] = None,
        strict_pack_mode: bool = True,
        harmonic_hints: Optional[Dict[str, Any]] = None,
        primary_harmonic_family: Optional[str] = None,
        sample_selector: Optional[Any] = None,
        plan_midi_hook: bool = True,  # P3: Track if MIDI hook should be planned
    ) -> Dict[str, Optional[Dict[str, Any]]]:
        """
        P0: Select layers with bus-aware pack coherence.
        P3: Enforce "armonía MIDI + piano + librería" hybrid truth.

        1. Select dominant packs per bus if not provided (dict format)
        2. Reserve budget slot for MIDI hook BEFORE filling with audio layers
        3. Ensure piano/keys hint from preferred_secondary_families is honored
        4. Select CORE layers first using JOINT_SCORE as main ranking
        5. Apply bus-aware pack scoring: each role uses its bus's dominant pack
        6. Non-harmonic roles (kick, clap, hat) can mix across packs freely
        7. Harmonic roles get strict coherence enforcement
        8. Log detailed scoring information
        9. Select OPTIONAL layers only if budget remains (after hook reservation)

        Returns dict of role -> selected sample with detailed selection log.
        """
        budget = TRACK_BUDGET.get(genre.lower(), TRACK_BUDGET['default'])

        logger.info(f"BUDGET_START: Genre={genre}, Max={budget['total_max']} tracks, "
                   f"Strict={strict_pack_mode}, JOINT_SCORE={'enabled' if sample_selector else 'disabled'}, "
                   f"P3_MIDI_HOOK_PLANNED={plan_midi_hook}")

        # P3: Reserve 1 slot for MIDI hook if planned (mandatory priority)
        if plan_midi_hook:
            # Reserve slot for hook - reduces effective budget for audio layers
            hook_reserved = True
            effective_audio_budget = budget['total_max'] - 1
            logger.info(f"[P3_HOOK_RESERVED] Reserved 1 slot for MIDI hook. "
                       f"Audio layers budget: {effective_audio_budget}/{budget['total_max']}")
        else:
            hook_reserved = False
            effective_audio_budget = budget['total_max']

        # P3: Log piano/keys hints enforcement
        if harmonic_hints:
            preferred_secondary = harmonic_hints.get('preferred_secondary_families', ['piano', 'keys'])
            logger.info(f"[P3_PIANO_HINTS] Enforcing piano/keys support: {preferred_secondary}")

        # P0: Select dominant packs per bus if not provided (new dict format)
        if dominant_packs is None:
            dominant_packs = self.select_dominant_palette(matches, genre)
        if not isinstance(dominant_packs, dict):
            logger.info(
                "DOMINANT_PALETTE_CONVERT: Converted single pack '%s' to bus-aware format",
                dominant_packs,
            )
        dominant_packs = _normalize_dominant_packs(dominant_packs)

        # Get overall dominant for reference
        overall_dominant = dominant_packs.get('overall', dominant_packs.get('music', 'unknown'))

        selected: Dict[str, Optional[Dict[str, Any]]] = {}
        used_paths: set = set()
        used_families: set = set()
        rng = random.Random(int(time.time() * 1000) % 10000)

        # Initialize selection log and auditor for manifest
        if not hasattr(self, '_selection_log'):
            self._selection_log: List[Dict[str, Any]] = []
        self._selection_log.clear()
        
        # Create SelectionAuditor with bus-aware tracking
        selection_auditor = SelectionAuditor()
        
        # Create harmonic coherence validator if primary family is set
        coherence_validator = None
        target_key = None
        if primary_harmonic_family:
            # Get target key from harmonic hints or fallback
            if harmonic_hints:
                target_key = harmonic_hints.get('target_key') or harmonic_hints.get('key')
            
            coherence_validator = HarmonicCoherenceValidator(
                primary_family=primary_harmonic_family,
                dominant_pack=overall_dominant,
                target_key=target_key or ''
            )
            logger.info(f"HARMONIC_VALIDATOR: Created for family={primary_harmonic_family}, "
                       f"pack={overall_dominant}, key={target_key}")

        # P0: Helper to get dominant pack for a role's bus
        def _get_dominant_pack_for_role(role: str) -> str:
            """Get the dominant pack for the bus this role belongs to."""
            bus = _get_bus_for_role(role)
            return dominant_packs.get(bus, overall_dominant)

        # P0: Helper to calculate pack bonus with bus awareness
        # P2 Sprint v0.1.17: Enhanced music bus strict coherence
        # P1 Sprint v0.1.18: Only allow 1 secondary pack with clear functional justification
        def _calculate_bus_aware_pack_bonus(
            candidate_pack: str, 
            role: str, 
            strict: bool = False
        ) -> Tuple[float, str]:
            """
            Calculate pack bonus for a candidate based on bus-aware coherence.
            
            P2 Changes for music bus:
            - Primary music pack: 2.0x bonus
            - Secondary music pack (tracked, 1 max): 1.3x bonus  
            - Unrelated pack for music: 0.3x penalty (70% reduction)
            
            P1 v0.1.18 Enhancements:
            - Only 1 secondary pack maximum
            - Secondary pack must have clear functional justification
            - Complementary roles only (no competing layers)
            
            Returns:
                Tuple of (bonus_multiplier, reason_string)
            """
            nonlocal music_bus_secondary_pack, music_bus_secondary_justification, music_bus_primary_roles
            
            bus = _get_bus_for_role(role)
            dominant_pack = dominant_packs.get(bus, overall_dominant)
            
            # P2 Sprint v0.1.17: Strict music bus enforcement
            # P1 Sprint v0.1.18: Enhanced with functional justification
            if bus == 'music' and role.lower() in MUSIC_BUS_ROLES:
                # Music bus: strict pack coherence required
                if candidate_pack == dominant_pack:
                    # P1: Track this role as part of primary pack
                    music_bus_primary_roles.add(role.lower())
                    return 2.0, "music_primary_pack"
                
                # Check if this is/should be the secondary music pack
                # P1: Only allow secondary if it serves a complementary function
                if music_bus_secondary_pack is None:
                    # First non-primary pack - check if it's justifiable as secondary
                    # Must have clear complementary function to dominant pack roles
                    if _is_pack_in_bus_group(candidate_pack, 'music'):
                        # P1: Check if this role is complementary to existing primary pack roles
                        is_complementary = False
                        justification = "no_clear_function"
                        
                        if music_bus_primary_roles:
                            # Check if current role complements what's in primary pack
                            complementary_to = MUSIC_ROLE_COMPLEMENTARY_PAIRS.get(role.lower(), [])
                            overlap = music_bus_primary_roles.intersection(set(complementary_to))
                            if overlap:
                                is_complementary = True
                                justification = f"complements_{'_'.join(overlap)}"
                        else:
                            # No primary roles yet - allow as potential secondary for contrast
                            is_complementary = True
                            justification = "primary_contrast"
                        
                        # P1: Only establish secondary if functionally justified
                        if is_complementary:
                            music_bus_secondary_pack = candidate_pack
                            music_bus_secondary_justification = justification
                            logger.info(f"[P1_MUSIC_SECONDARY] Established secondary pack: {candidate_pack} "
                                       f"for role '{role}' (justification: {justification})")
                            return 1.3, f"music_secondary_pack_{justification}"
                        else:
                            # No clear function - heavy penalty
                            logger.warning(f"[P1_MUSIC_REJECT] Rejected pack {candidate_pack} for role '{role}' - "
                                          f"no complementary function to primary pack roles: {music_bus_primary_roles}")
                            return 0.2, "music_no_function_rejected"
                
                elif candidate_pack == music_bus_secondary_pack:
                    # Already-established secondary pack
                    return 1.3, f"music_secondary_pack_{music_bus_secondary_justification or 'existing'}"
                elif candidate_pack != dominant_pack:
                    # P1: Third pack or unrelated pack for music bus - severe penalty
                    logger.warning(f"[P1_MUSIC_THIRD_PACK] Rejecting third pack {candidate_pack} for music bus - "
                                  f"only 1 primary + 1 secondary allowed (primary roles: {music_bus_primary_roles})")
                    return 0.1, "music_third_pack_rejected"
                
                # Unrelated pack for music bus - heavy penalty
                if strict:
                    return 0.3, "music_unrelated_strict_penalty"
                else:
                    return 0.5, "music_unrelated_soft_penalty"
            
            # Non-harmonic roles get full flexibility
            if role.lower() in NON_HARMONIC_ROLES:
                # Still prefer same pack but don't penalize mixing
                if candidate_pack == dominant_pack:
                    return 1.2, "non_harmonic_dominant_match"
                elif self._is_sibling_pack(candidate_pack, dominant_pack):
                    return 1.1, "non_harmonic_sibling"
                else:
                    return 1.0, "non_harmonic_flexible"
            
            # Harmonic roles (non-music bus): standard coherence enforcement
            if candidate_pack == dominant_pack:
                return 2.0, "harmonic_dominant_match"
            elif _is_pack_in_bus_group(candidate_pack, bus):
                # Pack belongs to same bus group (e.g., different music pack)
                return 1.5, f"bus_group_match_{bus}"
            elif self._is_sibling_pack(candidate_pack, dominant_pack):
                return 1.3, "harmonic_sibling"
            elif strict:
                # Strict mode: heavy penalty for off-pack harmonic roles
                return 0.3, "harmonic_strict_penalty"
            else:
                # Soft mode: moderate penalty
                return 0.6, "harmonic_soft_penalty"

        # Helper to get compatible families for a primary family
        def _get_compatible_families(primary: str) -> set:
            """Return set of families that are harmonically compatible."""
            compatibility_map = {
                'piano': {'keys', 'pad', 'atmosphere', 'pluck'},
                'keys': {'piano', 'pad', 'atmosphere', 'pluck'},
                'pad': {'piano', 'keys', 'atmosphere', 'pluck', 'ambient'},
                'atmosphere': {'pad', 'piano', 'keys', 'ambient'},
                'pluck': {'piano', 'keys', 'lead', 'guitar'},
                'lead': {'pluck', 'synth', 'pad'},
                'guitar': {'pluck', 'acoustic'},
                'bass': {'sub', 'reese', '808'},
                'reese': {'bass', 'sub'},
                '808': {'bass', 'sub'},
            }
            return compatibility_map.get(primary.lower(), set())

        # Helper to extract family from sample path/name
        def _extract_family_hint(sample_path: str) -> str:
            """Extract harmonic family hint from sample path."""
            return _infer_semantic_family(sample_path) or 'unknown'

        preferred_secondary = _normalize_preferred_secondary_families(
            harmonic_hints.get('preferred_secondary_families', ['piano', 'keys']) if harmonic_hints else ['piano', 'keys'],
            primary_harmonic_family,
        )
        
        # P0: Strict pack roles - only harmonic roles need strict enforcement
        strict_pack_roles = set(HarmonicCoherenceValidator.CRITICAL_HARMONIC_ROLES) - NON_HARMONIC_ROLES
        
        # P2 Sprint v0.1.17: Music bus strict coherence tracking
        # P1 Sprint v0.1.18: Enhanced to allow only 1 secondary pack with clear function
        # Track secondary music pack with justification for controlled musical variety
        music_bus_secondary_pack: Optional[str] = None
        music_bus_secondary_justification: Optional[str] = None  # P1: Track why secondary pack is needed
        music_bus_primary_roles: set = set()  # P1: Track which roles are in primary pack
        MUSIC_BUS_ROLES = {'synth_loop', 'pad', 'pluck', 'chords', 'lead', 'arp', 'drone', 'stab', 'counter'}
        
        # P1 v0.1.18: Define complementary role pairs for secondary pack justification
        # Secondary pack is only allowed if it serves a clearly different function
        MUSIC_ROLE_COMPLEMENTARY_PAIRS = {
            'pad': ['lead', 'pluck', 'arp', 'stab'],  # Pad supports melodic elements
            'lead': ['pad', 'chords', 'drone'],  # Lead needs harmonic support
            'pluck': ['pad', 'chords', 'drone'],  # Pluck needs harmonic bed
            'chords': ['lead', 'pluck', 'arp'],  # Chords support melodic roles
            'arp': ['pad', 'chords', 'drone'],  # Arp needs harmonic foundation
            'drone': ['lead', 'pluck', 'arp'],  # Drone supports melodic elements
            'synth_loop': ['pad', 'drone'],  # Synth loop can be supported by pads
        }

        def _record_fallback_selection(role: str, sample: Dict[str, Any], all_candidates: List[Dict[str, Any]]) -> None:
            if not sample:
                return
            candidate_path = self._candidate_path(sample) or sample.get('path', '')
            candidate_family = self._candidate_family(sample) or _extract_family_hint(candidate_path)
            piano_bonus, piano_reason = _get_piano_forward_bonus(
                role,
                candidate_family,
                preferred_secondary,
            )
            candidate_pack = self._extract_pack(candidate_path)
            
            # P0: Use bus-aware pack bonus calculation
            pack_bonus, pack_reason = _calculate_bus_aware_pack_bonus(candidate_pack, role, strict_pack_mode)

            coherence_score = 1.0
            coherence_reasons = [pack_reason]
            is_coherent = True
            if coherence_validator:
                is_coherent, coherence_score, validator_reasons = coherence_validator.validate_candidate(sample, role)
                coherence_reasons.extend(validator_reasons)

            final_score = float(sample.get('score', 1.0) or 1.0) * pack_bonus * piano_bonus * coherence_score
            winner = {
                'candidate': sample,
                'base_score': float(sample.get('score', 1.0) or 1.0),
                'joint_factor': 1.0,
                'coherence_score': coherence_score,
                'coherence_reasons': coherence_reasons,
                'is_coherent': is_coherent,
                'piano_bonus': piano_bonus,
                'pack_bonus': pack_bonus,
                'candidate_pack': candidate_pack,
                'bus': _get_bus_for_role(role),
                'final_score': final_score,
            }
            audit_context = {
                'primary_harmonic_family': primary_harmonic_family,
                'dominant_packs': dominant_packs,
                'target_key': target_key if target_key else '',
                'preferred_secondary_families': preferred_secondary,
                'selected_roles': list(selected.keys()),
                'selected_families': list(used_families),
            }
            selection_auditor.record_selection(role, winner, [{'candidate': item, 'final_score': float(item.get('score', 0) or 0)} for item in all_candidates or [sample]], audit_context)

        # 1. Select CORE layers first using JOINT_SCORE with bus-aware pack scoring
        # P3: Use effective_audio_budget (reserves 1 slot for MIDI hook)
        # P1 v0.1.20: Skip manual-only vocal roles
        core_count = 0
        for role in CORE_ROLES:
            if role in matches and matches[role] and len(selected) < effective_audio_budget:
                # P1 v0.1.20: Skip manual-only vocal roles (vocal_loop, vocal_build, vocal_peak, vocal_shot)
                if _is_manual_recording_role(role):
                    logger.debug(f"[MANUAL_ONLY_SKIP] Skipping manual-only role: {role}")
                    continue
                candidates = matches[role]
                
                # P0: Get dominant pack for this role's bus
                role_dominant_pack = _get_dominant_pack_for_role(role)
                role_bus = _get_bus_for_role(role)
                
                # Use JOINT_SCORE if sample_selector available
                if sample_selector and hasattr(sample_selector, '_calculate_joint_score'):
                    scored_candidates = []
                    
                    for candidate in candidates:
                        base_score = candidate.get('score', 1.0)
                        
                        # Calculate joint score with context of already selected layers
                        try:
                            # Build context of already selected samples
                            selected_context = {}
                            for sel_role, sel_sample in selected.items():
                                if sel_sample:
                                    selected_context[sel_role] = sel_sample
                            
                            joint_factor = sample_selector._calculate_joint_score(
                                candidate, role, selected_context
                            )
                        except Exception as e:
                            logger.debug(f"JOINT_SCORE_ERROR [{role}]: {e}, using neutral factor")
                            joint_factor = 1.0
                        
                        # Apply harmonic coherence validation
                        coherence_score = 1.0
                        coherence_reasons = ["no_validator"]
                        is_coherent = True
                        
                        if coherence_validator:
                            is_coherent, coherence_score, coherence_reasons = \
                                coherence_validator.validate_candidate(candidate, role)
                            
                            # Log rejected candidates for critical harmonic roles
                            if not is_coherent and role in HarmonicCoherenceValidator.CRITICAL_HARMONIC_ROLES:
                                logger.warning(f"[COHERENCE_REJECT] {role}: {candidate.get('file_name', 'unknown')}")
                                logger.warning(f"  Reasons: {coherence_reasons}")
                                continue  # Skip incoherent candidates for critical roles
                        
                        # P0: Calculate final score with bus-aware pack bonus
                        final_score = base_score * joint_factor * coherence_score
                        
                        # P0: Bus-aware pack scoring - harmonic roles use their bus's dominant pack
                        candidate_pack = self._extract_pack(candidate.get('path', ''))
                        pack_bonus, pack_reason = _calculate_bus_aware_pack_bonus(
                            candidate_pack, role, strict_pack_mode
                        )
                        
                        # Non-harmonic roles get flexibility, harmonic roles get strict enforcement
                        if role.lower() in strict_pack_roles and pack_bonus < 1.0:
                            logger.debug(f"PACK_STRICT [{role}]: {candidate_pack} vs {role_dominant_pack} - {pack_reason}")
                            if strict_pack_mode:
                                # Skip non-dominant pack candidates for strict harmonic roles
                                continue
                        
                        final_score *= pack_bonus
                        
                        # PIANO-FORWARD: Apply bonus for piano/keys in support roles (Task 2 & 3)
                        candidate_path = self._candidate_path(candidate)
                        candidate_family = self._candidate_family(candidate) or _extract_family_hint(candidate_path or candidate.get('path', ''))
                        piano_bonus, piano_reason = _get_piano_forward_bonus(
                            role,
                            candidate_family,
                            preferred_secondary,
                        )
                        if piano_bonus > 1.0:
                            logger.debug(
                                "PIANO_FORWARD [%s]: %s (%s) gets %.1fx bonus",
                                role,
                                candidate.get('file_name', 'unknown'),
                                candidate_family or 'unknown',
                                piano_bonus,
                            )
                        
                        final_score *= piano_bonus
                        
                        scored_candidates.append({
                            'candidate': candidate,
                            'base_score': base_score,
                            'joint_factor': joint_factor,
                            'coherence_score': coherence_score,
                            'coherence_reasons': coherence_reasons,
                            'is_coherent': is_coherent,
                            'piano_bonus': piano_bonus,
                            'pack_bonus': pack_bonus,
                            'candidate_pack': candidate_pack,
                            'bus': role_bus,
                            'final_score': final_score,
                            'reason': f"base:{base_score:.2f} joint:{joint_factor:.2f} coherence:{coherence_score:.2f} pack:{pack_bonus:.1f} ({pack_reason})" + (f" {piano_reason}" if piano_reason else "")
                        })
                    
                    # Filter to only valid candidates, with fallback for critical roles
                    valid_candidates = [sc for sc in scored_candidates if sc.get('is_coherent', True)]
                    
                    # If no valid candidates for critical role, use best available with heavy penalty
                    if not valid_candidates and role in HarmonicCoherenceValidator.CRITICAL_HARMONIC_ROLES and scored_candidates:
                        logger.error(f"[COHERENCE_CRISIS] No valid candidates for {role} with family {primary_harmonic_family}")
                        logger.error(f"  Using best available with penalty")
                        # Sort by coherence score and take best, but apply heavy penalty
                        scored_candidates.sort(key=lambda x: x['coherence_score'], reverse=True)
                        best = scored_candidates[0]
                        best['final_score'] *= 0.3  # Heavy penalty
                        valid_candidates = [best]
                    
                    # Sort by final score and select winner
                    valid_candidates.sort(key=lambda x: x['final_score'], reverse=True)
                    
                    # Select best valid candidate
                    winner = None
                    for scored in valid_candidates:
                        candidate = scored['candidate']
                        sample_path = self._candidate_path(candidate)
                        sample_family = self._candidate_family(candidate)
                        
                        # Check distinctness
                        if sample_path and sample_path in used_paths:
                            continue
                        if sample_family and sample_family in used_families:
                            continue
                            
                        winner = scored
                        break
                    
                    if winner:
                        sample = winner['candidate']
                        
                        # Log detailed selection info with bus-aware context
                        selection_log = {
                            'role': role,
                            'winner': sample.get('path', ''),
                            'winner_name': sample.get('file_name', ''),
                            'base_score': winner['base_score'],
                            'joint_factor': winner['joint_factor'],
                            'coherence_score': winner.get('coherence_score', 1.0),
                            'coherence_reasons': winner.get('coherence_reasons', []),
                            'is_coherent': winner.get('is_coherent', True),
                            'piano_bonus': winner.get('piano_bonus', 1.0),
                            'pack_bonus': winner.get('pack_bonus', 1.0),
                            'candidate_pack': winner.get('candidate_pack', 'unknown'),
                            'bus': winner.get('bus', role_bus),
                            'dominant_pack_for_bus': role_dominant_pack,
                            'final_score': winner['final_score'],
                            'reason': winner['reason'],
                            'alternatives_considered': len(valid_candidates),
                            'second_best': valid_candidates[1]['final_score'] if len(valid_candidates) > 1 else None,
                            'margin': winner['final_score'] - (valid_candidates[1]['final_score'] if len(valid_candidates) > 1 else 0)
                        }
                        self._selection_log.append(selection_log)
                        
                        # Record detailed selection for manifest audit with bus context
                        audit_context = {
                            'primary_harmonic_family': primary_harmonic_family,
                            'dominant_packs': dominant_packs,
                            'target_key': target_key if target_key else '',
                            'preferred_secondary_families': preferred_secondary,
                            'selected_roles': list(selected.keys()),
                            'selected_families': list(used_families),
                            'bus': role_bus,
                        }
                        selection_auditor.record_selection(role, winner, scored_candidates, audit_context)
                        
                        logger.info(f"[JOINT_SELECTION] {role} ({role_bus}): {sample.get('file_name', 'unknown')}")
                        logger.info(f"  Score breakdown: {winner['reason']}")
                        logger.info(f"  Coherence: {winner.get('coherence_score', 1.0):.2f} - {winner.get('coherence_reasons', [])}")
                        logger.info(f"  Final: {winner['final_score']:.2f}")
                        
                        selected[role] = sample
                        sample_path = self._candidate_path(sample)
                        sample_family = self._candidate_family(sample)
                        if sample_path:
                            used_paths.add(sample_path)
                        if sample_family:
                            used_families.add(sample_family)
                        core_count += 1
                        logger.info(f"BUDGET_CORE_JOINT: {role} -> {sample.get('file_name', 'unknown')} "
                                   f"[bus: {role_bus}, pack: {self._extract_pack(sample.get('path', 'unknown'))}]")
                    else:
                        # Fallback to pack constraint selection with bus-aware pack
                        if role == 'synth_loop' and harmonic_hints and harmonic_hints.get('synth_loop_hint'):
                            sample = self._select_harmonic_layer(role, matches[role], harmonic_hints)
                        else:
                            # P0: Use bus-aware pack selection
                            role_dominant = _get_dominant_pack_for_role(role)
                            sample = self._select_with_pack_constraint(
                                role, matches[role], role_dominant, strict=(strict_pack_mode and role in strict_pack_roles)
                            )
                        if sample:
                            sample_path = self._candidate_path(sample)
                            sample_family = self._candidate_family(sample)
                            if sample_path and sample_path not in used_paths and \
                               sample_family and sample_family not in used_families:
                                selected[role] = sample
                                used_paths.add(sample_path)
                                used_families.add(sample_family)
                                core_count += 1
                                _record_fallback_selection(role, sample, matches[role])
                                logger.info(f"BUDGET_CORE_FALLBACK: {role} -> {sample.get('file_name', 'unknown')}")
                else:
                    # Fallback to original pack constraint selection (no JOINT_SCORE available)
                    # P0: Use bus-aware pack selection
                    role_dominant = _get_dominant_pack_for_role(role)
                    if role == 'synth_loop' and harmonic_hints and harmonic_hints.get('synth_loop_hint'):
                        sample = self._select_harmonic_layer(role, matches[role], harmonic_hints)
                        hint_family = harmonic_hints.get('synth_loop_hint', {}).get('family', 'unknown')
                        if sample:
                            logger.info(f"BUDGET_CORE_HARMONIC: {role} selected using {hint_family} hint -> {sample.get('file_name', 'unknown')}")
                    else:
                        sample = self._select_with_pack_constraint(
                            role, matches[role], role_dominant, strict=(strict_pack_mode and role in strict_pack_roles)
                        )
                    if sample:
                        sample_path = self._candidate_path(sample)
                        sample_family = self._candidate_family(sample)
                        if sample_path and sample_path not in used_paths:
                            if sample_family and sample_family not in used_families:
                                selected[role] = sample
                                used_paths.add(sample_path)
                                used_families.add(sample_family)
                                core_count += 1
                                _record_fallback_selection(role, sample, matches[role])
                                logger.info(f"BUDGET_CORE: {role} -> {sample.get('file_name', 'unknown')} "
                                           f"[bus: {role_bus}, pack: {self._extract_pack(sample.get('path', 'unknown'))}]")
                            else:
                                logger.debug(f"BUDGET_CORE_SKIP: {role} - family already used")
                        else:
                            logger.debug(f"BUDGET_CORE_SKIP: {role} - path already used")
                    else:
                        logger.warning(f"BUDGET_CORE_OMIT: {role} - no suitable sample from dominant pack")

        # 2. Select OPTIONAL layers only if budget remains - SOFT pack enforcement with bus awareness
        # P3: Use effective_audio_budget (reserves 1 slot for MIDI hook)
        remaining = effective_audio_budget - len(selected)
        optional_count = 0

        logger.info(f"BUDGET_STATUS: Core={core_count}, Used={len(selected)}, Remaining={remaining}, "
                   f"P3_HOOK_RESERVED={hook_reserved}, EffectiveAudioBudget={effective_audio_budget}")

        for role in OPTIONAL_ROLES:
            if role in matches and matches[role] and optional_count < budget['optional_slots']:
                # P1 v0.1.20: Skip manual-only vocal roles
                if _is_manual_recording_role(role):
                    logger.debug(f"[MANUAL_ONLY_SKIP] Skipping manual-only role: {role}")
                    continue
                # Check if we have budget (respecting hook reservation)
                if len(selected) >= effective_audio_budget:
                    logger.debug(f"[P3_BUDGET_SKIP] {role} - budget exhausted (reserved for hook)")
                    break
                
                # P0: Get dominant pack for this role's bus
                role_dominant_pack = _get_dominant_pack_for_role(role)
                role_bus = _get_bus_for_role(role)

                # Only add if it adds real contrast
                if self._adds_contrast(selected, role, matches[role]):
                    # Also apply JOINT_SCORE for optional roles if available
                    if sample_selector and hasattr(sample_selector, '_calculate_joint_score'):
                        candidates = matches[role]
                        scored_candidates = []
                        
                        for candidate in candidates:
                            base_score = candidate.get('score', 1.0)
                            
                            try:
                                selected_context = {r: s for r, s in selected.items() if s}
                                joint_factor = sample_selector._calculate_joint_score(
                                    candidate, role, selected_context
                                )
                            except Exception:
                                joint_factor = 1.0
                            
                            # Apply harmonic coherence validation (lighter for optional roles)
                            coherence_score = 1.0
                            coherence_reasons = ["no_validator"]
                            is_coherent = True
                            
                            if coherence_validator:
                                is_coherent, coherence_score, coherence_reasons = \
                                    coherence_validator.validate_candidate(candidate, role)
                                
                                # Optional roles: apply penalty but don't reject entirely
                                if not is_coherent and role in HarmonicCoherenceValidator.CRITICAL_HARMONIC_ROLES:
                                    coherence_score *= 0.5  # 50% penalty for incoherent optional harmonic roles
                                    logger.debug(f"[COHERENCE_PENALTY] Optional {role}: {candidate.get('file_name', 'unknown')} - {coherence_reasons}")
                            
                            # P0: Optional roles get lighter pack coherence weighting but still bus-aware
                            final_score = base_score * joint_factor * coherence_score
                            
                            # P0: Apply bus-aware pack bonus (more flexible for optional roles)
                            candidate_pack = self._extract_pack(candidate.get('path', ''))
                            pack_bonus, pack_reason = _calculate_bus_aware_pack_bonus(
                                candidate_pack, role, strict=False  # Always soft for optional
                            )
                            final_score *= pack_bonus
                            
                            scored_candidates.append({
                                'candidate': candidate,
                                'final_score': final_score,
                                'coherence_score': coherence_score,
                                'coherence_reasons': coherence_reasons,
                                'pack_bonus': pack_bonus,
                                'pack_reason': pack_reason,
                                'candidate_pack': candidate_pack,
                                'bus': role_bus,
                                'is_coherent': is_coherent
                            })
                        
                        scored_candidates.sort(key=lambda x: x['final_score'], reverse=True)
                        
                        # Select best valid candidate
                        sample = None
                        for scored in scored_candidates:
                            candidate = scored['candidate']
                            sample_path = self._candidate_path(candidate)
                            sample_family = self._candidate_family(candidate)
                            
                            if sample_path and sample_path in used_paths:
                                continue
                            if sample_family and sample_family in used_families:
                                continue
                            
                            sample = candidate
                            break
                        
                        if not sample:
                            # Fallback to pack constraint with bus-aware pack
                            sample = self._select_with_pack_constraint(
                                role, matches[role], role_dominant_pack, strict=False
                            )
                    else:
                        # Soft mode for optional layers - use bus-aware dominant pack
                        sample = self._select_with_pack_constraint(
                            role, matches[role], role_dominant_pack, strict=False
                        )
                    
                    if sample:
                        # Check if distinct
                        sample_path = self._candidate_path(sample)
                        sample_family = self._candidate_family(sample)
                        if sample_path and sample_path in used_paths:
                            logger.debug(f"BUDGET_OPTIONAL_SKIP: {role} - path already used")
                            continue
                        if sample_family and sample_family in used_families:
                            logger.debug(f"BUDGET_OPTIONAL_SKIP: {role} - family already used")
                            continue

                        selected[role] = sample
                        if sample_path:
                            used_paths.add(sample_path)
                        if sample_family:
                            used_families.add(sample_family)
                        optional_count += 1
                        _record_fallback_selection(role, sample, matches[role])
                        selected_pack = self._extract_pack(sample.get('path', 'unknown'))
                        
                        # P0: Bus-aware pack status
                        pack_status = "BUS_DOMINANT" if selected_pack == role_dominant_pack else "BUS_FALLBACK"
                        if _is_pack_in_bus_group(selected_pack, role_bus):
                            pack_status = "BUS_GROUP"
                        
                        logger.info(f"BUDGET_OPTIONAL: {role} -> {sample.get('file_name', 'unknown')} "
                                   f"[{pack_status}: {selected_pack} in {role_bus}]")
                else:
                    logger.debug(f"BUDGET_SKIP: {role} - no contrast added")

        # Log budget usage summary
        total_used = len(selected)
        logger.info(f"[P3_BUDGET_COMPLETE] {total_used}/{effective_audio_budget} audio layers selected "
                   f"(+ 1 reserved for MIDI hook = {total_used + 1}/{budget['total_max']} total)")
        logger.info(f"  Core: {core_count}, Optional: {optional_count}, HookReserved: {hook_reserved}")

        if total_used >= effective_audio_budget:
            logger.warning(f"[P3_BUDGET_AUDIO_EXHAUSTED] Max audio layers reached ({effective_audio_budget}). "
                          f"Hook slot still reserved={hook_reserved}")
        elif optional_count >= budget['optional_slots']:
            logger.warning(f"BUDGET_OPTIONAL_LIMIT: Max optional slots used ({budget['optional_slots']})")

        # Verify pack coherence with bus-aware metrics (P0)
        coherence_report = self.verify_pack_coherence(
            selected, dominant_packs, primary_harmonic_family
        )
        
        # P3: Add hybrid validation tracking to coherence report
        coherence_report['hybrid_validation'] = {
            'has_audio_layers': total_used > 0,
            'audio_layer_count': total_used,
            'midi_hook_reserved': hook_reserved,
            'effective_audio_budget': effective_audio_budget,
            'total_budget': budget['total_max'],
            'piano_hints_enforced': preferred_secondary if harmonic_hints else ['piano', 'keys'],
        }
        
        # Store auditor data for manifest generation
        self._last_selection_audit = selection_auditor.to_manifest()
        self._last_selection_audit['pack_coherence'] = coherence_report
        
        # P3: Track MIDI hook planning status in selection result
        selected['_p3_hybrid_meta'] = {
            'midi_hook_planned': plan_midi_hook,
            'midi_hook_reserved': hook_reserved,
            'effective_audio_budget': effective_audio_budget,
            'audio_layers_selected': total_used,
            'piano_hints': preferred_secondary if harmonic_hints else ['piano', 'keys'],
        }
        
        return selected

    def _adds_contrast(
        self,
        current_selection: Dict[str, Optional[Dict[str, Any]]],
        new_role: str,
        new_samples: List[Dict[str, Any]]
    ) -> bool:
        """Check if adding this role adds musical contrast."""
        if not new_samples:
            return False

        # Get the first candidate sample
        new_sample = new_samples[0]
        new_path = self._candidate_path(new_sample)
        new_family = self._candidate_family(new_sample)

        # Check if similar to existing
        for existing_role, existing_sample in current_selection.items():
            if existing_sample is None:
                continue
            if self._are_similar(existing_sample, new_sample):
                logger.debug(f"BUDGET_CONTRAST: {new_role} similar to {existing_role}")
                return False

        # Check family diversity
        if new_family:
            for existing_role, existing_sample in current_selection.items():
                if existing_sample is None:
                    continue
                existing_family = self._candidate_family(existing_sample)
                if existing_family and existing_family == new_family:
                    logger.debug(f"BUDGET_CONTRAST: {new_role} same family as {existing_role}")
                    # Not a hard block, but reduces priority
                    return len(current_selection) < 8  # Allow if under 8 tracks

        return True

    def _are_similar(
        self,
        sample1: Optional[Dict[str, Any]],
        sample2: Optional[Dict[str, Any]]
    ) -> bool:
        """Check if two samples are too similar (shouldn't both be selected)."""
        if not sample1 or not sample2:
            return False

        # Same file = definitely similar
        path1 = self._candidate_path(sample1)
        path2 = self._candidate_path(sample2)
        if path1 and path2 and path1 == path2:
            return True

        # Same family = potentially similar
        family1 = self._candidate_family(sample1)
        family2 = self._candidate_family(sample2)
        if family1 and family2 and family1 == family2:
            # Check spectral characteristics
            centroid1 = float(sample1.get('spectral_centroid', 0) or 0)
            centroid2 = float(sample2.get('spectral_centroid', 0) or 0)
            if centroid1 > 0 and centroid2 > 0:
                centroid_diff = abs(centroid1 - centroid2) / max(centroid1, centroid2)
                if centroid_diff < 0.2:  # Within 20% spectral similarity
                    return True

        # Check audio vector similarity
        vector1 = sample1.get('deep_vector') or sample1.get('vector', [])
        vector2 = sample2.get('deep_vector') or sample2.get('vector', [])
        if vector1 and vector2 and len(vector1) == len(vector2) and len(vector1) > 0:
            try:
                v1 = np.asarray(vector1, dtype=np.float32)
                v2 = np.asarray(vector2, dtype=np.float32)
                norm1 = np.linalg.norm(v1)
                norm2 = np.linalg.norm(v2)
                if norm1 > 0 and norm2 > 0:
                    cosine_sim = np.dot(v1, v2) / (norm1 * norm2)
                    if cosine_sim > 0.85:  # High cosine similarity
                        return True
            except Exception:
                pass

        return False

    def sync_recent_memory_from_selector(self) -> None:
        """Sync recent sample diversity memory from sample_selector module."""
        global _recent_sample_diversity_memory
        try:
            from .sample_selector import _recent_sample_diversity_memory as selector_memory
            for role, paths in selector_memory.items():
                if role not in _recent_sample_diversity_memory:
                    _recent_sample_diversity_memory[role] = []
                for path in paths:
                    if path not in _recent_sample_diversity_memory[role]:
                        _recent_sample_diversity_memory[role].append(path)
        except ImportError:
            pass

    def get_recent_sample_diversity_state(self) -> Dict[str, List[str]]:
        """Get copy of recent sample diversity memory."""
        return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()}

    def get_selection_log(self) -> List[Dict[str, Any]]:
        """Get detailed selection log with JOINT_SCORE breakdowns."""
        if not hasattr(self, '_selection_log'):
            return []
        return self._selection_log.copy()

    def get_selection_summary(self) -> Dict[str, Any]:
        """Get summary of selection process with JOINT_SCORE statistics."""
        log = self.get_selection_log()
        if not log:
            return {"error": "No selection log available"}
        
        summary = {
            "total_selections": len(log),
            "selections_with_joint_score": 0,
            "avg_joint_factor": 0.0,
            "avg_family_bonus": 0.0,
            "selections_with_family_match": 0,
            "selections_with_family_penalty": 0,
            "by_role": {}
        }
        
        joint_factors = []
        family_bonuses = []
        
        for entry in log:
            role = entry.get('role', 'unknown')
            if role not in summary["by_role"]:
                summary["by_role"][role] = {
                    "count": 0,
                    "avg_final_score": 0.0,
                    "avg_joint_factor": 0.0,
                    "avg_family_bonus": 0.0
                }
            
            summary["by_role"][role]["count"] += 1
            
            joint_factor = entry.get('joint_factor', 1.0)
            family_bonus = entry.get('family_bonus', 1.0)
            final_score = entry.get('final_score', 0.0)
            
            if joint_factor != 1.0:
                summary["selections_with_joint_score"] += 1
                joint_factors.append(joint_factor)
            
            family_bonuses.append(family_bonus)
            if family_bonus > 1.0:
                summary["selections_with_family_match"] += 1
            elif family_bonus < 1.0:
                summary["selections_with_family_penalty"] += 1
            
            # Accumulate for averaging
            summary["by_role"][role]["avg_final_score"] += final_score
            summary["by_role"][role]["avg_joint_factor"] += joint_factor
            summary["by_role"][role]["avg_family_bonus"] += family_bonus
        
        # Calculate averages
        if joint_factors:
            summary["avg_joint_factor"] = sum(joint_factors) / len(joint_factors)
        if family_bonuses:
            summary["avg_family_bonus"] = sum(family_bonuses) / len(family_bonuses)
        
        for role_data in summary["by_role"].values():
            count = role_data["count"]
            if count > 0:
                role_data["avg_final_score"] /= count
                role_data["avg_joint_factor"] /= count
                role_data["avg_family_bonus"] /= count
        
        return summary

    def get_selection_audit(self) -> Dict[str, Any]:
        """Get full selection audit data for manifest including detailed layer info."""
        if not hasattr(self, '_last_selection_audit'):
            return {
                'layers': [],
                'summary': {
                    'total_layers': 0,
                    'layers_with_family_match': 0,
                    'layers_with_pack_match': 0,
                    'average_joint_score': 0.0,
                    'average_final_score': 0.0
                },
                'audit_metadata': {
                    'total_selections': 0,
                    'audit_duration_seconds': 0,
                    'timestamp': 0
                }
            }
        return self._last_selection_audit.copy()

    def match_assets(self, reference_path: str) -> Dict[str, Any]:
        reference = self.analyze_reference(reference_path)
        reference_sections = self.detect_reference_sections(reference_path)
        segment_bank = self._build_reference_segment_bank(reference_path, reference, reference_sections)
        assets = self._list_assets()
        matches: Dict[str, List[Dict[str, Any]]] = {}
        role_segments = {
            role: self._select_role_reference_segments(role, reference, segment_bank)
            for role in assets.keys()
        }
        rerank_limits = {
            "kick": 14,
            "snare": 14,
            "hat": 16,
            "bass_loop": 12,
            "perc_loop": 14,
            "top_loop": 12,
            "synth_loop": 12,
            "vocal_loop": 12,
            "crash_fx": 10,
            "fill_fx": 10,
            "snare_roll": 10,
            "atmos_fx": 8,
            "vocal_shot": 10,
        }

        for role, files in assets.items():
            analyses: List[Dict[str, Any]] = []
            vectors: List[List[float]] = []
            for file_path in files:
                try:
                    analysis = self.analyze_file(str(file_path), duration_limit=64.0)
                except Exception:
                    continue
                analyses.append(analysis)
                vectors.append(list(analysis.get("vector", [])))

            scores = self._cosine_scores(reference.get("vector", []), vectors)
            role_matches: List[Dict[str, Any]] = []
            for analysis, cosine_score in zip(analyses, scores):
                catalog_score = self._vector_store_role_score(role, analysis, reference)
                preliminary_score = self._role_score(
                    role,
                    reference,
                    analysis,
                    float(cosine_score),
                    segment_score=0.0,
                    catalog_score=catalog_score,
                )
                role_matches.append({
                    "_analysis": analysis,
                    "_cosine": float(cosine_score),
                    "_catalog": float(catalog_score),
                    "_preliminary": float(preliminary_score),
                })

            role_matches.sort(key=lambda item: item["_preliminary"], reverse=True)
            rerank_limit = min(int(rerank_limits.get(role, 10) or 10), len(role_matches))

            role_section_features = self._section_detector._get_role_section_features(role, reference_sections, role_segments.get(role, []))

            finalized_matches: List[Dict[str, Any]] = []
            for index, item in enumerate(role_matches):
                analysis = item["_analysis"]
                cosine_score = float(item["_cosine"])
                catalog_score = float(item["_catalog"])
                segment_score = 0.0
                character_bonus = 1.0
                final_score = float(item["_preliminary"])

                if index < rerank_limit:
                    segment_score = self._role_segment_similarity(role, analysis, role_segments.get(role, []))
                    final_score = self._role_score(
                        role,
                        reference,
                        analysis,
                        cosine_score,
                        segment_score=segment_score,
                        catalog_score=catalog_score,
                    )

                    if role_section_features:
                        character_bonus = self._section_detector._section_character_bonus(
                            role, analysis, role_section_features
                        )
                        final_score = final_score * character_bonus

                finalized_matches.append({
                    "path": analysis["path"],
                    "file_name": analysis["file_name"],
                    "tempo": analysis["tempo"],
                    "key": analysis["key"],
                    "duration": analysis["duration"],
                    "cosine": round(float(cosine_score), 6),
                    "segment_score": round(float(segment_score), 6),
                    "catalog_score": round(float(catalog_score), 6),
                    "character_bonus": round(float(character_bonus), 3),
                    "score": round(float(final_score), 6),
                })

            finalized_matches.sort(key=lambda item: item["score"], reverse=True)
            matches[role] = finalized_matches

        # Build section energy profile for generator
        section_energy_profile = []
        for section in reference_sections:
            features = section.get('features', {})
            section_energy_profile.append({
                'kind': section.get('kind', 'drop'),
                'energy_mean': features.get('energy_mean', features.get('energy', 0.5)),
                'energy_peak': features.get('energy_peak', 0.5),
                'energy_slope': features.get('energy_slope', 0.0),
                'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)),
                'spectral_centroid_std': features.get('spectral_centroid_std', 0.0),
                'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)),
                'low_energy_ratio': features.get('low_energy_ratio', 0.0),
                'high_energy_ratio': features.get('high_energy_ratio', 0.0),
                'kind_confidence': section.get('kind_confidence', 0.5),
            })

        return {
            "reference": reference,
            "reference_sections": reference_sections,
            "segment_bank_size": len(segment_bank),
            "role_segments": {
                role: [
                    {
                        "start": segment.get("start"),
                        "end": segment.get("end"),
                        "kind": segment.get("kind"),
                        "window_seconds": segment.get("window_seconds"),
                    }
                    for segment in items
                ]
                for role, items in role_segments.items()
            },
            "matches": matches,
            "section_energy_profile": section_energy_profile,
            "device": self.device_name,
        }

    def _section_offsets(self, sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]:
        offsets: List[Tuple[Dict[str, Any], float, float]] = []
        position = 0.0
        for section in sections:
            beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0))
            start = position
            end = position + beats
            offsets.append((section, start, end))
            position = end
        return offsets

    def _section_energy(self, reference: Dict[str, Any], progress: float) -> float:
        blocks = reference.get("blocks", [])
        if not blocks:
            return 0.5
        index = min(len(blocks) - 1, max(0, int(round(progress * (len(blocks) - 1)))))
        return float(blocks[index].get("energy_norm", 0.5))

    def _loop_step_beats(self, item: Optional[Dict[str, Any]], project_bpm: float, default_beats: float = 16.0) -> float:
        if not item:
            return default_beats
        duration = float(item.get("duration", 0.0))
        source_tempo = float(item.get("tempo", 0.0))
        if duration <= 0:
            return default_beats
        if source_tempo > 0:
            source_beats = duration * source_tempo / 60.0
            rounded = max(4.0, round(source_beats / 4.0) * 4.0)
            return float(rounded)
        estimated = duration * project_bpm / 60.0
        rounded = max(4.0, round(estimated / 4.0) * 4.0)
        return float(rounded)

    def _detect_roles_for_segment(self, features: Dict[str, float], section_kind: str) -> List[str]:
        """Detect appropriate roles for a segment based on its features and section type."""
        roles = []
        energy = features.get('energy', 0.5)
        onset = features.get('onset_density', 0.5)
        brightness = features.get('brightness', 0.5)

        # Drums are always present in non-intro/outro sections
        if section_kind in ['drop', 'build', 'verse']:
            roles.extend(['kick', 'snare', 'hat'])

        # Bass is present in high-energy sections
        if section_kind in ['drop', 'build'] or energy > 0.5:
            roles.append('bass_loop')

        # Percussion and top loops based on onset density
        if onset > 0.4:
            roles.extend(['perc_loop', 'top_loop'])

        # Synths in drops and high-brightness sections
        if section_kind == 'drop' or (brightness > 0.5 and energy > 0.6):
            roles.append('synth_loop')

        # Vocals are manual-only and must not be auto-generated.

        # FX based on section type
        if section_kind == 'build':
            roles.extend(['snare_roll', 'fill_fx', 'crash_fx'])
        elif section_kind == 'break':
            roles.extend(['atmos_fx', 'fill_fx'])
        elif section_kind == 'intro':
            roles.extend(['atmos_fx', 'crash_fx'])
        elif section_kind == 'outro':
            roles.extend(['atmos_fx', 'crash_fx'])

        return list(set(roles))

    def _analyze_segment_roles(self, reference: Dict[str, Any], sections: List[Dict[str, Any]]) -> Dict[str, List[str]]:
        """Analyze and return recommended roles for each section."""
        segment_roles: Dict[str, List[str]] = {}

        for i, section in enumerate(sections):
            kind = str(section.get("kind", "drop")).lower()

            # Use features if available from automatic detection
            features = section.get("features", {
                'energy': 0.5,
                'onset_density': 0.5,
                'brightness': 0.5,
            })

            # Estimate features from position if not available
            if 'energy' not in features:
                blocks = reference.get("blocks", [])
                if blocks:
                    progress = i / max(1, len(sections) - 1)
                    idx = min(len(blocks) - 1, max(0, int(progress * (len(blocks) - 1))))
                    features['energy'] = float(blocks[idx].get("energy_norm", 0.5))

            roles = self._detect_roles_for_segment(features, kind)
            segment_roles[f"section_{i}_{kind}"] = roles

        return segment_roles

    def detect_reference_sections(self, reference_path: str, min_section_seconds: float = 8.0) -> List[Dict[str, Any]]:
        """Automatically detect sections from a reference track with richer feature extraction."""
        if librosa is None:
            raise RuntimeError("librosa no estÃƒÂ¡ disponible")

        path = Path(reference_path)
        y, sr = librosa.load(str(path), sr=22050, mono=True)
        hop_length = 512
        n_fft = _adaptive_n_fft(len(y))

        onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
        rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
        centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0]

        duration = float(librosa.get_duration(y=y, sr=sr))

        sections = self._section_detector.detect_sections(
            rms, onset_env, centroid, duration, min_section_seconds
        )

        tempo = _safe_float(
            librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median),
            128.0,
        ) or 128.0

        if len(sections) < 2 and duration > min_section_seconds * 1.5:
            mid = duration / 2
            energy_first_half = float(np.mean(rms[:int(len(rms)/2)])) if len(rms) > 0 else 0.5
            energy_second_half = float(np.mean(rms[int(len(rms)/2):])) if len(rms) > 1 else 0.5

            if energy_first_half < energy_second_half * 0.8:
                sections = [
                    {'kind': 'intro', 'start': 0.0, 'end': mid * 0.4, 'duration': mid * 0.4,
                     'bars': max(4, int(mid * 0.4 * tempo / 60 / 4)), 'features': {'energy': energy_first_half}},
                    {'kind': 'build', 'start': mid * 0.4, 'end': mid, 'duration': mid * 0.6,
                     'bars': max(4, int(mid * 0.6 * tempo / 60 / 4)), 'features': {'energy': (energy_first_half + energy_second_half) / 2}},
                    {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid,
                     'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}},
                ]
            else:
                sections = [
                    {'kind': 'verse', 'start': 0.0, 'end': mid, 'duration': mid,
                     'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_first_half}},
                    {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid,
                     'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}},
                ]

        prev_features = None
        total_sections = len(sections)
        for i, section in enumerate(sections):
            sec_duration = section.get('duration', 8.0)
            beats_per_second = tempo / 60.0
            beats = sec_duration * beats_per_second
            bars = max(4, int(round(beats / 4.0)))
            section['bars'] = bars
            section['beats'] = bars * 4
            section['tempo'] = round(tempo, 1)
            section['section_index'] = i
            section['total_sections'] = total_sections

            start_time = float(section.get('start', 0.0))
            end_time = float(section.get('end', sec_duration))

            # Compute richer section features inline (method was in wrong class)
            duration_sec = end_time - start_time
            frames_per_second = sr / hop_length
            start_frame = int(start_time * frames_per_second)
            end_frame = int(end_time * frames_per_second)
            start_frame = max(0, min(start_frame, len(rms) - 1))
            end_frame = max(start_frame + 1, min(end_frame, len(rms)))

            section_rms = rms[start_frame:end_frame] if end_frame > start_frame else np.array([0.0])
            rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01
            energy_mean = float(np.mean(section_rms)) if len(section_rms) > 0 else 0.0
            energy_peak = float(np.max(section_rms)) if len(section_rms) > 0 else 0.0
            energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0)
            energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5)

            richer_features = {
                'energy_mean': round(energy_mean_norm, 3),
                'energy_peak': round(energy_peak_norm, 3),
                'energy_slope': 0.0,
                'spectral_centroid_mean': 0.5,
                'spectral_centroid_std': 0.0,
                'onset_rate': 0.5,
                'low_energy_ratio': 0.3,
                'high_energy_ratio': 0.3,
            }

            if 'features' not in section:
                section['features'] = {}
            section['features'].update(richer_features)

            kind = str(section.get('kind', 'drop')).lower()
            position_ratio = start_time / max(duration, 0.001)
            section['features']['total_sections'] = total_sections

            # Simple confidence calculation inline
            energy = section['features'].get('energy', 0.5)
            onset_density = section['features'].get('onset_density', 0.5)

            # Basic confidence based on energy and position
            if kind == 'intro' and position_ratio < 0.2:
                confidence = 0.7
            elif kind == 'outro' and position_ratio > 0.8:
                confidence = 0.7
            elif kind == 'drop' and energy > 0.6:
                confidence = 0.75
            elif kind == 'build' and 0.3 < position_ratio < 0.7:
                confidence = 0.65
            elif kind == 'break' and 0.4 < position_ratio < 0.8:
                confidence = 0.6
            else:
                confidence = 0.5

            section['kind_confidence'] = confidence
            alternatives = []
            if confidence < 0.55:
                alternatives = ['drop', 'build', 'break']
                section['kind_alternatives'] = alternatives

            prev_features = section['features']

        sections = self._validate_section_sequence(sections, duration, tempo)

        return sections

    def _validate_section_sequence(self, sections: List[Dict[str, Any]],
                                    duration: float, tempo: float) -> List[Dict[str, Any]]:
        """Validate and potentially correct section sequence for musical coherence."""
        if len(sections) < 2:
            return sections

        result = []
        sequence_issues = []

        VALID_TRANSITIONS = {
            'intro': {'verse', 'build', 'break', 'drop'},
            'verse': {'build', 'drop', 'break', 'verse', 'outro'},
            'build': {'drop', 'break', 'verse'},
            'drop': {'break', 'verse', 'build', 'outro', 'drop'},
            'break': {'build', 'drop', 'verse', 'outro'},
            'outro': set(),
        }

        PREFERRED_FIRST = {'intro', 'verse', 'build', 'break'}
        PREFERRED_LAST = {'outro', 'drop', 'break'}

        for i, section in enumerate(sections):
            kind = section.get('kind', 'drop')
            confidence = section.get('kind_confidence', 0.5)
            alternatives = section.get('kind_alternatives', [])

            section_copy = dict(section)

            if i == 0:
                if kind not in PREFERRED_FIRST:
                    if confidence < 0.55 and alternatives:
                        for alt in alternatives:
                            if alt in PREFERRED_FIRST:
                                section_copy['kind'] = alt
                                section_copy['sequence_correction'] = 'first_section_adjusted'
                                section_copy['original_kind'] = kind
                                break
                    elif confidence < 0.45:
                        section_copy['sequence_warning'] = f'first_section_is_{kind}'

            if i == len(sections) - 1:
                if kind not in PREFERRED_LAST:
                    if confidence < 0.55 and alternatives:
                        for alt in alternatives:
                            if alt in PREFERRED_LAST:
                                section_copy['kind'] = alt
                                section_copy['sequence_correction'] = 'last_section_adjusted'
                                section_copy['original_kind'] = kind
                                break
                    elif confidence < 0.45:
                        section_copy['sequence_warning'] = f'last_section_is_{kind}'

            if 0 < i < len(sections) - 1:
                prev_kind = sections[i - 1].get('kind', 'drop')
                next_kind = sections[i + 1].get('kind', 'drop') if i + 1 < len(sections) else None

                valid_prev = kind in VALID_TRANSITIONS.get(prev_kind, set())

                if not valid_prev and confidence < 0.60:
                    transition_key = f'{prev_kind}_to_{kind}'
                    sequence_issues.append(transition_key)

                    if alternatives:
                        for alt in alternatives:
                            if alt in VALID_TRANSITIONS.get(prev_kind, set()):
                                if next_kind is None or next_kind in VALID_TRANSITIONS.get(alt, set()):
                                    section_copy['kind'] = alt
                                    section_copy['sequence_correction'] = 'transition_fixed'
                                    section_copy['original_kind'] = kind
                                    section_copy['invalid_transition'] = transition_key
                                    break

            if kind == 'build':
                next_kind = sections[i + 1].get('kind', '') if i < len(sections) - 1 else None
                if next_kind and next_kind not in ('drop', 'break', 'verse'):
                    next_confidence = sections[i + 1].get('kind_confidence', 0.5)
                    if next_confidence < 0.60:
                        section_copy['build_transition_warning'] = f'build_followed_by_{next_kind}'

            if kind == 'drop':
                features = section.get('features', {})
                energy = features.get('energy', 0.5)
                if energy < 0.50:
                    section_copy['drop_energy_warning'] = f'drop_has_low_energy_{energy:.2f}'
                    if confidence < 0.55 and alternatives:
                        for alt in alternatives:
                            if alt in {'verse', 'build'}:
                                section_copy['kind'] = alt
                                section_copy['sequence_correction'] = 'low_energy_drop_reclassified'
                                section_copy['original_kind'] = 'drop'
                                break

            result.append(section_copy)

        if sequence_issues:
            result[0]['sequence_issues'] = sequence_issues[:5]

        return result

    def _get_section_variant(self, section_kind: str, section_name: str = "") -> str:
        """
        Determina la variante apropiada para una secciÃƒÂ³n.

        Retorna un string como 'sparse', 'dense', 'full', etc.
        """
        kind_lower = section_kind.lower()
        name_lower = section_name.lower()

        # Detectar variantes especiales por nombre
        if 'peak' in name_lower or 'main' in name_lower:
            return 'peak'
        if 'minimal' in name_lower:
            return 'minimal'
        if 'atmos' in name_lower:
            return 'atmospheric'

        # Usar defaults por tipo
        return SECTION_VARIANTS.get(kind_lower, ['standard'])[0]

    def _select_variant_samples(self,
                               base_samples: List[Any],
                               role: str,
                               section_variant: str,
                               target_key: str = None,
                               target_bpm: float = None) -> List[Any]:
        """
        T2: Selecciona samples apropiados para una variante de secciÃ³n.

        IMPORTANTE: Esta funciÃ³n maneja VARIACIÃ“N POR CONTENIDO, no por silencio.
        Filtra y reordena base_samples segÃºn la variante, pero con garantÃ­as:
        - Nunca retorna lista vacÃ­a (siempre hay fallback)
        - Para 'sparse'/'minimal': prefiere samples mÃ¡s ligeros pero mantiene continuidad
        - Para 'dense'/'full': prefiere samples mÃ¡s complejos sin eliminar todo lo demÃ¡s
        
        Esta funciÃ³n implementa sustituciÃ³n consciente de secciÃ³n (section-aware substitution)
        en lugar de eliminaciÃ³n simple que crearÃ­a silencio.
        
        Args:
            base_samples: Lista de samples candidatos
            role: Rol del sample
            section_variant: Tipo de variante ('sparse', 'dense', 'full', 'minimal', etc.)
            target_key: Key objetivo para compatibilidad
            target_bpm: BPM objetivo para compatibilidad
            
        Returns:
            Lista de samples ordenados por relevancia para la variante
            Garantizado: nunca retorna lista vacÃ­a si base_samples tiene elementos
        """
        if not base_samples:
            return base_samples

        # Por defecto, retornar sin cambios
        if section_variant == 'standard':
            return base_samples

        variant_samples = []
        fallback_samples = []  # T2: Siempre mantener opciones de fallback

        for sample in base_samples:
            # Get sample name from the match dict
            if isinstance(sample, dict):
                sample_name = sample.get('file_name', '')
            else:
                sample_name = str(sample)

            name_lower = sample_name.lower()

            # T2: Variant sparse/minimal: buscar keywords sutiles
            # PERO nunca eliminar completamente - solo reordenar preferencias
            if section_variant in ['sparse', 'minimal', 'atmospheric', 'fading']:
                if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'simple', 'minimal', 'clean', 'thin']):
                    variant_samples.insert(0, sample)  # Prioridad alta
                elif any(kw in name_lower for kw in ['heavy', 'full', 'busy', 'complex', 'big', 'thick']):
                    fallback_samples.append(sample)  # T2: Guardar como fallback, no eliminar
                else:
                    variant_samples.append(sample)

            # T2: Variant dense/full/peak: buscar keywords ricos
            # PERO mantener samples simples como fallback
            elif section_variant in ['dense', 'full', 'peak', 'building']:
                if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main', 'thick']):
                    variant_samples.insert(0, sample)  # Prioridad alta
                elif any(kw in name_lower for kw in ['minimal', 'subtle', 'light', 'thin']):
                    fallback_samples.append(sample)  # T2: Guardar como fallback
                else:
                    variant_samples.append(sample)

            else:
                variant_samples.append(sample)

        # T2: GARANTÃA DE CONTINUIDAD
        # Si no hay suficientes samples en la variante preferida, 
        # usar fallback para mantener continuidad musical
        MIN_SAMPLES_FOR_CONTINUITY = 2  # MÃ­nimo para evitar repeticiÃ³n excesiva
        
        if len(variant_samples) < MIN_SAMPLES_FOR_CONTINUITY and fallback_samples:
            needed = MIN_SAMPLES_FOR_CONTINUITY - len(variant_samples)
            variant_samples.extend(fallback_samples[:needed])
            logger.debug(
                "[T2_VARIANT_FALLBACK] AÃ±adidos %d samples de fallback para %s en variante '%s' "
                "para mantener continuidad",
                min(needed, len(fallback_samples)), role, section_variant
            )
        
        # T2: ÃšLTIMA GARANTÃA: Si aÃºn asÃ­ no hay suficientes, retornar originales
        if len(variant_samples) < 1:
            logger.warning(
                "[T2_VARIANT_SAFETY] Variante '%s' para %s dejarÃ­a lista vacÃ­a. "
                "Retornando samples originales para evitar silencio.",
                section_variant, role
            )
            return base_samples

        return variant_samples

    def _get_variant_samples_for_section(self,
                                        base_samples: List[Any],
                                        role: str,
                                        section_kind: str,
                                        section_name: str,
                                        target_key: str = None,
                                        target_bpm: float = None,
                                        max_variants: int = 3) -> Dict[str, List[Any]]:
        """
        Selecciona samples DIFERENTES para diferentes secciones de un mismo rol.

        Retorna un dict mapping section_key -> list of samples.

        Para roles variante (perc, top_loop, etc.), esto retorna samples distintos
        para intro/verse/build/drop/break/outro cuando es posible.
        """
        # Roles que pueden tener variaciÃƒÂ³n real
        variant_roles = ['perc', 'perc_alt', 'top_loop', 'synth_peak', 'atmos']

        if role not in variant_roles or not base_samples or len(base_samples) < 3:
            # No hay suficiente pool para variaciÃƒÂ³n
            return {'all': base_samples}

        section_map = {}

        # Variantes por tipo de secciÃƒÂ³n
        section_types = {
            'intro': ['minimal', 'sparse'],
            'verse': ['standard', 'light'],
            'build': ['building', 'adding'],
            'drop': ['full', 'peak', 'rich'],
            'break': ['sparse', 'atmospheric'],
            'outro': ['fading', 'minimal']
        }

        # Para cada secciÃƒÂ³n, seleccionar samples con preferencias diferentes
        section_key = f"{section_kind}_{section_name}"

        # Determinar preferencia para esta secciÃƒÂ³n
        variants = section_types.get(section_kind.lower(), ['standard'])
        preference = variants[0] if variants else 'standard'

        # Filtrar samples segÃƒÂºn preferencia
        variant_samples = []
        remaining_samples = list(base_samples)

        for sample in remaining_samples:
            # Get sample name from the match dict
            if isinstance(sample, dict):
                sample_name = sample.get('file_name', '')
            else:
                sample_name = str(sample)

            name_lower = sample_name.lower()

            # Para sparse/minimal: buscar keywords ligeros
            if preference in ['minimal', 'sparse', 'atmospheric']:
                if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'minimal', 'clean', 'atmos']):
                    variant_samples.append(sample)
                elif any(kw in name_lower for kw in ['heavy', 'hard', 'full', 'big']):
                    continue

            # Para full/peak: buscar keywords ricos
            elif preference in ['full', 'peak', 'rich', 'building']:
                if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main']):
                    variant_samples.append(sample)
                elif any(kw in name_lower for kw in ['minimal', 'subtle']):
                    continue

            else:
                variant_samples.append(sample)

        # Si no encontramos suficientes, usar del pool original
        if len(variant_samples) < 2:
            variant_samples = base_samples[:max_variants]

        section_map[section_key] = variant_samples[:max_variants]

        return section_map

    def build_arrangement_plan(self, reference_path: str, sections: List[Dict[str, Any]],
                               project_bpm: float, project_key: str,
                               variant_seed: Optional[int] = None,
                               genre: str = "") -> Dict[str, Any]:
        """
        Build arrangement plan with reference property locking.
        
        This method now extracts and locks key properties from the reference audio:
        - key: Musical key (e.g., 'Am') - MUST be used in generation
        - bpm: Tempo/BPM - Should be close to reference
        - dominant_pack: Primary sample pack family from reference
        - harmonic_family: Dominant harmonic tokens detected
        
        These properties are returned in locked_properties and should be enforced
        during the generation phase to ensure harmonic and temporal coherence
        with the reference track.
        """
        # Reset family tracking for new generation
        self.reset_family_tracking()

        # ANALYZE REFERENCE and extract locked properties
        ref_analysis = self.analyze_reference(reference_path)
        
        # LOCK these properties - these MUST be used in generation
        locked_properties = {
            'key': ref_analysis.get('key', project_key),  # e.g., 'Am' - MUST use this
            'bpm': ref_analysis.get('tempo', project_bpm) if ref_analysis.get('tempo', 0) > 0 else project_bpm,
            'dominant_pack': None,  # Will be set after palette selection
            'harmonic_family': None,  # Will be set after micro-stem analysis
            'scale': 'minor' if 'm' in str(ref_analysis.get('key', '')).lower() else 'major',
            'reference_path': reference_path,
            'reference_name': Path(reference_path).name,
        }
        
        logger.info("REFERENCE_LOCK: Extracted properties from %s: key=%s, bpm=%.3f, scale=%s",
                   Path(reference_path).name,
                   locked_properties['key'],
                   locked_properties['bpm'],
                   locked_properties['scale'])
        selection_genre = str(genre or ref_analysis.get('genre', '') or 'default').strip().lower()
        locked_properties['genre'] = selection_genre

        # Initialize section-aware selection via SampleSelector
        selector = get_sample_selector() if get_sample_selector else None
        if selector and hasattr(selector, 'clear_section_context'):
            selector.clear_section_context()
            logger.debug("SECTION_CONTEXT: Initialized - section tracking cleared")

        result = self.match_assets(reference_path)
        reference = result["reference"]
        raw_matches = dict(result["matches"] or {})
        matches = _filter_manual_recording_role_map(raw_matches)
        removed_manual_roles = sorted(set(raw_matches) - set(matches))
        if removed_manual_roles:
            logger.info(
                "MANUAL_VOCALS_POLICY: Removed manual-only roles from reference matches: %s",
                removed_manual_roles,
            )
        reference_sections = sections or result.get("reference_sections", []) or []

        micro_stem_plan = self._build_micro_stem_plan(
            reference_path,
            reference,
            reference_sections,
            matches,
        )
        if micro_stem_plan.get("segments"):
            matches = self._apply_micro_stem_bias(matches, micro_stem_plan)
            matches = _filter_manual_recording_role_map(matches)
            logger.info(
                "MICRO_STEMS: %d segmentos activos, familias dominantes=%s, tokens=%s",
                len(micro_stem_plan.get("segments", [])),
                [item.get("family") for item in micro_stem_plan.get("summary", {}).get("dominant_families", [])[:3]],
                [item.get("token") for item in micro_stem_plan.get("summary", {}).get("dominant_tokens", [])[:4]],
            )
            # UPDATE locked_properties with dominant family info
            dominant_families = micro_stem_plan.get("summary", {}).get("dominant_families", [])
            dominant_tokens = micro_stem_plan.get("summary", {}).get("dominant_tokens", [])
            if dominant_families:
                locked_properties['harmonic_family'] = dominant_families[0].get('family') if isinstance(dominant_families[0], dict) else dominant_families[0]
                logger.info("REFERENCE_LOCK: Harmonic family detected: %s", locked_properties['harmonic_family'])

        # Auto-detect sections if not provided or enhance existing ones
        if not sections:
            sections = self.detect_reference_sections(reference_path)

        # SELECT DOMINANT PALETTE and enforce pack constraints (P0: Bus-aware)
        dominant_packs = _normalize_dominant_packs(self.select_dominant_palette(matches, genre=selection_genre))
        # UPDATE locked_properties with dominant packs
        locked_properties['dominant_packs'] = dominant_packs
        locked_properties['dominant_pack'] = dominant_packs.get('overall', dominant_packs.get('music', 'unknown'))  # Legacy compatibility
        logger.info("REFERENCE_LOCK: Dominant packs selected: drums=%s, music=%s, fx=%s, vocal=%s",
                   dominant_packs.get('drums', 'unknown'),
                   dominant_packs.get('music', 'unknown'),
                   dominant_packs.get('fx', 'unknown'),
                   dominant_packs.get('vocal', dominant_packs.get('music', 'unknown')))

        # Resolve harmonic instruments from micro_stem_summary using the real MIDI/preset index
        micro_summary = dict(micro_stem_plan.get("summary", {}) or {})
        micro_summary["dominant_packs"] = dominant_packs  # P0: Store bus-aware packs
        micro_summary["dominant_pack"] = dominant_packs.get('overall', dominant_packs.get('music', 'unknown'))  # Legacy compatibility
        midi_preset_index = self._load_midi_preset_index()
        harmonic_instruments = self.resolve_harmonic_instruments(
            micro_summary,
            midi_preset_index
        )

        # Build synth_loop_hint from resolved instruments (prioritize pluck, pad, piano)
        synth_loop_hint = (
            harmonic_instruments.get('pluck')
            or harmonic_instruments.get('pad')
            or harmonic_instruments.get('piano')
            or harmonic_instruments.get('lead')
        )

        # Create MusicalTheme and PhrasePlan for hybrid materialization
        musical_theme = None
        phrase_plan = None
        primary_family = None
        try:
            # Import here to avoid circular dependencies
            from song_generator import MusicalTheme, PhrasePlan
            
            # Create musical theme from locked properties
            theme_key = locked_properties.get('key', project_key)
            theme_scale = locked_properties.get('scale', 'minor')
            musical_theme = MusicalTheme(key=theme_key, scale=theme_scale, seed=variant_seed)
            logger.info(f"MUSICAL_THEME_CREATED: key={theme_key}, scale={theme_scale}, seed={variant_seed}")
            
            # Create phrase plan from musical theme and sections
            if sections and musical_theme:
                # Convert sections to format expected by PhrasePlan
                phrase_sections = []
                current_bar = 0
                for i, section in enumerate(sections):
                    bars = int(section.get('bars', 8))
                    phrase_sections.append({
                        'kind': section.get('kind', 'drop'),
                        'start_bar': current_bar,
                        'end_bar': current_bar + bars,
                        'bars': bars,
                        'index': i
                    })
                    current_bar += bars
                
                # DETERMINE PRIMARY FAMILY from harmonic_instruments
                if harmonic_instruments:
                    priority_order = ['pluck', 'piano', 'keys', 'pad', 'lead']
                    for token in priority_order:
                        if token in harmonic_instruments:
                            primary_family = token
                            logger.info(f"PRIMARY_FAMILY_FROM_REFERENCE: {token} -> {primary_family}")
                            break
                    
                    # Fallback: use first available token name
                    if not primary_family:
                        first_token = list(harmonic_instruments.keys())[0]
                        primary_family = str(first_token).strip().lower() or None
                        if primary_family:
                            logger.warning(f"PRIMARY_FAMILY_FALLBACK: Using {primary_family} from {first_token}")
                
                # DETERMINE SECONDARY FAMILIES for harmonic support roles
                preferred_secondary_families = _derive_preferred_secondary_families(
                    harmonic_instruments,
                    primary_family,
                )
                for token in preferred_secondary_families:
                    logger.info(f"SECONDARY_FAMILY_FROM_REFERENCE: {token} added as preferred secondary")
                
                phrase_plan = PhrasePlan(
                    base_motif=musical_theme.base_motif,
                    sections=phrase_sections,
                    key=theme_key,
                    scale=theme_scale,
                    seed=variant_seed,
                    primary_harmonic_family=primary_family
                )
                logger.info(f"PHRASE_PLAN_CREATED: {len(phrase_plan.phrases)} phrases across {len(sections)} sections with family={primary_family}")
                
        except Exception as e:
            logger.warning(f"Failed to create MusicalTheme/PhrasePlan: {e}")
            import traceback
            logger.debug(traceback.format_exc())

        # Store hints in the plan for use during selection
        harmonic_hints = {
            'harmonic_instrument_hints': harmonic_instruments,
            'synth_loop_hint': synth_loop_hint,
            'midi_preset_index_stats': midi_preset_index.get("stats", {}),
            'preferred_secondary_families': preferred_secondary_families if 'preferred_secondary_families' in locals() else ['piano', 'keys'],
            'primary_harmonic_family': primary_family,
            'target_key': locked_properties.get('key', project_key),
            'key': locked_properties.get('key', project_key),
        }

        if harmonic_instruments:
            resolved_tokens = list(harmonic_instruments.keys())
            logger.info("HARMONIC_RESOLVE: Resolved tokens %s to instrument families", resolved_tokens)
            for token, info in harmonic_instruments.items():
                logger.debug(
                    "  - %s -> %s (family=%s, candidates=%d)",
                    token,
                    info['primary_candidate']['item'].get('name', 'unknown') if info.get('primary_candidate') else 'none',
                    info.get('family', 'unknown'),
                    1 + len(info.get('alternatives', []))
                )
        else:
            logger.info("HARMONIC_RESOLVE: no harmonic instrument hints resolved")

        # Use budget-based selection with P0 bus-aware pack enforcement
        selected = self._select_layers_with_budget(
            matches,
            genre=selection_genre,
            project_bpm=project_bpm,
            dominant_packs=dominant_packs,  # P0: Pass bus-aware dict
            strict_pack_mode=True,
            harmonic_hints=harmonic_hints,
            primary_harmonic_family=primary_family,
            sample_selector=selector,
        )
        selected = _filter_manual_recording_role_map(selected)

        # Verify pack coherence  after selection (P0: bus-aware)
        coherence_report = self.verify_pack_coherence(selected, dominant_packs, primary_family)

        # P1 Sprint v0.1.29: Enforce pack diversity in music bus
        # If all music roles are from the same pack, force one secondary pack selection
        music_packs_used = set()
        music_roles = ['synth_loop', 'synth_peak', 'chords', 'pad', 'texture']
        for role in music_roles:
            if selected.get(role):
                pack = self._extract_pack(selected[role].get('path', ''))
                if pack and pack != 'unknown':
                    music_packs_used.add(pack)
        
        # If only one pack is used for all music, force diversity for one role
        if len(music_packs_used) == 1:
            primary_music_pack = list(music_packs_used)[0]
            # Find a role we can diversify
            for role in ['synth_peak', 'chords', 'texture']:
                if selected.get(role):
                    # Find alternative from different pack
                    alt_candidates = [
                        item for item in matches.get(role, [])
                        if self._candidate_path(item) != self._candidate_path(selected[role])
                        and self._extract_pack(item.get('path', '')) != primary_music_pack
                        and self._is_pack_in_bus_group(self._extract_pack(item.get('path', '')), 'music')
                    ]
                    if alt_candidates:
                        # Pick best scoring alternative
                        alt_candidates.sort(key=lambda x: x.get('score', 0), reverse=True)
                        selected[role] = alt_candidates[0]
                        new_pack = self._extract_pack(alt_candidates[0].get('path', ''))
                        logger.info(f"[P1_DIVERSITY_ENFORCED] Role '{role}' diversified from {primary_music_pack} to {new_pack}")
                        break  # Only diversify one role

        offsets = self._section_offsets(sections)
        rng = random.Random(variant_seed if variant_seed is not None else random.SystemRandom().randint(1, 10**9))

        # Analyze roles per segment
        segment_roles = self._analyze_segment_roles(reference, sections)

        # Select alternative/variant samples for specific sections
        # These are selected from the bus-appropriate dominant pack when possible (P0)
        used_paths: set = set()
        used_families: set = set()

        # Build used paths/families from main selections
        for role, sample in selected.items():
            if sample:
                path = self._candidate_path(sample)
                family = self._candidate_family(sample)
                if path:
                    used_paths.add(path)
                if family:
                    used_families.add(family)

        # P0: Select variant samples with bus-aware pack preference
        # Perc uses drums bus dominant pack
        drums_dominant = dominant_packs.get('drums', dominant_packs.get('overall', ''))
        perc_candidates = [
            item for item in matches.get("perc_loop", [])
            if self._candidate_path(item) != self._candidate_path(selected.get("perc_loop"))
            and drums_dominant.lower() in item.get('path', '').lower()
        ]
        perc_alt = self._select_distinct_candidate("perc_loop", perc_candidates, rng, used_paths, used_families) if perc_candidates else None

        # Synth uses music bus dominant pack
        music_dominant = dominant_packs.get('music', dominant_packs.get('overall', ''))
        synth_candidates = [
            item for item in matches.get("synth_loop", [])
            if self._candidate_path(item) != self._candidate_path(selected.get("synth_loop"))
            and music_dominant.lower() in item.get('path', '').lower()
        ] if matches.get("synth_loop") else []
        # If no same-pack candidates, allow fallback
        if not synth_candidates and matches.get("synth_loop"):
            synth_candidates = [
                item for item in matches.get("synth_loop", [])
                if self._candidate_path(item) != self._candidate_path(selected.get("synth_loop"))
            ][:8]
        synth_alt = self._select_distinct_candidate("synth_loop", synth_candidates, rng, used_paths, used_families) if synth_candidates else None

        piano_audio_support = None
        piano_melody_sample = None
        selected_music_families = {
            self._candidate_family(sample)
            for role, sample in selected.items()
            if sample and role in {"synth_loop", "pad", "chords", "atmos_fx"}
        }
        preferred_piano_families = [family for family in preferred_secondary_families if family in PIANO_FAMILIES]
        if preferred_piano_families and not (selected_music_families & PIANO_FAMILIES):
            piano_candidates: List[Tuple[int, float, Dict[str, Any]]] = []
            for role_name in ("synth_loop", "atmos_fx"):
                for item in matches.get(role_name, []) or []:
                    candidate_path = self._candidate_path(item)
                    if not candidate_path or candidate_path in used_paths:
                        continue
                    candidate_family = self._candidate_family(item) or _infer_semantic_family(item.get("path", ""), item.get("file_name", ""))
                    if candidate_family not in PIANO_FAMILIES:
                        continue
                    candidate_pack = self._extract_pack(item.get("path", ""))
                    pack_rank = 2 if candidate_pack == music_dominant else (1 if _is_pack_in_bus_group(candidate_pack, "music") else 0)
                    support_rank = _score_piano_support_candidate(
                        item.get("file_name"),
                        item.get("path"),
                        item.get("name"),
                    )
                    piano_candidates.append((support_rank, pack_rank, float(item.get("score", 0.0) or 0.0), item))
            if piano_candidates:
                piano_candidates.sort(key=lambda entry: (entry[0], entry[1], entry[2]), reverse=True)
                piano_audio_support = piano_candidates[0][3]
                support_path = self._candidate_path(piano_audio_support)
                support_family = self._candidate_family(piano_audio_support)
                if support_path:
                    used_paths.add(support_path)
                if support_family:
                    used_families.add(support_family)
                logger.info(
                    "PIANO_AUDIO_SUPPORT: selected %s (%s) from %s",
                    piano_audio_support.get("file_name", "unknown"),
                    support_family or "unknown",
                    self._extract_pack(piano_audio_support.get("path", "")),
                )

        if preferred_piano_families:
            piano_melody_candidates: List[Tuple[int, int, float, Dict[str, Any]]] = []
            for item in matches.get("synth_loop", []) or []:
                candidate_path = self._candidate_path(item)
                if not candidate_path or candidate_path in used_paths:
                    continue
                candidate_family = self._candidate_family(item) or _infer_semantic_family(item.get("path", ""), item.get("file_name", ""))
                if candidate_family not in PIANO_FAMILIES:
                    continue
                melody_rank = _score_piano_melody_candidate(
                    item.get("file_name"),
                    item.get("path"),
                    item.get("name"),
                )
                if melody_rank <= 0:
                    continue
                candidate_pack = self._extract_pack(item.get("path", ""))
                pack_rank = 2 if candidate_pack == music_dominant else (1 if _is_pack_in_bus_group(candidate_pack, "music") else 0)
                piano_melody_candidates.append((melody_rank, pack_rank, float(item.get("score", 0.0) or 0.0), item))
            if piano_melody_candidates:
                piano_melody_candidates.sort(key=lambda entry: (entry[0], entry[1], entry[2]), reverse=True)
                piano_melody_sample = piano_melody_candidates[0][3]
                melody_path = self._candidate_path(piano_melody_sample)
                melody_family = self._candidate_family(piano_melody_sample)
                if melody_path:
                    used_paths.add(melody_path)
                if melody_family:
                    used_families.add(melody_family)
                logger.info(
                    "PIANO_MELODY: selected %s (%s) from %s",
                    piano_melody_sample.get("file_name", "unknown"),
                    melody_family or "unknown",
                    self._extract_pack(piano_melody_sample.get("path", "")),
                )

        # Vocal uses vocal bus dominant pack (or music if vocal not set)
        # P1 v0.1.20: Skip manual-only vocal roles
        vocal_alt = None
        vocal_candidates = []
        if not _is_manual_recording_role('vocal_loop'):
            vocal_dominant = dominant_packs.get('vocal', dominant_packs.get('music', dominant_packs.get('overall', '')))
            vocal_candidates = [
                item for item in matches.get("vocal_loop", [])
                if self._candidate_path(item) != self._candidate_path(selected.get("vocal_loop"))
                and vocal_dominant.lower() in item.get('path', '').lower()
            ] if matches.get("vocal_loop") else []
            # If no same-pack candidates, allow fallback
            if not vocal_candidates and matches.get("vocal_loop"):
                vocal_candidates = [
                    item for item in matches.get("vocal_loop", [])
                    if self._candidate_path(item) != self._candidate_path(selected.get("vocal_loop"))
                ][:8]
            vocal_alt = self._select_distinct_candidate("vocal_loop", vocal_candidates, rng, used_paths, used_families) if vocal_candidates else None
        else:
            logger.debug("[MANUAL_ONLY_SKIP] Skipping vocal_alt selection - vocal_loop is manual-only")

        def add_range(target: List[Tuple[float, Dict]], start: float, end: float, step: float, offset: float = 0.0, sample: Dict = None):
            if sample is None:
                return
            cursor = start + offset
            while cursor < end - 0.01:
                target.append((round(float(cursor), 3), sample))
                cursor += step

        def add_hit(target: List[Tuple[float, Dict]], position: float, sample: Dict = None):
            if position >= 0.0 and sample is not None:
                target.append((round(float(position), 3), sample))

        kick_positions: List[Tuple[float, Dict]] = []
        snare_positions: List[Tuple[float, Dict]] = []
        hat_positions: List[Tuple[float, Dict]] = []
        bass_positions: List[Tuple[float, Dict]] = []
        perc_positions: List[Tuple[float, Dict]] = []
        perc_alt_positions: List[Tuple[float, Dict]] = []
        top_loop_positions: List[Tuple[float, Dict]] = []
        synth_positions: List[Tuple[float, Dict]] = []
        synth_peak_positions: List[Tuple[float, Dict]] = []
        keys_support_positions: List[Tuple[float, Dict]] = []
        piano_melody_positions: List[Tuple[float, Dict]] = []
        vocal_positions: List[Tuple[float, Dict]] = []
        vocal_build_positions: List[Tuple[float, Dict]] = []
        vocal_peak_positions: List[Tuple[float, Dict]] = []
        crash_positions: List[Tuple[float, Dict]] = []
        fill_positions: List[Tuple[float, Dict]] = []
        snare_roll_positions: List[Tuple[float, Dict]] = []
        atmos_positions: List[Tuple[float, Dict]] = []
        vocal_shot_positions: List[Tuple[float, Dict]] = []

        bass_step = self._loop_step_beats(selected.get("bass_loop"), project_bpm, 16.0)
        perc_step = self._loop_step_beats(selected.get("perc_loop"), project_bpm, 16.0)
        perc_alt_step = self._loop_step_beats(perc_alt, project_bpm, 8.0)
        top_loop_step = self._loop_step_beats(selected.get("top_loop"), project_bpm, 8.0)
        synth_step = self._loop_step_beats(selected.get("synth_loop"), project_bpm, 16.0)
        vocal_step = self._loop_step_beats(selected.get("vocal_loop"), project_bpm, 8.0)
        vocal_alt_step = self._loop_step_beats(vocal_alt, project_bpm, 8.0)
        synth_alt_step = self._loop_step_beats(synth_alt, project_bpm, 8.0)
        atmos_step = self._loop_step_beats(selected.get("atmos_fx"), project_bpm, 16.0)
        piano_support_step = self._loop_step_beats(piano_audio_support, project_bpm, 16.0)
        piano_melody_step = self._loop_step_beats(piano_melody_sample, project_bpm, 8.0)

        # Manual vocal policy: do not let arrangement planning materialize auto vocals.
        selected["vocal_loop"] = None
        selected["vocal_shot"] = None
        vocal_alt = None

        # Store section-specific samples for roles eligible for variation
        section_samples: Dict[int, Dict[str, Optional[Dict[str, Any]]]] = {}

        for index, (section, start, end) in enumerate(offsets):
            kind = str(section.get("kind", "drop")).lower()
            section_name = str(section.get("name", "")).lower()

            # Set section context for section-aware sample selection
            if selector and hasattr(selector, 'set_section_context'):
                selector.set_section_context(kind)
                logger.debug("SECTION_CONTEXT [%s]: Set context for section %d ('%s')", kind, index, section_name)

            midpoint = (start + end) / 2.0
            progress = midpoint / max(1.0, offsets[-1][2])
            energy = self._section_energy(reference, progress)
            is_peak = "peak" in section_name or energy > 0.82
            is_vocal = "vocal" in section_name
            span = max(4.0, end - start)
            has_next_section = index < len(offsets) - 1
            next_section = offsets[index + 1][0] if has_next_section else {}
            next_kind = str(next_section.get("kind", "")).lower()
            next_name = str(next_section.get("name", "")).lower()
            transition_into_drop = next_kind == "drop" or "drop" in next_name or "peak" in next_name
            transition_is_vocal = "vocal" in next_name
            tail_hit = max(start, end - min(4.0, span / 2.0))
            roll_start = max(start, end - min(8.0, span))

            # Apply section variation for eligible roles
            section_variant = self._get_section_variant(kind, section.get('name', ''))
            section_samples[index] = {}

            # Map roles to their match lists and global selections
            role_match_map = {
                'perc': ('perc_loop', matches.get('perc_loop', []), selected.get('perc_loop')),
                'perc_alt': ('perc_loop', matches.get('perc_loop', []), perc_alt),
                'top_loop': ('top_loop', matches.get('top_loop', []), selected.get('top_loop')),
                'synth_peak': ('synth_loop', matches.get('synth_loop', []), synth_alt),
                'atmos': ('atmos_fx', matches.get('atmos_fx', []), selected.get('atmos_fx')),
            }

            for var_role, (match_role, match_list, fallback_sample) in role_match_map.items():
                if var_role in SECTION_VARIATION_ROLES and match_list and section_variant != 'standard':
                    # Apply variant filtering with section-specific samples
                    section_samples_map = self._get_variant_samples_for_section(
                        match_list,
                        var_role,
                        kind,
                        section.get('name', ''),
                        target_key=project_key,
                        target_bpm=project_bpm
                    )

                    # Get section-specific samples for this role
                    section_key = f"{kind}_{section.get('name', '')}"
                    specific_samples = section_samples_map.get(section_key, match_list)

                    # Use specific_samples for selection
                    samples_to_use = specific_samples if specific_samples else match_list

                    if samples_to_use and samples_to_use != match_list:
                        # Select from section-specific samples, avoiding already used paths
                        section_used_paths = used_paths.copy()
                        section_sample = self._select_distinct_candidate(
                            match_role,
                            samples_to_use,
                            rng,
                            section_used_paths,
                            used_families,
                            section_kind=kind,
                            section_energy=energy,
                        )

                        if section_sample:
                            # Get the actual file path for logging
                            sample_path = section_sample.get('file_path', section_sample.get('file_name', 'unknown'))
                            logger.debug("SECTION_VARIANT_REAL: role '%s' using %d specific samples for section '%s' (vs %d base) - selected: %s",
                                        var_role, len(samples_to_use), section.get('name'), len(match_list), sample_path)
                            section_samples[index][var_role] = section_sample
                            if selector and hasattr(selector, 'record_section_selection'):
                                selector.record_section_selection(kind, var_role, section_sample)
                        else:
                            # Fallback to global selection
                            section_samples[index][var_role] = fallback_sample
                    else:
                        # No filtering applied or no samples after filter, use global
                        section_samples[index][var_role] = fallback_sample
                else:
                    # Not eligible for variation or no variant, use global
                    section_samples[index][var_role] = fallback_sample

            # P0 Sprint v0.1.23: Harmonic variation for synth_loop and bass_loop
            # CRITICAL: Must stay within SAME pack/family for harmonic coherence
            if kind in HARMONIC_VARIATION_SECTIONS and section_variant != 'standard':
                global_synth = selected.get('synth_loop')
                global_bass = selected.get('bass_loop')
                
                # Get pack constraints for harmonic coherence
                synth_pack = self._extract_pack(global_synth.get('path', '')) if global_synth else None
                bass_pack = self._extract_pack(global_bass.get('path', '')) if global_bass else None
                synth_family = self._candidate_family(global_synth) if global_synth else None
                bass_family = self._candidate_family(global_bass) if global_bass else None
                
                # Synth loop variation within same pack/family
                if global_synth and synth_pack and 'synth_loop' in matches:
                    synth_candidates = [
                        c for c in matches.get('synth_loop', [])
                        if self._extract_pack(c.get('path', '')) == synth_pack
                        and self._candidate_path(c) != self._candidate_path(global_synth)
                    ]
                    if synth_candidates and synth_family:
                        # Further constrain to same family if possible
                        family_synths = [c for c in synth_candidates if self._candidate_family(c) == synth_family]
                        if family_synths:
                            synth_candidates = family_synths
                    
                    if synth_candidates:
                        section_synth = self._select_distinct_candidate(
                            'synth_loop', synth_candidates, rng,
                            set(), used_families,
                            section_kind=kind, section_energy=energy
                        )
                        if section_synth:
                            section_samples[index]['synth_loop'] = section_synth
                            logger.debug("HARMONIC_VARIANT: synth_loop for section '%s' - pack: %s",
                                        section.get('name', kind), synth_pack)
                
                # Bass loop variation within same pack/family
                if global_bass and bass_pack and 'bass_loop' in matches:
                    bass_candidates = [
                        c for c in matches.get('bass_loop', [])
                        if self._extract_pack(c.get('path', '')) == bass_pack
                        and self._candidate_path(c) != self._candidate_path(global_bass)
                    ]
                    if bass_candidates and bass_family:
                        family_bass = [c for c in bass_candidates if self._candidate_family(c) == bass_family]
                        if family_bass:
                            bass_candidates = family_bass
                    
                    if bass_candidates:
                        section_bass = self._select_distinct_candidate(
                            'bass_loop', bass_candidates, rng,
                            set(), used_families,
                            section_kind=kind, section_energy=energy
                        )
                        if section_bass:
                            section_samples[index]['bass_loop'] = section_bass
                            logger.debug("HARMONIC_VARIANT: bass_loop for section '%s' - pack: %s",
                                        section.get('name', kind), bass_pack)

            # Helper to get the right sample for a role in this section
            def get_sample(role: str, fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
                """Get section-specific sample if available, otherwise fallback."""
                return section_samples[index].get(role, fallback)

            def get_step_for_sample(sample: Optional[Dict[str, Any]], fallback_step: float, default_beats: float) -> float:
                """Resolve step from the actual materialized sample, not only the global selection."""
                if sample is None:
                    return fallback_step
                try:
                    return self._loop_step_beats(sample, project_bpm, default_beats)
                except Exception:
                    return fallback_step

            atmos_sample = get_sample('atmos', selected.get("atmos_fx"))
            if atmos_sample and kind in {"intro", "break", "outro"}:
                add_range(atmos_positions, start, end, max(8.0, atmos_step), sample=atmos_sample)
            elif atmos_sample and is_vocal and span >= 8.0:
                add_hit(atmos_positions, max(start, end - 8.0), sample=atmos_sample)

            if piano_audio_support and kind in {"intro", "build", "break"}:
                support_step = max(8.0, min(piano_support_step, 16.0))
                support_start = start if kind != "build" else min(end - support_step, start + 4.0)
                add_range(keys_support_positions, max(start, support_start), end, support_step, sample=piano_audio_support)

            if piano_melody_sample and kind in {"build", "break"}:
                melody_step = max(8.0, min(piano_melody_step, 16.0))
                melody_start = start if kind == "break" else min(end - melody_step, start + 4.0)
                add_range(piano_melody_positions, max(start, melody_start), end, melody_step, sample=piano_melody_sample)

            if kind == 'intro':
                add_range(kick_positions, start, end, 2.0 if energy < 0.55 else 1.0, sample=selected.get("kick"))
                add_range(hat_positions, start, end, 1.0, 0.5, sample=selected.get("hat"))
                if selected.get("top_loop") and energy > 0.5:
                    add_range(top_loop_positions, start + min(4.0, span / 2.0), end, top_loop_step, 0.0, sample=get_sample('top_loop', selected.get("top_loop")))
            elif kind == 'break':
                # P1 Sprint v0.1.26: Break con más soporte armónico, no vacío
                add_range(kick_positions, start, end, 4.0, sample=selected.get("kick"))
                add_range(snare_positions, start + 3.0, end, 4.0, sample=selected.get("snare"))
                if selected.get("perc_loop"):
                    perc_sample = get_sample('perc_alt', perc_alt) if perc_alt else get_sample('perc', selected.get("perc_loop"))
                    add_range(perc_alt_positions if perc_alt else perc_positions, start, end, perc_alt_step if perc_alt else perc_step, sample=perc_sample)
                
                # P1 Sprint v0.1.26: Bass debe seguir en break para evitar huecos
                if selected.get("bass_loop"):
                    bass_samp = get_sample('bass_loop', selected.get("bass_loop"))
                    # Break: bajo más suave pero presente
                    add_range(bass_positions, start, end, bass_step * 2.0, sample=bass_samp)
                
                # P1 Sprint v0.1.26: Soporte armónico en break (keys/piano)
                if selected.get("chords") or piano_audio_support:
                    support_sample = piano_audio_support or selected.get("chords")
                    support_step = self._loop_step_beats(support_sample, project_bpm, 8.0) if support_sample else 8.0
                    # Break: piano/keys para mantener coherencia armónica
                    add_range(keys_support_positions, start + 2.0, end, support_step, sample=support_sample)
                
                # P1 Sprint v0.1.26: Atmos en break si está disponible
                if selected.get("atmos"):
                    add_range(atmos_positions, start, end, max(8.0, atmos_step), sample=selected.get("atmos"))
                
                if vocal_alt and (is_vocal or energy > 0.6):
                    add_range(vocal_build_positions, start + max(0.0, span - 8.0), end, vocal_alt_step, sample=vocal_alt)
                if selected.get("fill_fx") and has_next_section:
                    add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx"))
                if selected.get("snare_roll") and has_next_section:
                    add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll"))
            elif kind == 'build':
                add_range(kick_positions, start, end, 1.0, sample=selected.get("kick"))
                add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare"))
                add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat"))
                if selected.get("bass_loop"):
                    # P0 Sprint v0.1.23: Use section-specific bass if available
                    bass_samp = get_sample('bass_loop', selected.get("bass_loop"))
                    bass_step_local = get_step_for_sample(bass_samp, bass_step, 16.0)
                    add_range(bass_positions, start, end, bass_step_local, sample=bass_samp)
                if selected.get("perc_loop"):
                    add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop")))
                if selected.get("top_loop"):
                    add_range(top_loop_positions, start + 4.0, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop")))
                if selected.get("vocal_loop") and is_vocal:
                    add_range(vocal_positions, start, end, vocal_step, sample=selected.get("vocal_loop"))
                if vocal_alt and (is_vocal or energy > 0.58):
                    add_range(vocal_build_positions, start, end, vocal_alt_step, 0.0, sample=vocal_alt)
                if selected.get("synth_loop") and energy > 0.62:
                    # P0 Sprint v0.1.23: Use section-specific synth if available
                    synth_samp = get_sample('synth_loop', selected.get("synth_loop"))
                    synth_step_local = get_step_for_sample(synth_samp, synth_step, 16.0)
                    add_range(
                        synth_positions,
                        max(start, end - max(8.0, synth_step_local)),
                        end,
                        synth_step_local,
                        sample=synth_samp,
                    )
                if selected.get("snare_roll"):
                    add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll"))
                if selected.get("fill_fx"):
                    add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx"))
                if transition_into_drop and selected.get("crash_fx"):
                    add_hit(crash_positions, end, sample=selected.get("crash_fx"))
            else:
                add_range(kick_positions, start, end, 1.0, sample=selected.get("kick"))
                add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare"))
                add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat"))
                if selected.get("bass_loop"):
                    # P0 Sprint v0.1.23: Use section-specific bass if available
                    bass_samp = get_sample('bass_loop', selected.get("bass_loop"))
                    bass_step_local = get_step_for_sample(bass_samp, bass_step, 16.0)
                    add_range(bass_positions, start, end, bass_step_local, sample=bass_samp)
                
                # P1 Sprint v0.1.26: Variación de densidad por sección para loops de groove
                # Drop A vs Drop B deben tener diferente densidad de percusión y elementos
                is_drop_a = "drop a" in section_name.lower() or (kind == 'drop' and index == 2)
                is_drop_b = "drop b" in section_name.lower() or (kind == 'drop' and index == 4)
                
                if selected.get("perc_loop"):
                    add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop")))
                
                if selected.get("top_loop"):
                    add_range(top_loop_positions, start, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop")))
                
                # Variación de percusión alternativa en peak moments
                if perc_alt and ("peak" in str(section.get("name", "")).lower() or energy > 0.82):
                    add_range(perc_alt_positions, start, end, perc_alt_step, sample=get_sample('perc_alt', perc_alt))
                
                # P1 Sprint v0.1.26: Synth loop con diferenciación Drop A vs Drop B
                if selected.get("synth_loop") and ("drop" in kind or is_peak):
                    synth_samp = get_sample('synth_loop', selected.get("synth_loop"))
                    synth_step_local = get_step_for_sample(synth_samp, synth_step, 16.0)
                    add_range(synth_positions, start, end, synth_step_local, sample=synth_samp)

                if piano_melody_sample and is_drop_b:
                    melody_step = max(8.0, min(piano_melody_step, 16.0))
                    add_range(
                        piano_melody_positions,
                        start + min(4.0, span / 4.0),
                        end,
                        melody_step,
                        sample=piano_melody_sample,
                    )
                
                if synth_alt and is_peak:
                    add_range(synth_peak_positions, start + min(4.0, span / 4.0), end, synth_alt_step, sample=get_sample('synth_peak', synth_alt))
                if selected.get("vocal_loop") and ("drop b" in section_name or is_peak):
                    add_range(vocal_positions, start + 4.0, end, vocal_step, sample=selected.get("vocal_loop"))
                if vocal_alt and is_peak:
                    add_range(vocal_peak_positions, start, end, vocal_alt_step, sample=vocal_alt)
                if selected.get("crash_fx") and index > 0:
                    add_hit(crash_positions, start, sample=selected.get("crash_fx"))
                if selected.get("fill_fx") and has_next_section and next_kind != "outro":
                    add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx"))

            vocal_shot_sample = get_sample('vocal_shot', selected.get("vocal_shot"))
            if vocal_shot_sample and (is_peak or transition_is_vocal):
                add_hit(vocal_shot_positions, min(end - 1.0, start + 4.0), sample=vocal_shot_sample)
                if span >= 16.0:
                    add_hit(vocal_shot_positions, min(end - 1.0, start + span / 2.0), sample=vocal_shot_sample)

        if selected.get("bass_loop"):
            existing_bass_positions = {round(float(pos), 3) for pos, _sample in bass_positions}
            for index, (section, start, _end) in enumerate(offsets):
                kind = str(section.get("kind", "drop")).lower()
                if kind not in {"build", "drop"}:
                    continue
                anchor = round(float(start), 3)
                if anchor in existing_bass_positions:
                    continue
                bass_sample = section_samples.get(index, {}).get("bass_loop") or selected.get("bass_loop")
                if bass_sample is None:
                    continue
                bass_positions.append((anchor, bass_sample))
                existing_bass_positions.add(anchor)
                logger.debug("BASS_ANCHOR: injected %s at %.3f for section %s", bass_sample.get("file_name", "unknown"), anchor, kind)

        # After processing all sections, record selections and clear context
        if selector and hasattr(selector, 'record_section_selection'):
            # Record selections for joint scoring
            for index, selections in section_samples.items():
                section = sections[index] if index < len(sections) else None
                if section:
                    section_kind = str(section.get("kind", "drop")).lower()
                    for role, sample in selections.items():
                        if sample:
                            selector.record_section_selection(section_kind, role, sample)
            logger.debug("SECTION_CONTEXT: Recorded %d section selections for joint scoring", len(section_samples))

        if selector and hasattr(selector, 'clear_section_context'):
            selector.clear_section_context()
            logger.debug("SECTION_CONTEXT: Cleared after processing all sections")

        layers: List[Dict[str, Any]] = []

        def add_layer(name: str, role: str, asset: Optional[Dict[str, Any]], positions: List[Tuple[float, Dict]],
                      color: int, volume: float):
            """Add one or more layers for positions grouped by sample."""
            if not positions:
                return

            # Validate total presence of musical layers without deleting section variants.
            MIN_TOTAL_PLACEMENTS_FOR_MUSICAL_LAYERS = 3
            is_musical_layer = role in {'synth_loop', 'synth_peak', 'lead', 'chords', 'pad', 'texture', 'ambient', 'bass_loop', 'top_loop'}
            
            # Group positions by sample
            positions_by_sample: Dict[str, List[float]] = {}
            sample_info: Dict[str, Dict[str, Any]] = {}

            for pos, sample in positions:
                if sample is None:
                    continue
                sample_path = sample.get("path", "")
                if sample_path not in positions_by_sample:
                    positions_by_sample[sample_path] = []
                    sample_info[sample_path] = sample
                positions_by_sample[sample_path].append(pos)

            if is_musical_layer:
                total_positions = sum(len(pos_list) for pos_list in positions_by_sample.values())
                if total_positions < MIN_TOTAL_PLACEMENTS_FOR_MUSICAL_LAYERS:
                    logger.warning(
                        "LAYER_OMIT: %s has only %d total placements (min: %d) - OMITTED",
                        name,
                        total_positions,
                        MIN_TOTAL_PLACEMENTS_FOR_MUSICAL_LAYERS,
                    )
                    return

            # If no asset provided but positions exist, use the first sample
            if asset is None and positions_by_sample:
                first_sample_path = next(iter(positions_by_sample))
                asset = sample_info[first_sample_path]

            # If all positions use the same sample (or asset is provided), create single layer
            if asset and asset.get("path") in positions_by_sample:
                asset_positions = positions_by_sample.get(asset.get("path", ""), [p for p, _ in positions])
                if asset_positions:
                    adj_vol = volume
                    rms = asset.get("rms_energy", 0.0)
                    if rms > 0.0:
                        adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5))

                    layers.append({
                        "name": name,
                        "role": role,
                        "file_path": asset["path"],
                        "positions": sorted(set(asset_positions)),
                        "color": color,
                        "volume": round(adj_vol, 3),
                        "source": asset.get("file_name", ""),
                        "source_file": asset.get("file_name", ""),
                        "family": self._candidate_family(asset),
                        "pack": self._extract_pack(asset.get("path", "")),
                    })
            else:
                # Multiple samples - create layers with variant names
                for i, (sample_path, pos_list) in enumerate(positions_by_sample.items()):
                    sample = sample_info[sample_path]
                    variant_name = sample.get("file_name", "")

                    adj_vol = volume
                    rms = sample.get("rms_energy", 0.0)
                    if rms > 0.0:
                        adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5))

                    # Create variant suffix based on sample characteristics
                    if i > 0:
                        layer_name = f"{name} ({variant_name[:20]})"
                    else:
                        layer_name = name

                    layers.append({
                        "name": layer_name,
                        "role": role,
                        "file_path": sample_path,
                        "positions": sorted(set(pos_list)),
                        "color": color,
                        "volume": round(adj_vol, 3),
                        "source": variant_name,
                        "source_file": variant_name,
                        "family": self._candidate_family(sample),
                        "pack": self._extract_pack(sample_path),
})

        add_layer("AUDIO KICK", "kick", selected.get("kick"), kick_positions, 10, 0.86)
        add_layer("AUDIO CLAP", "snare", selected.get("snare"), snare_positions, 45, 0.72)
        add_layer("AUDIO HAT", "hat", selected.get("hat"), hat_positions, 5, 0.58)
        add_layer("AUDIO BASS LOOP", "bass_loop", selected.get("bass_loop"), bass_positions, 30, 0.76)
        add_layer("AUDIO PERC MAIN", "perc_loop", selected.get("perc_loop"), perc_positions, 20, 0.68)
        add_layer("AUDIO PERC ALT", "perc_loop", perc_alt, perc_alt_positions, 22, 0.62)
        add_layer("AUDIO TOP LOOP", "top_loop", selected.get("top_loop") or perc_alt or selected.get("perc_loop"), top_loop_positions, 24, 0.52)
        add_layer("AUDIO SYNTH LOOP", "synth_loop", selected.get("synth_loop"), synth_positions, 50, 0.52)
        add_layer("AUDIO SYNTH PEAK", "synth_loop", synth_alt or selected.get("synth_loop"), synth_peak_positions, 52, 0.48)
        add_layer("AUDIO KEYS SUPPORT", "chords", piano_audio_support, keys_support_positions, 53, 0.42)
        add_layer("AUDIO PIANO MELODY", "lead", piano_melody_sample, piano_melody_positions, 51, 0.46)
        add_layer("AUDIO CRASH FX", "crash_fx", selected.get("crash_fx"), crash_positions, 26, 0.5)
        add_layer("AUDIO TRANSITION FILL", "fill_fx", selected.get("fill_fx") or selected.get("snare_roll"), fill_positions, 28, 0.56)
        add_layer("AUDIO SNARE ROLL", "snare_roll", selected.get("snare_roll"), snare_roll_positions, 27, 0.54)
        add_layer("AUDIO ATMOS", "atmos_fx", selected.get("atmos_fx"), atmos_positions, 54, 0.44)

        # P1.2: Fill harmonic gaps with backbone content
        layers = self._fill_harmonic_gaps(
            layers, 
            offsets, 
            selected,
            dominant_packs,
            primary_family,
            preferred_secondary_families if 'preferred_secondary_families' in locals() else ['piano', 'keys']
        )

        # Compute remake quality metrics
        remake_quality = self._compute_remake_quality_metrics(
            sections, selected, sections
        )

        # Add palette_dominance to remake_quality
        remake_quality['palette_dominance'] = coherence_report

        # Build section energy profile for generator
        section_energy_profile = []
        for section in sections:
            features = section.get('features', {})
            section_energy_profile.append({
                'kind': section.get('kind', 'drop'),
                'energy_mean': features.get('energy_mean', features.get('energy', 0.5)),
                'energy_peak': features.get('energy_peak', 0.5),
                'energy_slope': features.get('energy_slope', 0.0),
                'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)),
                'spectral_centroid_std': features.get('spectral_centroid_std', 0.0),
                'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)),
                'low_energy_ratio': features.get('low_energy_ratio', 0.0),
                'high_energy_ratio': features.get('high_energy_ratio', 0.0),
                'kind_confidence': section.get('kind_confidence', 0.5),
            })

        return {
            "reference": {
                "path": reference.get("path"),
                "file_name": reference.get("file_name"),
                "tempo": reference.get("tempo"),
                "key": reference.get("key") or project_key,
                "device": self.device_name,
                "variant_seed": variant_seed,
            },
            "sections": sections,
            "segment_roles": segment_roles,
            "layers": layers,
            "matches": selected,
            "section_samples": section_samples,
            "section_energy_profile": section_energy_profile,
            "remake_quality": remake_quality,
            "micro_stems": micro_stem_plan,
            "micro_stem_summary": micro_summary,
            "palette_info": {
                "dominant_pack": dominant_packs.get("overall", dominant_packs.get("music", "unknown")),
                "dominant_packs": dominant_packs,
                "coherence": coherence_report,
                "enforcement_mode": "strict",
                "threshold": 0.6,
            },
            "harmonic_instrument_hints": harmonic_hints.get("harmonic_instrument_hints", {}),
            "midi_preset_index_stats": harmonic_hints.get("midi_preset_index_stats", {}),
            "synth_loop_hint": harmonic_hints.get("synth_loop_hint"),
            "musical_theme": musical_theme.to_dict() if musical_theme else None,
            "phrase_plan": phrase_plan.to_dict() if phrase_plan else None,
            "primary_harmonic_family": primary_family,
            "preferred_secondary_families": preferred_secondary_families if 'preferred_secondary_families' in locals() else ['piano', 'keys'],
            "manual_recording_roles": sorted(MANUAL_RECORDING_ROLES),
            "auto_vocal_layers_enabled": False,
            "layer_selection_audit": self.get_selection_audit(),
            # REFERENCE LOCK: Properties that MUST be used in generation
            "locked_properties": locked_properties,
        }

    def _fill_harmonic_gaps(
        self,
        layers: List[Dict[str, Any]],
        offsets: List[Tuple[Dict, float, float]],
        selected: Dict[str, Optional[Dict[str, Any]]],
        dominant_packs: Dict[str, str],
        primary_family: Optional[str],
        preferred_secondary_families: List[str]
    ) -> List[Dict[str, Any]]:
        """
        P1.2 Sprint v0.1.39: Fill silence with harmonic backbone, not random clutter.
        
        Detects gaps in harmonic content and extends existing harmonic layers
        preferentially instead of adding random FX.
        
        Strategy:
        1. Detect gaps > 8 beats in harmonic coverage (synth_loop, chords, pad, bass_loop)
        2. Extend existing harmonic layers into gaps
        3. Support bass continuity across sections
        4. Reuse coherent family material from selected samples
        5. Do NOT spray random FX
        6. Do NOT fill every gap blindly - only critical gaps
        
        Returns:
            Updated layers list with gap-filling layers added
        """
        if not layers or not offsets:
            return layers
        
        HARMONIC_ROLES = {'synth_loop', 'synth_peak', 'chords', 'pad', 'bass_loop', 'lead', 'pluck'}
        MAX_HARMONIC_GAP_BEATS = 16.0  # Only fill gaps larger than 16 beats
        MIN_GAP_FILL_BENEFIT = 8.0  # Minimum beats a gap fill must cover
        
        # Collect all harmonic positions
        harmonic_events = []
        for layer in layers:
            role = str(layer.get('role', '')).lower()
            if role in HARMONIC_ROLES:
                for pos in layer.get('positions', []):
                    harmonic_events.append({
                        'position': float(pos),
                        'role': role,
                        'layer': layer
                    })
        
        if not harmonic_events:
            logger.debug("[P1.2_HARMONIC_GAP] No harmonic events found, skipping gap fill")
            return layers
        
        # Sort by position
        harmonic_events.sort(key=lambda x: x['position'])
        
        # Detect gaps in harmonic coverage
        total_beats = offsets[-1][2] if offsets else 0
        gaps = []
        
        for i in range(len(harmonic_events) - 1):
            current_pos = harmonic_events[i]['position']
            next_pos = harmonic_events[i + 1]['position']
            gap_size = next_pos - current_pos
            
            if gap_size > MAX_HARMONIC_GAP_BEATS:
                gaps.append({
                    'start': current_pos,
                    'end': next_pos,
                    'size': gap_size,
                    'after_role': harmonic_events[i]['role'],
                    'before_role': harmonic_events[i + 1]['role']
                })
        
        # Check for gap at the beginning
        if harmonic_events and harmonic_events[0]['position'] > MAX_HARMONIC_GAP_BEATS:
            gaps.insert(0, {
                'start': 0.0,
                'end': harmonic_events[0]['position'],
                'size': harmonic_events[0]['position'],
                'after_role': None,
                'before_role': harmonic_events[0]['role']
            })
        
        # Check for gap at the end
        if harmonic_events and (total_beats - harmonic_events[-1]['position']) > MAX_HARMONIC_GAP_BEATS:
            gaps.append({
                'start': harmonic_events[-1]['position'],
                'end': total_beats,
                'size': total_beats - harmonic_events[-1]['position'],
                'after_role': harmonic_events[-1]['role'],
                'before_role': None
            })
        
        if not gaps:
            logger.debug("[P1.2_HARMONIC_GAP] No critical gaps detected (max gap < %.0f beats)", MAX_HARMONIC_GAP_BEATS)
            return layers
        
        logger.info(
            "[P1.2_HARMONIC_GAP] Detected %d critical harmonic gaps (>%0.f beats): %s",
            len(gaps), 
            MAX_HARMONIC_GAP_BEATS,
            [(g['start'], g['end'], g['size']) for g in gaps[:5]]
        )
        
        # Fill gaps preferentially
        filled_layers = []
        
        for gap in gaps:
            # P1.2: Strategy 1 - Extend existing harmonic layer
            # Find the best harmonic layer to extend (prefer same family, same pack)
            best_layer_to_extend = None
            best_score = -1
            
            for layer in layers:
                role = str(layer.get('role', '')).lower()
                if role not in HARMONIC_ROLES:
                    continue
                
                positions = layer.get('positions', [])
                if not positions:
                    continue
                
                # Check if this layer is adjacent to the gap
                min_pos = min(positions)
                max_pos = max(positions)
                
                # Check if layer is just before or just after gap
                is_adjacent_before = abs(max_pos - gap['start']) < 4.0  # Within 4 beats
                is_adjacent_after = abs(min_pos - gap['end']) < 4.0
                
                if not (is_adjacent_before or is_adjacent_after):
                    continue
                
                # Score this layer
                score = 0
                
                # Prefer layers from dominant pack
                layer_pack = self._extract_pack(layer.get('file_path', ''))
                music_dominant = dominant_packs.get('music', dominant_packs.get('overall', ''))
                if layer_pack and music_dominant.lower() in layer_pack.lower():
                    score += 30
                
                # Prefer layers with matching family
                layer_family = layer.get('family', '').lower()
                if primary_family and layer_family == primary_family.lower():
                    score += 25
                elif layer_family in preferred_secondary_families:
                    score += 15
                
                # Prefer bass for bass continuity
                if role == 'bass_loop' and gap['after_role'] == 'bass_loop':
                    score += 20
                
                # Prefer longer loops
                if len(positions) >= 4:
                    score += 10
                
                if score > best_score:
                    best_score = score
                    best_layer_to_extend = layer
            
            # P1.2: If we found a good layer to extend, do it
            if best_layer_to_extend and gap['size'] >= MIN_GAP_FILL_BENEFIT:
                # Extend positions into gap
                extended_positions = list(best_layer_to_extend.get('positions', []))
                loop_length = 16.0  # Default loop length in beats
                
                # Calculate positions to add
                gap_positions = []
                cursor = gap['start']
                while cursor < gap['end']:
                    gap_positions.append(cursor)
                    cursor += loop_length
                
                # Add new positions
                for pos in gap_positions:
                    if pos not in extended_positions:
                        extended_positions.append(pos)
                
                # Update the layer
                best_layer_to_extend['positions'] = sorted(extended_positions)
                
                logger.info(
                    "[P1.2_HARMONIC_GAP_FILLED] Extended %s (%s) into gap %.0f-%.0f (%.0f beats), added %d positions",
                    best_layer_to_extend.get('name', 'unknown'),
                    best_layer_to_extend.get('role', 'unknown'),
                    gap['start'], gap['end'], gap['size'],
                    len(gap_positions)
                )
            else:
                # P1.2: If no good extension, consider creating a new sustain layer
                # Only do this if we have a good candidate from selected samples
                logger.debug(
                    "[P1.2_HARMONIC_GAP_SKIP] Gap %.0f-%.0f (%.0f beats) not filled - no suitable extension found",
                    gap['start'], gap['end'], gap['size']
                )
        
        return layers

    def _compute_remake_quality_metrics(
        self,
        sections: List[Dict[str, Any]],
        selected: Dict[str, Optional[Dict[str, Any]]],
        reference_sections: List[Dict[str, Any]]
    ) -> Dict[str, Any]:
        """
        Compute per-section quality scores for how well selected samples match reference character.

        Metrics included:
        - Energy profile similarity
        - Spectral characteristic similarity
        - Rhythmic density comparison
        - Low-end presence matching
        - High-end brightness matching

        Uses already-computed data - no new librosa calls.
        """
        section_scores = []

        energy_profile_scores = []
        spectral_similarity_scores = []
        rhythmic_density_scores = []
        low_end_presence_scores = []
        high_end_brightness_scores = []

        for i, section in enumerate(sections):
            kind = str(section.get('kind', 'drop')).lower()
            features = section.get('features', {})
            section_match_score = 0.5
            weak_roles = []

            ref_energy_mean = features.get('energy_mean', features.get('energy', 0.5))
            _ = features.get('energy_peak', ref_energy_mean)
            ref_energy_slope = features.get('energy_slope', 0.0)
            ref_onset_rate = features.get('onset_rate', features.get('onset_density', 0.5))
            ref_low_ratio = features.get('low_energy_ratio', 0.0)
            ref_high_ratio = features.get('high_energy_ratio', 0.0)
            ref_spectral_centroid = features.get('spectral_centroid_mean', features.get('brightness', 0.5))
            ref_spectral_std = features.get('spectral_centroid_std', 0.0)

            energy_profile_score = 0.5
            spectral_similarity_score = 0.5
            rhythmic_density_score = 0.5
            low_end_presence_score = 0.5
            high_end_brightness_score = 0.5

            selected_samples_energy = []
            selected_samples_centroid = []
            selected_samples_onset = []
            selected_samples_low_energy = 0.0
            selected_samples_high_energy = 0.0

            for role in ['kick', 'snare', 'hat', 'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'atmos_fx']:
                sample = selected.get(role)
                if sample:
                    rms = float(sample.get('rms_mean', sample.get('rms_energy', 0.5)) or 0.5)
                    centroid = float(sample.get('spectral_centroid', 5000) or 5000)
                    onset = float(sample.get('onset_mean', sample.get('onset_rate', 3)) or 3)

                    selected_samples_energy.append(rms)
                    selected_samples_centroid.append(centroid)
                    selected_samples_onset.append(onset)

                    if centroid < 300:
                        selected_samples_low_energy += rms
                    if centroid > 4000:
                        selected_samples_high_energy += rms

            if selected_samples_energy:
                avg_energy = sum(selected_samples_energy) / len(selected_samples_energy)
                energy_diff = abs(avg_energy - ref_energy_mean)
                energy_profile_score = max(0.0, 1.0 - energy_diff * 2.0)

                if ref_energy_slope > 0.1:
                    build_roles = ['snare_roll', 'fill_fx', 'hat']
                    build_energy = sum(
                        float(selected.get(r, {}).get('rms_mean', 0) or 0)
                        for r in build_roles if selected.get(r)
                    )
                    if build_energy > 0.3:
                        energy_profile_score = min(1.0, energy_profile_score + 0.15)

            if selected_samples_centroid:
                avg_centroid_norm = sum(selected_samples_centroid) / len(selected_samples_centroid) / 10000.0
                ref_centroid_norm = ref_spectral_centroid
                centroid_diff = abs(avg_centroid_norm - ref_centroid_norm)
                spectral_similarity_score = max(0.0, 1.0 - centroid_diff)

                if ref_spectral_std > 0.3:
                    centroid_variance = 0.0
                    if len(selected_samples_centroid) > 1:
                        centroid_variance = float(np.std(selected_samples_centroid)) / 10000.0
                    if centroid_variance > 0.1:
                        spectral_similarity_score = min(1.0, spectral_similarity_score + 0.1)

            if selected_samples_onset:
                avg_onset_norm = sum(selected_samples_onset) / len(selected_samples_onset) / 10.0
                ref_onset_norm = ref_onset_rate
                onset_diff = abs(avg_onset_norm - ref_onset_norm)
                rhythmic_density_score = max(0.0, 1.0 - onset_diff)

                if ref_onset_rate > 0.5:
                    perc_onset = float(selected.get('perc_loop', {}).get('onset_mean', 0) or 0)
                    top_onset = float(selected.get('top_loop', {}).get('onset_mean', 0) or 0)
                    hat_onset = float(selected.get('hat', {}).get('onset_mean', 0) or 0)
                    if perc_onset > 3 or top_onset > 3 or hat_onset > 3:
                        rhythmic_density_score = min(1.0, rhythmic_density_score + 0.15)

            bass_match = selected.get('bass_loop')
            kick_match = selected.get('kick')
            if bass_match or kick_match:
                bass_centroid = float(bass_match.get('spectral_centroid', 500) or 500) if bass_match else 500
                kick_centroid = float(kick_match.get('spectral_centroid', 300) or 300) if kick_match else 300
                low_centroid_avg = (bass_centroid + kick_centroid) / 2

                if ref_low_ratio > 0.3:
                    if low_centroid_avg < 1500:
                        low_end_presence_score = 0.85 + (ref_low_ratio * 0.15)
                    elif low_centroid_avg < 2500:
                        low_end_presence_score = 0.65
                    else:
                        low_end_presence_score = 0.35
                        weak_roles.append('bass_loop')
                else:
                    low_end_presence_score = 0.7
            else:
                if ref_low_ratio > 0.35:
                    low_end_presence_score = 0.3
                    weak_roles.append('bass_loop')

            hat_match = selected.get('hat')
            top_match = selected.get('top_loop')
            synth_match = selected.get('synth_loop')
            if hat_match or top_match or synth_match:
                high_centroids = []
                if hat_match:
                    high_centroids.append(float(hat_match.get('spectral_centroid', 6000) or 6000))
                if top_match:
                    high_centroids.append(float(top_match.get('spectral_centroid', 5000) or 5000))
                if synth_match:
                    high_centroids.append(float(synth_match.get('spectral_centroid', 4000) or 4000))

                avg_high_centroid = sum(high_centroids) / len(high_centroids) if high_centroids else 5000

                if ref_high_ratio > 0.25:
                    if avg_high_centroid > 7000:
                        high_end_brightness_score = 0.85 + (ref_high_ratio * 0.15)
                    elif avg_high_centroid > 5000:
                        high_end_brightness_score = 0.65
                    else:
                        high_end_brightness_score = 0.4
                        weak_roles.append('hat')
                else:
                    high_end_brightness_score = 0.7
            else:
                if ref_high_ratio > 0.3:
                    high_end_brightness_score = 0.35
                    weak_roles.append('hat')

            if kind == 'drop':
                if bass_match and ref_energy_mean > 0.6:
                    section_match_score += 0.08
                if hat_match and ref_onset_rate > 0.4:
                    section_match_score += 0.05
            elif kind == 'break':
                atmos_match = selected.get('atmos_fx')
                if atmos_match and ref_energy_mean < 0.45:
                    section_match_score += 0.10
                    low_end_presence_score = min(1.0, low_end_presence_score + 0.1)
            elif kind == 'build':
                snare_roll_match = selected.get('snare_roll')
                fill_match = selected.get('fill_fx')
                if snare_roll_match and ref_energy_slope > 0.05:
                    section_match_score += 0.08
                    rhythmic_density_score = min(1.0, rhythmic_density_score + 0.1)
                if fill_match:
                    section_match_score += 0.05
            elif kind == 'intro':
                atmos_match = selected.get('atmos_fx')
                if atmos_match:
                    section_match_score += 0.05
            elif kind == 'outro':
                atmos_match = selected.get('atmos_fx')
                if atmos_match and ref_energy_mean < 0.4:
                    section_match_score += 0.05

            energy_profile_scores.append(energy_profile_score)
            spectral_similarity_scores.append(spectral_similarity_score)
            rhythmic_density_scores.append(rhythmic_density_score)
            low_end_presence_scores.append(low_end_presence_score)
            high_end_brightness_scores.append(high_end_brightness_score)

            combined_score = (
                energy_profile_score * 0.20 +
                spectral_similarity_score * 0.20 +
                rhythmic_density_score * 0.20 +
                low_end_presence_score * 0.20 +
                high_end_brightness_score * 0.20
            )
            section_match_score = max(section_match_score, combined_score)
            section_match_score = max(0.0, min(1.0, section_match_score))

            section_scores.append({
                'kind': kind,
                'score': round(section_match_score, 3),
                'weak_roles': weak_roles,
                'energy_profile_score': round(energy_profile_score, 3),
                'spectral_similarity_score': round(spectral_similarity_score, 3),
                'rhythmic_density_score': round(rhythmic_density_score, 3),
                'low_end_presence_score': round(low_end_presence_score, 3),
                'high_end_brightness_score': round(high_end_brightness_score, 3),
            })

        overall_score = sum(s['score'] for s in section_scores) / max(len(section_scores), 1)

        avg_energy_profile = sum(energy_profile_scores) / max(len(energy_profile_scores), 1)
        avg_spectral = sum(spectral_similarity_scores) / max(len(spectral_similarity_scores), 1)
        avg_rhythmic = sum(rhythmic_density_scores) / max(len(rhythmic_density_scores), 1)
        avg_low_end = sum(low_end_presence_scores) / max(len(low_end_presence_scores), 1)
        avg_high_end = sum(high_end_brightness_scores) / max(len(high_end_brightness_scores), 1)

        improvement_hints = []
        for section_score in section_scores:
            for role in section_score.get('weak_roles', []):
                hint = f"{section_score['kind']} section needs better {role} samples"
                if hint not in improvement_hints:
                    improvement_hints.append(hint)

        if avg_energy_profile < 0.5:
            improvement_hints.append("Overall energy profile mismatch - adjust sample dynamics")
        if avg_spectral < 0.5:
            improvement_hints.append("Spectral characteristics differ - check brightness/texture match")
        if avg_rhythmic < 0.5:
            improvement_hints.append("Rhythmic density mismatch - adjust percussive element selection")
        if avg_low_end < 0.5:
            improvement_hints.append("Low-end presence weak - select bass/kick with more sub energy")
        if avg_high_end < 0.5:
            improvement_hints.append("High-end brightness lacking - select brighter hat/top samples")

        return {
            'remake_score': round(overall_score, 3),
            'section_scores': [
                {
                    'kind': s['kind'],
                    'score': s['score'],
                    'weak_roles': s['weak_roles'],
                }
                for s in section_scores
            ],
            'improvement_hints': improvement_hints[:10],
            'metric_averages': {
                'energy_similarity': round(avg_energy_profile, 3),
                'spectral_similarity': round(avg_spectral, 3),
                'rhythmic_density': round(avg_rhythmic, 3),
                'low_end_match': round(avg_low_end, 3),
                'high_end_match': round(avg_high_end, 3),
            },
            'metrics_detail': {
                'energy_similarity': {
                    'description': 'RMS energy distribution comparison between selected samples and reference section energy',
                    'range': '0.0-1.0, higher is better',
                    'weight': 0.22,
                    'factors': ['average RMS match', 'energy slope for builds', 'peak energy variance'],
                },
                'spectral_similarity': {
                    'description': 'Spectral centroid and variance matching',
                    'range': '0.0-1.0, higher is better',
                    'weight': 0.18,
                    'factors': ['centroid mean match', 'centroid variance match'],
                },
                'rhythmic_density': {
                    'description': 'Onset rate comparison between selected samples and reference',
                    'range': '0.0-1.0, higher is better',
                    'weight': 0.22,
                    'factors': ['onset rate match', 'percussive element density'],
                },
                'low_end_match': {
                    'description': 'Sub-bass and low frequency content presence matching',
                    'range': '0.0-1.0, higher is better',
                    'weight': 0.20,
                    'factors': ['bass spectral centroid', 'kick spectral centroid', 'low frequency RMS'],
                },
                'high_end_match': {
                    'description': 'High frequency brightness and air content matching',
                    'range': '0.0-1.0, higher is better',
                    'weight': 0.18,
                    'factors': ['hat spectral centroid', 'top loop brightness', 'synth high frequency content'],
                },
            },
        }


def export_segment_rag_manifest(
    manifest: List[Dict[str, Any]],
    output_path: Path,
    format: str = "json",
) -> None:
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    if format == "json":
        output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
        return

    cached = [item for item in manifest if item.get("cached")]
    built = [item for item in manifest if not item.get("cached")]
    total_cached_segments = sum(int(item.get("segments", 0) or 0) for item in cached)
    total_built_segments = sum(int(item.get("segments", 0) or 0) for item in built)

    lines = [
        "# Segment RAG Index Manifest",
        "",
        f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}",
        f"Total Files: {len(manifest)}",
        "",
        "## Summary",
        "",
        f"- Cached (reused): {len(cached)}",
        f"- Built (analyzed): {len(built)}",
        f"- Cached segments: {total_cached_segments}",
        f"- Built segments: {total_built_segments}",
    ]

    if cached:
        lines.extend(["", "## Cached Files (Reused)", ""])
        for item in sorted(cached, key=lambda value: value.get("file_name", "").lower()):
            lines.append(f"- **{item.get('file_name', 'unknown')}**")
            lines.append(f"  - Roles: {', '.join(item.get('roles', []))}")
            lines.append(f"  - Segments: {int(item.get('segments', 0) or 0)}")

    if built:
        lines.extend(["", "## Built Files (Analyzed)", ""])
        for item in sorted(built, key=lambda value: value.get("file_name", "").lower()):
            lines.append(f"- **{item.get('file_name', 'unknown')}**")
            lines.append(f"  - Roles: {', '.join(item.get('roles', []))}")
            lines.append(f"  - Segments: {int(item.get('segments', 0) or 0)}")

    output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _get_segment_rag_status(library_dir: Path) -> Dict[str, Any]:
    """
    Get status of the segment RAG cache with human-readable metadata.

    For each cache file, tries to recover metadata from:
    1. Embedded metadata in the cache file (new format)
    2. indexing_state.json lookup (backfill source)

    Returns stats about metadata coverage and human-readable names.
    """
    cache_dir = library_dir / ".segment_rag"

    if not cache_dir.exists():
        return {
            "cache_dir": str(cache_dir),
            "cache_files": 0,
            "total_segments": 0,
            "status": "not_built"
        }

    cache_files = list(cache_dir.glob("*.json.gz"))
    total_segments = 0
    role_coverage: Dict[str, int] = defaultdict(int)
    entries: List[Dict[str, Any]] = []
    state_path = cache_dir / "indexing_state.json"
    state_payload: Dict[str, Any] = {}
    if state_path.exists():
        try:
            state_payload = json.loads(state_path.read_text(encoding="utf-8"))
        except Exception:
            state_payload = {}
    indexed_entries = state_payload.get("indexed_entries", {}) or {}

    # Build lookup by cache_prefix for state entries
    by_prefix: Dict[str, Dict[str, Any]] = {}
    for entry in indexed_entries.values():
        cache_prefix = entry.get("cache_prefix")
        if cache_prefix:
            by_prefix[cache_prefix] = entry

    # Track metadata coverage
    files_with_embedded_metadata = 0
    files_with_state_metadata = 0
    files_without_metadata = 0

    for cache_file in cache_files:
        try:
            with gzip.open(cache_file, "rt", encoding="utf-8") as handle:
                payload = json.load(handle)
            if isinstance(payload, list):
                segments = payload
                metadata = {}
            elif isinstance(payload, dict):
                segments = payload.get("segments", []) or []
                metadata = payload.get("metadata", {}) or {}
            else:
                segments = []
                metadata = {}
            total_segments += len(segments)

            # Extract cache prefix from filename (format: {path_key}__{fingerprint}__{windows}__{duration}.json.gz)
            cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem
            cache_prefix = cache_stem.rsplit("__", 1)[0]

            # Look up metadata from state file
            state_entry = by_prefix.get(cache_prefix, {})

            # Determine metadata source
            has_embedded = bool(metadata)
            has_state = bool(state_entry)

            if has_embedded:
                files_with_embedded_metadata += 1
            elif has_state:
                files_with_state_metadata += 1
            else:
                files_without_metadata += 1

            # Merge metadata: prefer embedded, fallback to state
            file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_file.name
            file_path = metadata.get("path") or state_entry.get("path") or ""
            roles = metadata.get("roles") or state_entry.get("roles") or []

            # Determine if the name is human-readable (not just a hash)
            is_hash_name = len(cache_stem.split("__")[0]) == 16 and all(c in "0123456789abcdef" for c in cache_stem.split("__")[0])
            has_human_name = file_name != cache_file.name and not is_hash_name

            for role in roles:
                if role:
                    role_coverage[role] += len(segments)

            mtime = cache_file.stat().st_mtime

            entries.append({
                "file_name": file_name,
                "path": file_path,
                "segments": len(segments),
                "mtime": mtime,
                "cache_file": cache_file.name,
                "roles": roles,
                "has_embedded_metadata": has_embedded,
                "has_human_readable_name": has_human_name,
            })
        except Exception:
            logger.debug("Failed to inspect segment cache %s", cache_file, exc_info=True)

    entries.sort(key=lambda item: item["mtime"], reverse=True)

    # Calculate cache size
    cache_size_bytes = sum(f.stat().st_size for f in cache_files)
    cache_size_mb = round(cache_size_bytes / (1024 * 1024), 2)

    return {
        "cache_dir": str(cache_dir),
        "cache_files": len(cache_files),
        "total_segments": total_segments,
        "role_coverage": dict(role_coverage) if role_coverage else {},
        "newest_entries": entries[:5],
        "oldest_entries": entries[-5:] if len(entries) > 5 else [],
        "metadata_coverage": {
            "files_with_embedded_metadata": files_with_embedded_metadata,
            "files_with_state_metadata": files_with_state_metadata,
            "files_without_metadata": files_without_metadata,
        },
        "cache_size_mb": cache_size_mb,
        "indexing_complete": state_payload.get("complete", False),
        "last_indexed": state_payload.get("timestamp"),
        "status": "ok"
    }


def _backfill_segment_cache_metadata(library_dir: Path, force: bool = False) -> Dict[str, Any]:
    """
    Backfill metadata into existing segment cache files.

    For cache files that don't have embedded metadata, this function:
    1. Looks up the file in indexing_state.json
    2. Rewrites the cache file with metadata included

    Args:
        library_dir: Path to the audio library
        force: If True, rewrite all cache files even if they already have metadata

    Returns:
        Dict with backfill statistics
    """
    cache_dir = library_dir / ".segment_rag"

    if not cache_dir.exists():
        return {
            "cache_dir": str(cache_dir),
            "backfilled": 0,
            "skipped": 0,
            "errors": 0,
            "status": "no_cache"
        }

    # Load state file for metadata lookup
    state_path = cache_dir / "indexing_state.json"
    state_payload: Dict[str, Any] = {}
    if state_path.exists():
        try:
            state_payload = json.loads(state_path.read_text(encoding="utf-8"))
        except Exception:
            state_payload = {}

    indexed_entries = state_payload.get("indexed_entries", {}) or {}

    # Build lookup by cache_prefix
    by_prefix: Dict[str, Dict[str, Any]] = {}
    for entry in indexed_entries.values():
        cache_prefix = entry.get("cache_prefix")
        if cache_prefix:
            by_prefix[cache_prefix] = entry

    cache_files = list(cache_dir.glob("*.json.gz"))
    backfilled = 0
    skipped = 0
    errors = 0

    for cache_file in cache_files:
        try:
            with gzip.open(cache_file, "rt", encoding="utf-8") as handle:
                payload = json.load(handle)

            # Check if already has metadata
            if isinstance(payload, dict):
                segments = payload.get("segments", []) or []
                metadata = payload.get("metadata", {}) or {}
                if metadata and not force:
                    skipped += 1
                    continue
            elif isinstance(payload, list):
                segments = payload
                metadata = {}
            else:
                continue

            # Extract cache prefix from filename
            cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem
            cache_prefix = cache_stem.rsplit("__", 1)[0]

            # Look up metadata from state
            state_entry = by_prefix.get(cache_prefix, {})
            file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_stem
            roles = metadata.get("roles") or state_entry.get("roles") or []
            path = metadata.get("path") or state_entry.get("path") or ""

            # If we found some metadata, rewrite the cache file
            if file_name or roles or path:
                new_metadata = {
                    "file_name": file_name,
                    "path": path,
                    "roles": roles,
                    "indexed_at": time.time(),
                    "backfilled": True,
                }
                new_payload = {
                    "segments": segments,
                    "metadata": new_metadata
                }
                with gzip.open(cache_file, "wt", encoding="utf-8") as handle:
                    json.dump(new_payload, handle)
                backfilled += 1
                logger.debug("Backfilled metadata for %s", cache_file.name)
            else:
                skipped += 1

        except Exception:
            errors += 1
            logger.debug("Failed to backfill %s", cache_file, exc_info=True)

    return {
        "cache_dir": str(cache_dir),
        "cache_files": len(cache_files),
        "backfilled": backfilled,
        "skipped": skipped,
        "errors": errors,
        "status": "ok"
    }
