Skip to content

Commit 184653c

Browse files
author
Stephen Ancliffe
committed
Echo fix with new headphones.
1 parent c5ea5e7 commit 184653c

6 files changed

Lines changed: 145 additions & 86 deletions

File tree

assistant.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,27 @@
1616
from config_manager import load_config_and_args, get_ollama_client
1717
from voice_assistant import VoiceAssistant
1818
except ImportError as e:
19-
print(f"FATAL: Missing required Python module: {e.name}. Please ensure all dependencies are installed (e.g., via pip install -r requirements.txt).", file=sys.stderr)
19+
print(
20+
f"FATAL: Missing required Python module: {e.name}. Please ensure all dependencies are installed (e.g., via pip install -r requirements.txt).",
21+
file=sys.stderr,
22+
)
2023
sys.exit(1)
2124
# --- END IMPROVEMENT ---
2225

26+
2327
def setup_logging():
2428
"""Configures the logging format and level."""
25-
log_format = '%(levelname)s %(asctime)s - %(message)s'
29+
log_format = "%(levelname)s %(asctime)s - %(message)s"
2630
logging.basicConfig(
2731
level=logging.INFO,
2832
format=log_format,
29-
handlers=[
30-
logging.StreamHandler(sys.stdout)
31-
]
33+
handlers=[logging.StreamHandler(sys.stdout)],
3234
)
3335

36+
3437
def main() -> None:
3538
"""The entry point for the assistant application."""
36-
39+
3740
# Initialize logging first to catch all subsequent errors
3841
try:
3942
setup_logging()
@@ -45,20 +48,22 @@ def main() -> None:
4548
assistant: VoiceAssistant | None = None
4649
try:
4750
args, _, should_exit = load_config_and_args()
48-
51+
4952
# Handle device listing exit flag
5053
if should_exit:
5154
# config_manager has already printed the device list.
5255
sys.exit(0)
5356

5457
# Get the Ollama client *once* and pass it to the assistant.
5558
ollama_client = get_ollama_client(args.ollama_host)
56-
59+
5760
if ollama_client is None:
58-
logging.warning("Ollama server not reachable. Assistant will run but cannot respond.")
61+
logging.warning(
62+
"Ollama server not reachable. Assistant will run but cannot respond."
63+
)
5964

6065
assistant = VoiceAssistant(args, ollama_client)
61-
66+
6267
assistant.run()
6368

6469
except IOError as e:
@@ -75,9 +80,10 @@ def main() -> None:
7580
if assistant:
7681
assistant.cleanup()
7782

83+
7884
if __name__ == "__main__":
7985
try:
8086
main()
8187
except SystemExit:
8288
# This allows --list-devices and --list-output-devices to exit cleanly
83-
pass
89+
pass

audio_utils.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,35 @@
88
RATE: int = 16000 # 16kHz sample rate (for VAD and Whisper)
99
CHUNK_DURATION_MS: int = 30 # 30ms chunks for VAD
1010
CHUNK_SIZE: int = int(RATE * CHUNK_DURATION_MS / 1000) # 480 frames
11-
INT16_MAX: float = 32768.0 # Renamed: Normalization factor for int16
11+
INT16_MAX: float = 32768.0 # Normalization factor for int16
1212
SENTENCE_END_PUNCTUATION: list[str] = ['.', '?', '!', '\n']
1313
MAX_TTS_ERRORS: int = 5
1414
MAX_HISTORY_MESSAGES: int = 20
1515

16+
# FIX #2: Configurable audio buffer size with larger default (200 instead of 100)
17+
DEFAULT_AUDIO_BUFFER_SIZE: int = 200
18+
1619
# --- 2. Centralized Configuration Defaults ---
1720
DEFAULT_SETTINGS: dict[str, Any] = {
1821
'ollama_model': 'llama3',
1922
'whisper_model': 'base.en',
20-
'wakeword_model_path': 'hey_glados.onnx',
23+
'wakeword_model_path': 'models/hey_jarvis_v2.onnx',
2124
'piper_model_path': 'models/en_US-lessac-medium.onnx',
2225
'ollama_host': 'http://localhost:11434',
23-
'wakeword': 'hey glados',
26+
'wakeword': 'hey jarvis',
2427
'wakeword_threshold': 0.35,
2528
'vad_aggressiveness': 2,
2629
'silence_seconds': 0.3,
2730
'listen_timeout': 4.0,
2831
'pre_buffer_ms': 400,
29-
'system_prompt': 'You are a friendly, concise, and intelligent voice assistant named GLaDOS. Keep your responses short and witty.',
32+
'system_prompt': 'You are a friendly, concise, and intelligent voice assistant named Jarvis. Keep your responses short and witty.',
3033
'device_index': None,
3134
'piper_output_device_index': None,
3235
'max_words_per_command': 60,
3336
'whisper_device': 'cpu',
3437
'whisper_compute_type': 'int8',
3538
'max_history_tokens': 2048,
39+
'audio_buffer_size': DEFAULT_AUDIO_BUFFER_SIZE, # FIX #2: Added buffer size config
3640
}
3741

3842
# --- 3. Audio Helpers (Updated for sounddevice) ---

config.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ ollama_host = http://localhost:11434
99
wakeword = hey jarvis
1010
wakeword_threshold = 0.20
1111
vad_aggressiveness = 2
12-
silence_seconds = 0.7
12+
silence_seconds = 0.5
1313
listen_timeout = 5.0
1414
pre_buffer_ms = 400
1515
system_prompt = You are a witty, intelligent voice assistant. Your brainpower is vast, but your output channel is tiny. All responses must be highly compressed: just one or two clever, concise sentences. Be witty, be accurate, be brief. No filler.
1616
device_index = None
1717
piper_output_device_index = None
18-
max_words_per_command = 65
18+
max_words_per_command = 80
1919
whisper_device = cpu
2020
whisper_compute_type = int8
2121
max_history_tokens = 2048

config_manager.py

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
config_manager.py
55
66
Handles loading configuration from config.ini and command-line arguments.
7+
8+
FIXES APPLIED:
9+
- Added path sanitization for security (High Priority #16)
10+
- Improved file path validation
11+
- Added system prompt file size limit
712
"""
813

914
import configparser
@@ -12,7 +17,7 @@
1217
import sys
1318
import os
1419
import ollama
15-
from typing import Any, Tuple, Optional, Literal # Literal imported but only used for type checking clarity
20+
from typing import Any, Tuple, Optional
1621

1722
# Import defaults and helpers from audio_utils
1823
try:
@@ -27,6 +32,63 @@
2732

2833
CONFIG_FILE_NAME = 'config.ini'
2934

35+
# FIX #16: Security constants for path validation
36+
MAX_SYSTEM_PROMPT_FILE_SIZE = 10 * 1024 # 10KB limit for system prompt files
37+
ALLOWED_MODEL_DIRECTORIES = ['models', 'Models', './models', './Models']
38+
39+
def sanitize_file_path(file_path: str, description: str = "file") -> str:
40+
"""
41+
FIX #16: Sanitizes and validates file paths to prevent path traversal attacks.
42+
43+
Args:
44+
file_path: The path to sanitize
45+
description: Description of the file for error messages
46+
47+
Returns:
48+
Absolute path if valid
49+
50+
Raises:
51+
ValueError: If path is invalid or potentially malicious
52+
"""
53+
if not file_path:
54+
raise ValueError(f"Empty {description} path provided")
55+
56+
# Get absolute path and normalize
57+
abs_path = os.path.abspath(file_path)
58+
59+
# Check for path traversal attempts
60+
if '..' in file_path:
61+
logging.warning(f"Path traversal detected in {description} path: {file_path}")
62+
raise ValueError(f"Invalid {description} path: path traversal not allowed")
63+
64+
# For model files, ensure they're in allowed directories
65+
if 'model' in description.lower():
66+
path_valid = False
67+
current_dir = os.path.abspath('.')
68+
69+
for allowed_dir in ALLOWED_MODEL_DIRECTORIES:
70+
allowed_abs = os.path.abspath(allowed_dir)
71+
try:
72+
# Check if the file is within an allowed directory
73+
os.path.commonpath([allowed_abs, abs_path])
74+
if abs_path.startswith(allowed_abs):
75+
path_valid = True
76+
break
77+
except ValueError:
78+
# Paths are on different drives (Windows) or not related
79+
continue
80+
81+
if not path_valid:
82+
# Allow absolute paths that exist (for custom installations)
83+
if os.path.exists(abs_path):
84+
logging.warning(f"{description} path outside standard directories: {abs_path}")
85+
path_valid = True
86+
87+
if not path_valid:
88+
raise ValueError(f"Invalid {description} path: must be in models/ directory or provide absolute path")
89+
90+
return abs_path
91+
3092
def get_ollama_client(ollama_host: str) -> Optional[ollama.Client]:
3193
"""
3294
Tries to connect to the Ollama server and returns a client instance.
@@ -40,9 +102,7 @@ def get_ollama_client(ollama_host: str) -> Optional[ollama.Client]:
40102
return client
41103
except Exception as e:
42104
logging.error(f"Failed to connect to Ollama at {ollama_host}: {e}")
43-
# --- FIX: Changed advisory log level from error to warning ---
44105
logging.warning("Please ensure Ollama is running and the 'ollama_host' in config.ini is correct.")
45-
# --- END FIX ---
46106
return None
47107

48108
# Define a custom type converter for device indices that handles 'none'
@@ -55,9 +115,7 @@ def device_index_type(value: str) -> Optional[int]:
55115
except ValueError:
56116
raise argparse.ArgumentTypeError(f"Invalid device index: '{value}'. Must be an integer or 'none'.")
57117

58-
# --- IMPROVEMENT: Changed return type hint from Literal[True, False] to bool ---
59118
def load_config_and_args() -> Tuple[argparse.Namespace, configparser.ConfigParser, bool]:
60-
# --- END IMPROVEMENT ---
61119
"""
62120
Loads settings from config.ini, parses command-line arguments,
63121
and sets up logging.
@@ -72,15 +130,13 @@ def load_config_and_args() -> Tuple[argparse.Namespace, configparser.ConfigParse
72130
logging.info(f"Loaded configuration from {CONFIG_FILE_NAME}")
73131
config_loaded = True
74132
else:
75-
# Logging setup is now expected to be done in assistant.py
76133
logging.warning(f"{CONFIG_FILE_NAME} not found. Using default settings and CLI args.")
77134

78135
config_models = config['Models'] if 'Models' in config else {}
79136
config_func = config['Functionality'] if 'Functionality' in config else {}
80137

81138
def get_config_val(section: configparser.SectionProxy, key: str, default: Any, type_converter: type) -> Any:
82139
"""Helper to get and convert config values, handling 'none' string and missing config file."""
83-
# Use config_loaded flag to prevent logging warnings when config is totally missing
84140
if not config_loaded:
85141
return default
86142

@@ -96,7 +152,6 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
96152

97153
return type_converter(val)
98154
except (ValueError, configparser.NoOptionError):
99-
# Only warn if config was loaded but had an invalid value
100155
logging.warning(f"Invalid value '{val}' for '{key}' in config.ini. Using default: {default}")
101156
return default
102157

@@ -129,6 +184,7 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
129184
func_group.add_argument('--whisper-device', type=str, default=DEFAULT_SETTINGS['whisper_device'], help="Device for Whisper (e.g., 'cpu', 'cuda').")
130185
func_group.add_argument('--whisper-compute-type', type=str, default=DEFAULT_SETTINGS['whisper_compute_type'], help="Compute type for Whisper (e.g., 'int8', 'float16').")
131186
func_group.add_argument('--max-history-tokens', type=int, default=DEFAULT_SETTINGS['max_history_tokens'], help="Maximum token context for chat history.")
187+
func_group.add_argument('--audio-buffer-size', type=int, default=DEFAULT_SETTINGS['audio_buffer_size'], help="Size of the audio buffer queue (default: 200).")
132188

133189
# Apply configuration defaults
134190
parser.set_defaults(
@@ -155,7 +211,8 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
155211
max_words_per_command=get_config_val(config_func, 'max_words_per_command', DEFAULT_SETTINGS['max_words_per_command'], int),
156212
whisper_device=get_config_val(config_func, 'whisper_device', DEFAULT_SETTINGS['whisper_device'], str),
157213
whisper_compute_type=get_config_val(config_func, 'whisper_compute_type', DEFAULT_SETTINGS['whisper_compute_type'], str),
158-
max_history_tokens=get_config_val(config_func, 'max_history_tokens', DEFAULT_SETTINGS['max_history_tokens'], int)
214+
max_history_tokens=get_config_val(config_func, 'max_history_tokens', DEFAULT_SETTINGS['max_history_tokens'], int),
215+
audio_buffer_size=get_config_val(config_func, 'audio_buffer_size', DEFAULT_SETTINGS['audio_buffer_size'], int)
159216
)
160217

161218
args = parser.parse_args()
@@ -165,25 +222,46 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
165222
logging.getLogger().setLevel(logging.DEBUG)
166223
logging.debug("DEBUG logging enabled.")
167224

168-
# Check if system_prompt is a file path
225+
# FIX #16: Sanitize model file paths
226+
try:
227+
args.wakeword_model_path = sanitize_file_path(args.wakeword_model_path, "wakeword model")
228+
args.piper_model_path = sanitize_file_path(args.piper_model_path, "Piper TTS model")
229+
except ValueError as e:
230+
logging.critical(f"Security error: {e}")
231+
logging.critical("Please check your model paths in config.ini or command-line arguments.")
232+
sys.exit(1)
233+
234+
# FIX #16: Check if system_prompt is a file path with validation
169235
if args.system_prompt and os.path.isfile(args.system_prompt):
170236
logging.info(f"Loading system prompt from file: {args.system_prompt}")
171-
file_content = None
237+
172238
try:
173-
with open(args.system_prompt, 'r', encoding='utf-8') as f:
239+
# Sanitize the path
240+
prompt_file_path = sanitize_file_path(args.system_prompt, "system prompt file")
241+
242+
# FIX #16: Check file size before reading
243+
file_size = os.path.getsize(prompt_file_path)
244+
if file_size > MAX_SYSTEM_PROMPT_FILE_SIZE:
245+
raise ValueError(f"System prompt file too large ({file_size} bytes). Maximum allowed: {MAX_SYSTEM_PROMPT_FILE_SIZE} bytes")
246+
247+
with open(prompt_file_path, 'r', encoding='utf-8') as f:
174248
file_content = f.read().strip()
175249

176-
# --- IMPROVEMENT: Explicit fallback if file is empty ---
177250
if file_content:
178251
args.system_prompt = file_content
252+
logging.info(f"Loaded system prompt ({len(file_content)} characters) from file.")
179253
else:
180254
logging.warning(f"System prompt file '{args.system_prompt}' is empty. Using default.")
181255
args.system_prompt = DEFAULT_SETTINGS['system_prompt']
256+
257+
except ValueError as e:
258+
logging.error(f"Security error with system prompt file: {e}")
259+
logging.warning("Using the default system prompt instead.")
260+
args.system_prompt = DEFAULT_SETTINGS['system_prompt']
182261
except Exception as e:
183262
logging.error(f"Failed to read system prompt file '{args.system_prompt}': {e}")
184263
logging.warning("Using the default system prompt instead.")
185264
args.system_prompt = DEFAULT_SETTINGS['system_prompt']
186-
# --- END IMPROVEMENT ---
187265

188266
# Device listing logic
189267
if args.list_devices or args.list_output_devices:

requirements.txt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
numpy
2-
ollama
1+
numpy>=1.24.0
2+
ollama>=0.1.6
33
openwakeword
4-
webrtcvad
5-
faster-whisper
6-
sounddevice
7-
piper-tts
4+
webrtcvad>=2.0.10
5+
faster-whisper>=1.0.0
6+
sounddevice>=0.4.6
7+
piper-tts>=1.2.0

0 commit comments

Comments
 (0)