""" Audio Annotation Layout Generates a form interface for audio annotation with segmentation. Uses Peaks.js for waveform visualization and segment management. Features: - Waveform visualization (amplitude display) - Spectrogram visualization (frequency display) - optional - Region/segment selection and playback - Zoom and pan for long audio files - Two annotation modes: - Label mode: Assign labels to segments (like span annotation) - Questions mode: Answer questions about each segment (radio, multirate, etc.) """ import logging import json from typing import List, Dict, Tuple, Any from .identifier_utils import ( safe_generate_layout, escape_html_content ) logger = logging.getLogger(__name__) # Default colors for segment labels DEFAULT_COLORS = [ "#4ECDC4", # Teal "#FF6B6B", # Red "#45B7D1", # Blue "#96CEB4", # Green "#FFEAA7", # Yellow "#DDA0DD", # Plum "#95A5A6", # Gray "#F39C12", # Orange "#9B59B6", # Purple "#3498DB", # Light Blue ] # Valid annotation modes VALID_MODES = ["label", "questions", "both"] # Default spectrogram options DEFAULT_SPECTROGRAM_OPTIONS = { "fft_size": 2048, "hop_length": 512, "frequency_range": [0, 8000], "color_map": "viridis", } # Valid color maps for spectrogram VALID_COLOR_MAPS = ["viridis", "magma", "plasma", "inferno", "grayscale"] def generate_audio_annotation_layout(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]: """ Generate HTML for an audio annotation interface. Args: annotation_scheme (dict): Configuration including: - name: Schema identifier - description: Display description - mode: "label" | "questions" | "both" - labels: List of segment labels (for label/both modes) - segment_schemes: List of annotation schemes per segment (for questions/both modes) - min_segments: Minimum required segments (default: 0) - max_segments: Maximum allowed segments (default: null/unlimited) - zoom_enabled: Whether to enable zoom (default: True) - playback_rate_control: Show playback speed controls (default: False) - waveform: Whether to show waveform (default: True) - spectrogram: Whether to show spectrogram (default: False) - spectrogram_options: Spectrogram configuration (optional): - fft_size: FFT window size (default: 2048) - hop_length: Hop length between FFT windows (default: 512) - frequency_range: [min_hz, max_hz] (default: [0, 8000]) - color_map: Color mapping ("viridis", "magma", "plasma", "inferno", "grayscale") Returns: tuple: (html_string, key_bindings) html_string: Complete HTML for the audio annotation interface key_bindings: List of keyboard shortcuts Raises: ValueError: If required fields are missing or invalid """ return safe_generate_layout(annotation_scheme, _generate_audio_annotation_layout_internal) def _generate_audio_annotation_layout_internal(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]: """ Internal function to generate audio annotation layout after validation. """ schema_name = annotation_scheme.get('name', 'audio_annotation') logger.debug(f"Generating audio annotation layout for schema: {schema_name}") # Get mode (default to "label" for simplicity) mode = annotation_scheme.get('mode', 'label') if mode not in VALID_MODES: error_msg = f"Invalid mode '{mode}' in schema: {schema_name}. Must be one of: {VALID_MODES}" logger.error(error_msg) raise ValueError(error_msg) # Validate labels for label/both modes labels = [] if mode in ['label', 'both']: if 'labels' not in annotation_scheme: error_msg = f"Missing labels in schema: {schema_name} (required for mode '{mode}')" logger.error(error_msg) raise ValueError(error_msg) labels = _process_labels(annotation_scheme['labels']) # Validate segment_schemes for questions/both modes segment_schemes = [] if mode in ['questions', 'both']: if 'segment_schemes' not in annotation_scheme: error_msg = f"Missing segment_schemes in schema: {schema_name} (required for mode '{mode}')" logger.error(error_msg) raise ValueError(error_msg) segment_schemes = annotation_scheme['segment_schemes'] if not isinstance(segment_schemes, list) or not segment_schemes: error_msg = f"segment_schemes must be a non-empty list in schema: {schema_name}" logger.error(error_msg) raise ValueError(error_msg) # Get configuration options min_segments = annotation_scheme.get('min_segments', 0) max_segments = annotation_scheme.get('max_segments', None) zoom_enabled = annotation_scheme.get('zoom_enabled', True) playback_rate_control = annotation_scheme.get('playback_rate_control', False) # Waveform and spectrogram display options show_waveform = annotation_scheme.get('waveform', True) show_spectrogram = annotation_scheme.get('spectrogram', False) # Process spectrogram options with defaults spectrogram_options = _process_spectrogram_options( annotation_scheme.get('spectrogram_options', {}), schema_name ) # source_field: Links this annotation schema to a display field from instance_display source_field = annotation_scheme.get("source_field", "") # Build config object for JavaScript js_config = { "schemaName": schema_name, "mode": mode, "labels": labels, "segmentSchemes": segment_schemes, "minSegments": min_segments, "maxSegments": max_segments, "zoomEnabled": zoom_enabled, "playbackRateControl": playback_rate_control, "sourceField": source_field, "waveform": show_waveform, "spectrogram": show_spectrogram, "spectrogramOptions": spectrogram_options, } # Generate HTML html = _generate_html(annotation_scheme, js_config, schema_name, labels, mode) # Generate keybindings keybindings = _generate_keybindings(labels, mode) logger.info(f"Successfully generated audio annotation layout for {schema_name}") return html, keybindings def _process_labels(labels_config: List) -> List[Dict[str, Any]]: """ Process label configuration and assign colors. Args: labels_config: List of label configs (strings or dicts) Returns: List of processed label dicts with name, color, and optional key_value """ processed = [] for i, label in enumerate(labels_config): if isinstance(label, str): processed.append({ "name": label, "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)], }) elif isinstance(label, dict): processed.append({ "name": label.get("name", f"label_{i}"), "color": label.get("color", DEFAULT_COLORS[i % len(DEFAULT_COLORS)]), "key_value": label.get("key_value"), }) else: processed.append({ "name": str(label), "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)], }) return processed def _process_spectrogram_options(options: Dict[str, Any], schema_name: str) -> Dict[str, Any]: """ Process and validate spectrogram configuration options. Args: options: User-provided spectrogram options schema_name: Schema name for error messages Returns: Validated and merged spectrogram options with defaults """ # Start with defaults processed = dict(DEFAULT_SPECTROGRAM_OPTIONS) if not options: return processed # Validate and merge fft_size if 'fft_size' in options: fft_size = options['fft_size'] if isinstance(fft_size, int) and fft_size > 0: # Ensure power of 2 for FFT efficiency if fft_size & (fft_size - 1) == 0: processed['fft_size'] = fft_size else: logger.warning( f"fft_size {fft_size} is not a power of 2 in schema {schema_name}, " f"using default {DEFAULT_SPECTROGRAM_OPTIONS['fft_size']}" ) # Validate and merge hop_length if 'hop_length' in options: hop_length = options['hop_length'] if isinstance(hop_length, int) and hop_length > 0: processed['hop_length'] = hop_length # Validate and merge frequency_range if 'frequency_range' in options: freq_range = options['frequency_range'] if (isinstance(freq_range, (list, tuple)) and len(freq_range) == 2 and all(isinstance(f, (int, float)) for f in freq_range) and freq_range[0] < freq_range[1]): processed['frequency_range'] = list(freq_range) else: logger.warning( f"Invalid frequency_range {freq_range} in schema {schema_name}, " f"using default {DEFAULT_SPECTROGRAM_OPTIONS['frequency_range']}" ) # Validate and merge color_map if 'color_map' in options: color_map = options['color_map'] if color_map in VALID_COLOR_MAPS: processed['color_map'] = color_map else: logger.warning( f"Invalid color_map '{color_map}' in schema {schema_name}, " f"must be one of {VALID_COLOR_MAPS}. Using default '{DEFAULT_SPECTROGRAM_OPTIONS['color_map']}'" ) return processed def _generate_html( annotation_scheme: Dict[str, Any], js_config: Dict[str, Any], schema_name: str, labels: List[Dict[str, Any]], mode: str ) -> str: """ Generate the HTML for the audio annotation interface. """ escaped_name = escape_html_content(schema_name) description = escape_html_content(annotation_scheme.get('description', '')) config_json = json.dumps(js_config) # Determine display mode show_waveform = js_config.get('waveform', True) show_spectrogram = js_config.get('spectrogram', False) # Generate label buttons (for label/both modes) label_selector = "" if mode in ['label', 'both'] and labels: label_selector = _generate_label_selector(labels) # Generate playback rate control playback_rate_html = "" if js_config.get('playbackRateControl'): playback_rate_html = '''