# Volume Normalization Modal - Design Document ## Overview The Volume Normalization Modal is a PyQt6 dialog component that enables users to configure audio loudness normalization settings before downloading tracks. The feature integrates with the existing Download tab and provides both preset configurations and custom parameter tuning. Settings persist globally across application sessions, ensuring consistent audio processing behavior. ### Key Design Goals 1. **User-Friendly Configuration**: Provide preset options for non-technical users while allowing advanced customization 2. **Persistent Settings**: Store user preferences to avoid reconfiguration across sessions 3. **Seamless Integration**: Integrate with existing downloader and config modules without breaking changes 4. **Validation and Safety**: Ensure all parameters conform to FFmpeg loudnorm specifications 5. **Consistent UI/UX**: Match existing modal styling and application design patterns --- ## Architecture ### System Context The Volume Normalization Modal operates within a three-layer architecture: ``` ┌─────────────────────────────────────────────────────────────┐ │ UI Layer (PyQt6) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ DownloadTab │ │ │ │ ├─ Normalization Settings Button │ │ │ │ └─ Preset Indicator │ │ │ │ │ │ │ │ NormalizationModal (Dialog) │ │ │ │ ├─ Preset Selection (Radio Buttons) │ │ │ │ ├─ Parameter Input Fields (I, TP, LRA) │ │ │ │ ├─ Validation & Error Display │ │ │ │ └─ Apply/Cancel Buttons │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ Config Layer (Persistence) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Config Module (src/core/config.py) │ │ │ │ ├─ Normalization Settings Storage │ │ │ │ ├─ Preset Definitions │ │ │ │ └─ Default Configuration │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ Core Layer (Processing) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ YouTubeDownloader (src/core/downloader.py) │ │ │ │ ├─ normalize_volume() - Uses Config settings │ │ │ │ ├─ Parameter Validation │ │ │ │ └─ FFmpeg Command Construction │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### Data Flow 1. **Initialization**: Application starts → Config loads saved settings → Modal displays current preset 2. **User Interaction**: User selects preset/custom params → Modal validates → User clicks Apply 3. **Persistence**: Modal saves settings to Config → Config persists to storage 4. **Download**: Download initiated → Downloader reads settings from Config → FFmpeg applies normalization --- ## Components and Interfaces ### 1. NormalizationModal Class **File**: `src/ui/components/normalization_modal_qt.py` **Responsibilities**: - Display preset selection interface - Manage custom parameter input fields - Validate user input against FFmpeg specifications - Handle apply/cancel operations - Load and display current settings **Key Methods**: ```python class NormalizationModal(QDialog): """Modal dialog for configuring volume normalization settings.""" settings_applied = pyqtSignal(dict) # Emitted when Apply is clicked def __init__(self, parent, config): """Initialize modal with current settings from config.""" def _load_current_settings(self): """Load saved settings from Config module.""" def _on_preset_selected(self, preset_name: str): """Handle preset selection - update fields and enable/disable editing.""" def _on_parameter_changed(self, param_name: str, value: str): """Handle parameter input changes - validate and update UI.""" def _validate_parameters(self) -> tuple[bool, str]: """Validate all parameters against FFmpeg specifications.""" def _update_parameter_fields(self, params: dict): """Update input fields with parameter values.""" def _enable_custom_editing(self, enabled: bool): """Enable/disable custom parameter input fields.""" def _on_apply(self): """Save settings and close modal.""" def _on_cancel(self): """Close modal without saving.""" def get_current_settings(self) -> dict: """Return current settings as dict.""" ``` **Signals**: - `settings_applied(dict)`: Emitted when Apply is clicked with new settings ### 2. DownloadTab Integration **File**: `src/ui/tabs/download_tab_qt.py` (modifications) **Changes**: - Add "Normalization Settings" button to control panel - Add preset indicator label showing current active preset - Connect button to open NormalizationModal - Display tooltip with current parameters on hover **New Methods**: ```python def _open_normalization_modal(self): """Open the normalization settings modal.""" def _update_preset_indicator(self, preset_name: str): """Update the preset indicator label.""" def _on_normalization_settings_applied(self, settings: dict): """Handle settings applied from modal.""" ``` ### 3. Config Module Extensions **File**: `src/core/config.py` (modifications) **New Class**: `NormalizationSettings` ```python class NormalizationSettings: """Manages normalization settings persistence.""" # Preset definitions PRESETS = { 'Quiet': {'I': -18, 'TP': -1.5, 'LRA': 11}, 'Normal': {'I': -14, 'TP': -1.5, 'LRA': 11}, 'Loud': {'I': -10, 'TP': -1.5, 'LRA': 11}, 'Very Loud': {'I': -6, 'TP': -1.5, 'LRA': 11}, } # Parameter validation ranges PARAM_RANGES = { 'I': (-23, 0), # Integrated Loudness (LUFS) 'TP': (-5, 0), # True Peak (dBFS) 'LRA': (1, 20), # Loudness Range (LU) } @classmethod def load_settings(cls) -> dict: """Load normalization settings from storage.""" @classmethod def save_settings(cls, preset: str, custom_params: dict = None) -> bool: """Save normalization settings to storage.""" @classmethod def get_default_settings(cls) -> dict: """Return default settings (Normal preset).""" @classmethod def validate_parameters(cls, params: dict) -> tuple[bool, str]: """Validate parameters against ranges.""" @classmethod def get_preset_params(cls, preset_name: str) -> dict: """Get parameters for a preset.""" ``` **Storage Format** (JSON in config file): ```json { "normalization": { "current_preset": "Normal", "custom_parameters": { "I": -14, "TP": -1.5, "LRA": 11 } } } ``` ### 4. Downloader Module Integration **File**: `src/core/downloader.py` (modifications) **Changes to `normalize_volume` method**: ```python def normalize_volume(self, filepath: str, timeout: int = 60) -> bool: """Normalize audio loudness using configured settings.""" if not self.ffmpeg: return True # Load current settings from Config settings = NormalizationSettings.load_settings() params = settings.get('custom_parameters', {}) # Validate parameters valid, error_msg = NormalizationSettings.validate_parameters(params) if not valid: logger.error(f"Invalid normalization parameters: {error_msg}") return False # Construct FFmpeg filter string filter_str = f"loudnorm=I={params['I']}:TP={params['TP']}:LRA={params['LRA']}" # ... rest of implementation ``` --- ## Data Models ### Normalization Settings Structure ```python { "current_preset": str, # "Quiet", "Normal", "Loud", "Very Loud", or "Custom" "custom_parameters": { "I": float, # Integrated Loudness (-23 to 0 LUFS) "TP": float, # True Peak (-5 to 0 dBFS) "LRA": float, # Loudness Range (1 to 20 LU) } } ``` ### Preset Definitions ```python PRESETS = { "Quiet": { "I": -18, "TP": -1.5, "LRA": 11, "description": "For content that should remain quiet" }, "Normal": { "I": -14, "TP": -1.5, "LRA": 11, "description": "Default, suitable for most music" }, "Loud": { "I": -10, "TP": -1.5, "LRA": 11, "description": "For content that should be louder" }, "Very Loud": { "I": -6, "TP": -1.5, "LRA": 11, "description": "For maximum loudness" } } ``` ### Parameter Validation Ranges ```python PARAM_RANGES = { "I": { "min": -23, "max": 0, "unit": "LUFS", "description": "Integrated Loudness" }, "TP": { "min": -5, "max": 0, "unit": "dBFS", "description": "True Peak" }, "LRA": { "min": 1, "max": 20, "unit": "LU", "description": "Loudness Range" } } ``` --- ## Module Changes ### Config Module (`src/core/config.py`) **Additions**: 1. Add `NormalizationSettings` class with preset definitions and validation logic 2. Add storage path constant for normalization settings 3. Add methods to load/save normalization settings 4. Add default settings initialization **Storage Location**: Settings stored in application config directory (platform-specific): - Windows: `%APPDATA%/dl_yt/config.json` - macOS: `~/Library/Application Support/dl_yt/config.json` - Linux: `~/.config/dl_yt/config.json` ### Downloader Module (`src/core/downloader.py`) **Modifications**: 1. Update `__init__` to load normalization settings from Config 2. Modify `normalize_volume` to use configured parameters instead of hardcoded values 3. Add parameter validation before FFmpeg command construction 4. Add logging for parameter usage and validation failures **Backward Compatibility**: If no settings are configured, default to "Normal" preset (existing behavior) ### DownloadTab Module (`src/ui/tabs/download_tab_qt.py`) **Additions**: 1. Add "Normalization Settings" button to control panel 2. Add preset indicator label 3. Add method to open NormalizationModal 4. Add method to handle settings changes 5. Connect modal signals to update UI **Button Placement**: In the control panel area, positioned after "Browse Output" button --- ## UI/UX Design ### Modal Layout ``` ┌─────────────────────────────────────────────────────────┐ │ Volume Normalization Settings [X] │ ├─────────────────────────────────────────────────────────┤ │ │ │ Current Preset: Normal │ │ Parameters: I=-14 TP=-1.5 LRA=11 │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Presets: │ │ │ │ ○ Quiet (I=-18, TP=-1.5, LRA=11) │ │ │ │ ● Normal (I=-14, TP=-1.5, LRA=11) │ │ │ │ ○ Loud (I=-10, TP=-1.5, LRA=11) │ │ │ │ ○ Very Loud (I=-6, TP=-1.5, LRA=11) │ │ │ │ ○ Custom │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Custom Parameters (disabled): │ │ │ │ Integrated Loudness (I): [-14] LUFS │ │ │ │ True Peak (TP): [-1.5] dBFS │ │ │ │ Loudness Range (LRA): [11] LU │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ [Error message area - hidden when no errors] │ │ │ │ [Cancel] [Apply] │ └─────────────────────────────────────────────────────────┘ ``` ### Styling - **Background**: #212121 (dark gray, matching DeleteModal) - **Text Color**: #ffffff (white for primary, #cccccc for secondary) - **Input Fields**: QLineEdit/QDoubleSpinBox with dark theme - **Buttons**: - Cancel: Gray (#757575) - Apply: Blue (#2196F3) when enabled, gray when disabled - **Modal Size**: 500x400 pixels - **Font**: 11pt for labels, 12pt for buttons ### Error Display Error messages appear in a red-tinted area above the buttons: - Non-numeric input: "Parameter must be a number" - Out of range: "Parameter {name} must be between {min} and {max}" - Save failure: "Failed to save settings. Please try again." ### Download Tab Integration Preset indicator in control panel: ``` [Normalization Settings] | Preset: Normal (I=-14, TP=-1.5, LRA=11) ``` Tooltip on hover shows full parameter details. --- ## State Management ### Modal State Lifecycle ``` ┌─────────────────────────────────────────────────────────┐ │ CLOSED │ │ (Settings not loaded, modal not visible) │ └────────────────────┬────────────────────────────────────┘ │ User clicks "Normalization Settings" ↓ ┌─────────────────────────────────────────────────────────┐ │ OPENING │ │ (Load settings from Config, populate UI) │ └────────────────────┬────────────────────────────────────┘ │ Settings loaded ↓ ┌─────────────────────────────────────────────────────────┐ │ OPEN │ │ (Modal visible, user can interact) │ └────────┬──────────────────────────────────┬─────────────┘ │ User clicks Cancel │ User clicks Apply ↓ ↓ CLOSED VALIDATING (Discard changes) (Check parameters) │ ┌────┴────┐ │ │ Valid │ │ Invalid ↓ ↓ SAVING ERROR (Persist) (Show error) │ │ └────┬─────┘ │ CLOSED ``` ### Settings Loading 1. **Application Startup**: - Config module loads settings from storage - If missing/corrupted, use defaults - Log warning if loading fails 2. **Modal Open**: - Load current settings from Config - Display current preset as selected - Populate parameter fields - Enable/disable custom fields based on preset 3. **Download Initiation**: - Downloader reads settings from Config - Validate parameters before use - Log parameters being applied ### Settings Saving 1. **Apply Button Clicked**: - Validate all parameters - If invalid, show error and don't save - If valid, save to Config - Emit signal with new settings - Close modal 2. **Config Save**: - Write to persistent storage - Log success/failure - Notify UI of changes --- ## Integration Points ### 1. DownloadTab ↔ NormalizationModal **Connection**: ```python # In DownloadTab.__init__ self.normalization_modal = NormalizationModal(self, self.config) self.normalization_modal.settings_applied.connect(self._on_normalization_settings_applied) self.normalization_btn.clicked.connect(self._open_normalization_modal) ``` **Data Flow**: - DownloadTab passes Config instance to modal - Modal emits `settings_applied` signal when Apply clicked - DownloadTab updates preset indicator ### 2. NormalizationModal ↔ Config **Connection**: ```python # In NormalizationModal settings = NormalizationSettings.load_settings() NormalizationSettings.save_settings(preset, custom_params) ``` **Data Flow**: - Modal reads current settings on open - Modal saves settings on Apply - Config handles persistence ### 3. Downloader ↔ Config **Connection**: ```python # In YouTubeDownloader.normalize_volume settings = NormalizationSettings.load_settings() params = settings['custom_parameters'] ``` **Data Flow**: - Downloader reads settings before normalization - Downloader validates parameters - Downloader constructs FFmpeg command with parameters ### 4. Backward Compatibility **Existing Code**: - Current `normalize_volume` uses hardcoded: `loudnorm=I=-14:TP=-1.5:LRA=11` - This matches the "Normal" preset **Migration**: - If no settings exist, default to "Normal" preset - Existing behavior preserved - No breaking changes to API --- ## Correctness Properties *A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* ### Property 1: Preset Selection Updates Parameters *For any* preset selection (Quiet, Normal, Loud, Very Loud), selecting that preset shall update the parameter input fields to display the correct values for that preset. **Validates: Requirements 2.3** ### Property 2: Non-Custom Presets Disable Editing *For any* non-Custom preset selection, the parameter input fields shall be disabled (read-only) and cannot be edited by the user. **Validates: Requirements 2.4** ### Property 3: Custom Preset Enables Editing *When* the Custom preset is selected, the parameter input fields shall be enabled and editable by the user. **Validates: Requirements 2.5** ### Property 4: Modal Loads Current Settings *For any* application state with saved normalization settings, opening the NormalizationModal shall display the currently active preset as pre-selected. **Validates: Requirements 2.6** ### Property 5: Parameter Validation Enforces Ranges *For any* parameter value entered in a custom parameter field, if the value is outside the allowed range (I: -23 to 0, TP: -5 to 0, LRA: 1 to 20), an error message shall be displayed and the Apply button shall remain disabled. **Validates: Requirements 3.3, 3.4, 8.2, 8.3** ### Property 6: Cancel Discards Changes *For any* changes made in the NormalizationModal, clicking Cancel shall close the modal without saving any changes, and the previous settings shall remain active. **Validates: Requirements 1.9** ### Property 7: Apply Saves Settings *For any* valid parameter configuration in the NormalizationModal, clicking Apply shall save the settings to persistent storage and close the modal. **Validates: Requirements 1.10, 4.1** ### Property 8: Settings Persist Across Sessions *For any* normalization settings saved via Apply, when the application is restarted, the NormalizationModal shall load and display the previously saved settings. **Validates: Requirements 4.3** ### Property 9: Downloader Uses Configured Settings *For any* download initiated after normalization settings are configured, the Downloader shall use the current settings from Config to construct the FFmpeg loudnorm filter string. **Validates: Requirements 5.1, 5.2, 5.3** ### Property 10: Settings Button Always Enabled *For any* application state (downloads running or idle), the "Normalization Settings" button in the Download tab shall be enabled and clickable. **Validates: Requirements 6.4** ### Property 11: Modal Opens on Button Click *When* the user clicks the "Normalization Settings" button in the Download tab, the NormalizationModal shall open and display the current settings. **Validates: Requirements 6.3** ### Property 12: Preset Indicator Shows Current Preset *For any* active normalization preset, the Download tab shall display an indicator showing the current preset name. **Validates: Requirements 7.4** ### Property 13: Default Settings on First Run *When* the application starts for the first time with no saved normalization settings, the NormalizationModal shall display the "Normal" preset as selected with parameters I=-14, TP=-1.5, LRA=11. **Validates: Requirements 4.5, 12.1, 12.2** ### Property 14: All Presets Valid *For all* preset definitions (Quiet, Normal, Loud, Very Loud), all parameters shall conform to FFmpeg loudnorm specifications: I between -23 and 0, TP between -5 and 0, LRA between 1 and 20. **Validates: Requirements 10.1, 10.2, 10.3, 10.4** ### Property 15: Configuration Structure Correct *For any* saved normalization settings, the stored configuration shall include the keys: `current_preset` (string) and `custom_parameters` (dict with I, TP, LRA keys). **Validates: Requirements 11.2** ### Property 16: Invalid Parameters Prevent Apply *For any* invalid parameter value (non-numeric or out of range), the Apply button shall remain disabled until all parameters are corrected to valid values. **Validates: Requirements 8.1, 8.3, 8.4** ### Property 17: Downloader Validates Before Processing *When* the Downloader receives normalization parameters, the parameters shall be validated against the allowed ranges before constructing the FFmpeg command. **Validates: Requirements 10.5** ### Property 18: Failed Normalization Doesn't Stop Download *If* normalization fails during audio processing, the download process shall continue and the file shall be saved without normalization applied. **Validates: Requirements 5.5** ### Property 19: Modal Changes Don't Affect Running Downloads *When* the NormalizationModal is open and the user changes settings, any currently running downloads shall continue using the previously configured settings until the next download is initiated. **Validates: Requirements 6.6** ### Property 20: Corrupted Config Uses Defaults *If* the configuration file is corrupted or missing, the application shall use the default "Normal" preset settings and log a warning. **Validates: Requirements 11.5, 12.4** --- ## Error Handling ### Input Validation Errors | Error Condition | Message | Action | |---|---|---| | Non-numeric input | "Parameter must be a number" | Show error, disable Apply | | Value < minimum | "Parameter {name} must be between {min} and {max}" | Show error, disable Apply | | Value > maximum | "Parameter {name} must be between {min} and {max}" | Show error, disable Apply | | Empty field | "Parameter {name} is required" | Show error, disable Apply | ### Configuration Errors | Error Condition | Message | Action | |---|---|---| | Config file missing | Log warning, use defaults | Application continues | | Config file corrupted | Log warning, use defaults | Application continues | | Save failure | "Failed to save settings. Please try again." | Show error in modal | | Load failure | Log warning, use defaults | Application continues | ### FFmpeg Errors | Error Condition | Action | |---|---| | Invalid parameters | Log error, skip normalization | | FFmpeg timeout | Log error, skip normalization | | FFmpeg not found | Log error, skip normalization | | File processing error | Log error, skip normalization | ### Recovery Strategies 1. **Validation Failure**: User corrects input, error clears, Apply becomes enabled 2. **Save Failure**: User clicks Apply again, or closes and reopens modal 3. **Load Failure**: Application uses defaults, user can reconfigure 4. **FFmpeg Failure**: Download continues without normalization, user can retry --- ## Testing Strategy ### Unit Testing **Test Categories**: 1. **Modal UI Tests**: - Verify modal opens/closes correctly - Verify preset selection updates fields - Verify custom fields enable/disable appropriately - Verify buttons are positioned and styled correctly 2. **Validation Tests**: - Test numeric validation (reject non-numeric input) - Test range validation (reject out-of-range values) - Test error message display - Test Apply button enable/disable based on validation 3. **Settings Persistence Tests**: - Test saving settings to Config - Test loading settings from Config - Test default settings when no config exists - Test corrupted config handling 4. **Integration Tests**: - Test modal signal emission - Test DownloadTab receives settings changes - Test Downloader uses configured settings - Test FFmpeg command construction with parameters 5. **Edge Cases**: - Test with boundary values (min/max for each parameter) - Test with missing config file - Test with corrupted config file - Test rapid preset switching - Test modal open/close cycles ### Property-Based Testing **Property Test Configuration**: - Minimum 100 iterations per property test - Use hypothesis or similar PBT library for Python - Tag each test with: `Feature: volume-normalization-modal, Property {number}: {property_text}` **Property Tests**: 1. **Property 1: Preset Selection Updates Parameters** - Generate random preset names - Verify parameter fields match preset values - Tag: `Feature: volume-normalization-modal, Property 1: Preset Selection Updates Parameters` 2. **Property 5: Parameter Validation Enforces Ranges** - Generate random parameter values (in and out of range) - Verify validation result matches expected range - Tag: `Feature: volume-normalization-modal, Property 5: Parameter Validation Enforces Ranges` 3. **Property 7: Apply Saves Settings** - Generate random valid parameter configurations - Apply settings, verify they're saved to Config - Tag: `Feature: volume-normalization-modal, Property 7: Apply Saves Settings` 4. **Property 8: Settings Persist Across Sessions** - Generate random settings, save them - Simulate application restart - Verify settings are loaded correctly - Tag: `Feature: volume-normalization-modal, Property 8: Settings Persist Across Sessions` 5. **Property 9: Downloader Uses Configured Settings** - Generate random valid parameters - Configure settings, initiate download - Verify FFmpeg command uses correct parameters - Tag: `Feature: volume-normalization-modal, Property 9: Downloader Uses Configured Settings` 6. **Property 14: All Presets Valid** - For each preset, verify all parameters are in valid ranges - Tag: `Feature: volume-normalization-modal, Property 14: All Presets Valid` 7. **Property 15: Configuration Structure Correct** - Generate random settings, save them - Verify saved structure contains required keys - Tag: `Feature: volume-normalization-modal, Property 15: Configuration Structure Correct` ### Test Coverage Goals - **Unit Tests**: 90%+ coverage of modal and config classes - **Property Tests**: All 20 correctness properties covered - **Integration Tests**: All component interactions tested - **Edge Cases**: All boundary conditions and error scenarios tested --- ## Implementation Phases ### Phase 1: Core Infrastructure - Create NormalizationSettings class in Config module - Define preset configurations and validation ranges - Implement settings load/save functionality ### Phase 2: Modal Component - Create NormalizationModal class - Implement preset selection UI - Implement custom parameter input fields - Implement validation and error display ### Phase 3: Integration - Integrate modal with DownloadTab - Add "Normalization Settings" button - Add preset indicator - Connect signals and slots ### Phase 4: Downloader Updates - Modify normalize_volume to use Config settings - Add parameter validation - Update FFmpeg command construction ### Phase 5: Testing - Write unit tests for all components - Write property-based tests for correctness properties - Write integration tests - Test edge cases and error scenarios --- ## Deployment Considerations ### Configuration Migration For users upgrading from previous versions: 1. Check if normalization settings exist 2. If not, create with default "Normal" preset 3. Log migration event 4. No user action required ### Backward Compatibility - Existing `normalize_volume` behavior preserved - Default settings match current hardcoded values - No breaking changes to public APIs - Graceful degradation if settings unavailable ### Performance Impact - Minimal: Settings loaded once at startup - Settings lookup is O(1) dictionary access - No additional FFmpeg overhead - No UI performance impact --- ## Future Enhancements 1. **Preset Management**: Allow users to create and save custom presets 2. **Per-Track Settings**: Apply different settings to different tracks 3. **Batch Operations**: Apply settings to multiple files 4. **Settings Export/Import**: Share settings between users 5. **Advanced Visualization**: Show loudness graphs and analysis 6. **A/B Comparison**: Compare before/after audio samples