Skip to content

Commit 02dd861

Browse files
committed
feat: much better config management and new schema
closes #121 & closes #120
1 parent 8eb6439 commit 02dd861

File tree

3 files changed

+403
-44
lines changed

3 files changed

+403
-44
lines changed

lib/src/cli_commands.py

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2207,48 +2207,35 @@ def setup_config(backend: Optional[str] = None, model: Optional[str] = None, rem
22072207
for key, value in remote_config.items():
22082208
config.set_setting(key, value)
22092209

2210-
config.save_config()
2211-
log_success(f"Created {config_file}")
2210+
if config.save_config():
2211+
log_success(f"Created {config_file}")
2212+
else:
2213+
log_error(f"Failed to create {config_file}")
22122214
else:
22132215
log_info(f"Config already exists at {config_file}")
2214-
# Update existing config if needed
2216+
# Update existing config via ConfigManager (handles migrations, sparse save)
22152217
try:
2216-
with open(config_file, 'r', encoding='utf-8') as f:
2217-
existing_config = json.load(f)
2218-
2219-
# Update backend if provided (accept both old 'local'/'remote' and new backend types)
2218+
config = ConfigManager()
2219+
22202220
if backend:
22212221
# Map old values for backward compatibility
22222222
if backend == 'local':
2223-
backend = 'cpu' # Map old 'local' to 'cpu'
2223+
backend = 'cpu'
22242224
elif backend == 'remote':
2225-
backend = 'rest-api' # Map old 'remote' to 'rest-api'
2226-
existing_config['transcription_backend'] = backend
2227-
2228-
# Apply remote configuration if provided
2225+
backend = 'rest-api'
2226+
config.set_setting('transcription_backend', backend)
2227+
22292228
if remote_config:
22302229
for key, value in remote_config.items():
2231-
existing_config[key] = value
2232-
2233-
# Update model if provided, otherwise default to base if missing
2230+
config.set_setting(key, value)
2231+
22342232
if model:
2235-
existing_config['model'] = model
2236-
elif 'model' not in existing_config and not remote_config:
2237-
# Only set default model if not using remote backend
2238-
existing_config['model'] = 'base'
2239-
2240-
# Add audio_feedback if missing
2241-
if 'audio_feedback' not in existing_config:
2242-
existing_config['audio_feedback'] = True
2243-
existing_config['start_sound_volume'] = 1.0
2244-
existing_config['stop_sound_volume'] = 1.0
2245-
existing_config['start_sound_path'] = 'ping-up.ogg'
2246-
existing_config['stop_sound_path'] = 'ping-down.ogg'
2247-
2248-
with open(config_file, 'w', encoding='utf-8') as f:
2249-
json.dump(existing_config, f, indent=2)
2250-
2251-
log_success("Updated existing config")
2233+
config.set_setting('model', model)
2234+
2235+
if config.save_config():
2236+
log_success("Updated existing config")
2237+
else:
2238+
log_error("Failed to save updated config")
22522239
except Exception as e:
22532240
log_error(f"Failed to update config: {e}")
22542241

lib/src/config_manager.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Handles loading, saving, and managing application settings
44
"""
55

6+
import copy
67
import json
78
from pathlib import Path
89
from typing import Any, Dict
@@ -15,7 +16,9 @@
1516

1617
class 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

Comments
 (0)