33Handles loading, saving, and managing application settings
44"""
55
6+ import copy
67import json
78from pathlib import Path
89from typing import Any , Dict
1516
1617class ConfigManager :
1718 """Manages application configuration and settings"""
18-
19+
20+ SCHEMA_URL = "https://raw.githubusercontent.com/goodroot/hyprwhspr/main/share/config.schema.json"
21+
1922 def __init__ (self ):
2023 # Default configuration values - minimal set for hyprwhspr
2124 self .default_config = {
@@ -75,6 +78,15 @@ def __init__(self):
7578 'onnx_asr_model' : 'nemo-parakeet-tdt-0.6b-v3' , # Best balance of speed and quality for CPU (includes punctuation)
7679 'onnx_asr_quantization' : 'int8' , # INT8 quantization for CPU performance (or None for fp32)
7780 'onnx_asr_use_vad' : True , # Use VAD for long recordings (>30s)
81+ # Audio feedback settings
82+ 'audio_feedback' : False , # Play sounds on recording start/stop/error
83+ 'audio_volume' : 0.5 , # Master audio feedback volume (0.0-1.0)
84+ 'start_sound_volume' : 1.0 , # Volume multiplier for start sound
85+ 'stop_sound_volume' : 1.0 , # Volume multiplier for stop sound
86+ 'error_sound_volume' : 0.5 , # Volume multiplier for error sound
87+ 'start_sound_path' : None , # Custom path for start sound (None = built-in ping-up.ogg)
88+ 'stop_sound_path' : None , # Custom path for stop sound (None = built-in ping-down.ogg)
89+ 'error_sound_path' : None , # Custom path for error sound (None = built-in ping-error.ogg)
7890 # Visual feedback settings
7991 'mic_osd_enabled' : True , # Show microphone visualization overlay during recording
8092 'mute_detection' : True , # Enable mute detection to cancel recording when mic is muted
@@ -94,7 +106,8 @@ def __init__(self):
94106 self .config_file = CONFIG_FILE
95107
96108 # Current configuration (starts with defaults)
97- self .config = self .default_config .copy ()
109+ # Deep copy so mutable values (dicts, lists) aren't shared references
110+ self .config = copy .deepcopy (self .default_config )
98111
99112 # Ensure config directory exists
100113 self ._ensure_config_dir ()
@@ -120,34 +133,49 @@ def _load_config(self):
120133 with open (self .config_file , 'r' , encoding = 'utf-8' ) as f :
121134 loaded_config = json .load (f )
122135
136+ # Detect whether this is a new-style sparse config (has $schema)
137+ # or a legacy full config. Legacy configs need "missing key" migrations;
138+ # sparse configs omit default values intentionally.
139+ is_legacy_config = '$schema' not in loaded_config
140+
141+ # Strip $schema key so it doesn't pollute self.config
142+ loaded_config .pop ('$schema' , None )
143+
123144 # Migrate old push_to_talk config to recording_mode (before merging with defaults)
124145 # Check the original loaded_config, not self.config (which has defaults merged)
125- migration_occurred = False
146+ migrations = []
126147 if 'push_to_talk' in loaded_config and 'recording_mode' not in loaded_config :
127148 if loaded_config ['push_to_talk' ]:
128149 loaded_config ['recording_mode' ] = 'push_to_talk'
129150 else :
130151 loaded_config ['recording_mode' ] = 'toggle'
131152 # Remove old push_to_talk key from loaded config
132153 del loaded_config ['push_to_talk' ]
133- migration_occurred = True
154+ migrations . append ( "'push_to_talk' -> 'recording_mode'" )
134155
135156 # Migrate old audio_device config key to audio_device_id
136157 if 'audio_device' in loaded_config and 'audio_device_id' not in loaded_config :
137158 loaded_config ['audio_device_id' ] = loaded_config ['audio_device' ]
138159 del loaded_config ['audio_device' ]
139- migration_occurred = True
160+ migrations .append ("'audio_device' -> 'audio_device_id'" )
161+
162+ # Migrate pre-audio-feedback configs: enable audio feedback for existing users
163+ # who set up before this feature existed (previously done in setup_config).
164+ # Only for legacy configs — sparse configs omit audio_feedback intentionally.
165+ if is_legacy_config and 'audio_feedback' not in loaded_config :
166+ loaded_config ['audio_feedback' ] = True
167+ migrations .append ("enabled 'audio_feedback' for legacy config" )
140168
141169 # Merge loaded config with defaults (preserving any new default keys)
142170 self .config .update (loaded_config )
143-
171+
144172 # Attempt automatic migration of API key if needed
145173 self .migrate_api_key_to_credential_manager ()
146-
174+
147175 # Save migrated config if migration occurred
148- if migration_occurred :
176+ if migrations :
149177 self .save_config ()
150- print ("Migrated 'push_to_talk' config to 'recording_mode' " )
178+ print (f "Migrated config: { ', ' . join ( migrations ) } " )
151179
152180 print (f"Configuration loaded from { self .config_file } " )
153181 else :
@@ -160,10 +188,14 @@ def _load_config(self):
160188 print ("Using default configuration" )
161189
162190 def save_config (self ) -> bool :
163- """Save current configuration to file"""
191+ """Save current configuration to file (sparse: only non-default keys + $schema) """
164192 try :
193+ sparse = {"$schema" : self .SCHEMA_URL }
194+ for key , value in self .config .items ():
195+ if key not in self .default_config or self .default_config [key ] != value :
196+ sparse [key ] = value
165197 with open (self .config_file , 'w' , encoding = 'utf-8' ) as f :
166- json .dump (self . config , f , indent = 2 )
198+ json .dump (sparse , f , indent = 2 )
167199 print (f"Configuration saved to { self .config_file } " )
168200 return True
169201 except Exception as e :
@@ -184,7 +216,7 @@ def get_all_settings(self) -> Dict[str, Any]:
184216
185217 def reset_to_defaults (self ):
186218 """Reset configuration to default values"""
187- self .config = self .default_config . copy ( )
219+ self .config = copy . deepcopy ( self .default_config )
188220 print ("Configuration reset to defaults" )
189221
190222 def get_temp_directory (self ) -> Path :
0 commit comments