abstractvoice 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abstractvoice/__init__.py +33 -0
- abstractvoice/__main__.py +119 -0
- abstractvoice/examples/__init__.py +1 -0
- abstractvoice/examples/cli_repl.py +861 -0
- abstractvoice/examples/voice_cli.py +85 -0
- abstractvoice/examples/web_api.py +214 -0
- abstractvoice/recognition.py +252 -0
- abstractvoice/stt/__init__.py +5 -0
- abstractvoice/stt/transcriber.py +138 -0
- abstractvoice/tts/__init__.py +5 -0
- abstractvoice/tts/tts_engine.py +931 -0
- abstractvoice/vad/__init__.py +5 -0
- abstractvoice/vad/voice_detector.py +75 -0
- abstractvoice/voice_manager.py +294 -0
- abstractvoice-0.1.0.dist-info/METADATA +1132 -0
- abstractvoice-0.1.0.dist-info/RECORD +20 -0
- abstractvoice-0.1.0.dist-info/WHEEL +5 -0
- abstractvoice-0.1.0.dist-info/entry_points.txt +3 -0
- abstractvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractvoice-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLI example using AbstractVoice with a text-generation API.
|
|
4
|
+
|
|
5
|
+
This example shows how to use AbstractVoice to create a CLI application
|
|
6
|
+
that interacts with an LLM API for text generation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import cmd
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import requests
|
|
15
|
+
from abstractvoice import VoiceManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ANSI color codes
|
|
19
|
+
class Colors:
|
|
20
|
+
BLUE = "\033[94m"
|
|
21
|
+
CYAN = "\033[96m"
|
|
22
|
+
GREEN = "\033[92m"
|
|
23
|
+
YELLOW = "\033[93m"
|
|
24
|
+
RED = "\033[91m"
|
|
25
|
+
BOLD = "\033[1m"
|
|
26
|
+
UNDERLINE = "\033[4m"
|
|
27
|
+
END = "\033[0m"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VoiceREPL(cmd.Cmd):
|
|
31
|
+
"""Voice-enabled REPL for LLM interaction."""
|
|
32
|
+
|
|
33
|
+
intro = "" # Will be set in __init__ to include help
|
|
34
|
+
prompt = f"{Colors.GREEN}> {Colors.END}"
|
|
35
|
+
|
|
36
|
+
# Override cmd module settings
|
|
37
|
+
ruler = "" # No horizontal rule line
|
|
38
|
+
use_rawinput = True
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_url="http://localhost:11434/api/chat",
|
|
41
|
+
model="granite3.3:2b", debug_mode=False):
|
|
42
|
+
super().__init__()
|
|
43
|
+
|
|
44
|
+
# Debug mode
|
|
45
|
+
self.debug_mode = debug_mode
|
|
46
|
+
|
|
47
|
+
# API settings
|
|
48
|
+
self.api_url = api_url
|
|
49
|
+
self.model = model
|
|
50
|
+
self.temperature = 0.4
|
|
51
|
+
self.max_tokens = 4096
|
|
52
|
+
|
|
53
|
+
# Initialize voice manager
|
|
54
|
+
self.voice_manager = VoiceManager(debug_mode=debug_mode)
|
|
55
|
+
|
|
56
|
+
# Settings
|
|
57
|
+
self.use_tts = True
|
|
58
|
+
self.voice_mode = "off" # off, full, wait, stop, ptt
|
|
59
|
+
self.voice_mode_active = False # Is voice recognition running?
|
|
60
|
+
|
|
61
|
+
# System prompt
|
|
62
|
+
self.system_prompt = """
|
|
63
|
+
You are a Helpful Voice Assistant. By design, your answers are short and more conversational, unless specifically asked to detail something.
|
|
64
|
+
You only speak, so never use any text formatting or markdown. Write for a speaker.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# Message history
|
|
68
|
+
self.messages = [{"role": "system", "content": self.system_prompt}]
|
|
69
|
+
|
|
70
|
+
# Token counting
|
|
71
|
+
self.system_tokens = 0
|
|
72
|
+
self.user_tokens = 0
|
|
73
|
+
self.assistant_tokens = 0
|
|
74
|
+
self._count_system_tokens()
|
|
75
|
+
|
|
76
|
+
if self.debug_mode:
|
|
77
|
+
print(f"Initialized with API URL: {api_url}")
|
|
78
|
+
print(f"Using model: {model}")
|
|
79
|
+
|
|
80
|
+
# Set intro with help information
|
|
81
|
+
self.intro = self._get_intro()
|
|
82
|
+
|
|
83
|
+
def _get_intro(self):
|
|
84
|
+
"""Generate intro message with help."""
|
|
85
|
+
intro = f"\n{Colors.BOLD}Welcome to AbstractVoice CLI REPL{Colors.END}\n"
|
|
86
|
+
intro += f"API: {self.api_url} | Model: {self.model}\n"
|
|
87
|
+
intro += f"\n{Colors.CYAN}Quick Start:{Colors.END}\n"
|
|
88
|
+
intro += " • Type messages to chat with the LLM\n"
|
|
89
|
+
intro += " • Use /voice <mode> to enable voice input\n"
|
|
90
|
+
intro += " • Type /help for full command list\n"
|
|
91
|
+
intro += " • Type /exit or /q to quit\n"
|
|
92
|
+
return intro
|
|
93
|
+
|
|
94
|
+
def _count_system_tokens(self):
|
|
95
|
+
"""Count tokens in the system prompt."""
|
|
96
|
+
self._count_tokens(self.system_prompt, "system")
|
|
97
|
+
|
|
98
|
+
def parseline(self, line):
|
|
99
|
+
"""Parse the line to extract command and arguments.
|
|
100
|
+
|
|
101
|
+
Override to handle / prefix for commands. This ensures /voice, /help, etc.
|
|
102
|
+
are recognized as commands by stripping the leading / before parsing.
|
|
103
|
+
"""
|
|
104
|
+
line = line.strip()
|
|
105
|
+
|
|
106
|
+
# If line starts with /, remove it for command processing
|
|
107
|
+
if line.startswith('/'):
|
|
108
|
+
line = line[1:].strip()
|
|
109
|
+
|
|
110
|
+
# Call parent parseline to do the actual parsing
|
|
111
|
+
return super().parseline(line)
|
|
112
|
+
|
|
113
|
+
def default(self, line):
|
|
114
|
+
"""Handle regular text input.
|
|
115
|
+
|
|
116
|
+
Only 'stop' is recognized as a command without /
|
|
117
|
+
All other commands MUST use / prefix.
|
|
118
|
+
"""
|
|
119
|
+
# Skip empty lines
|
|
120
|
+
if not line.strip():
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# ONLY 'stop' is recognized without / (for voice mode convenience)
|
|
124
|
+
if line.strip().lower() == "stop":
|
|
125
|
+
return self.do_stop("")
|
|
126
|
+
|
|
127
|
+
# Check if in voice mode - don't send to LLM
|
|
128
|
+
if self.voice_mode_active:
|
|
129
|
+
if self.debug_mode:
|
|
130
|
+
print(f"Voice mode active ({self.voice_mode}). Use /voice off or say 'stop' to exit.")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Everything else goes to LLM
|
|
134
|
+
self.process_query(line.strip())
|
|
135
|
+
|
|
136
|
+
def process_query(self, query):
|
|
137
|
+
"""Process a query and get a response from the LLM."""
|
|
138
|
+
if not query:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Count user message tokens
|
|
142
|
+
self._count_tokens(query, "user")
|
|
143
|
+
|
|
144
|
+
# Create the message
|
|
145
|
+
user_message = {"role": "user", "content": query}
|
|
146
|
+
self.messages.append(user_message)
|
|
147
|
+
|
|
148
|
+
if self.debug_mode:
|
|
149
|
+
print(f"Sending request to API: {self.api_url}")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Structure the payload with system prompt outside the messages array
|
|
153
|
+
payload = {
|
|
154
|
+
"model": self.model,
|
|
155
|
+
"messages": self.messages,
|
|
156
|
+
"stream": False, # Disable streaming for simplicity
|
|
157
|
+
"temperature": self.temperature,
|
|
158
|
+
"max_tokens": self.max_tokens
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Make API request
|
|
162
|
+
response = requests.post(self.api_url, json=payload)
|
|
163
|
+
response.raise_for_status()
|
|
164
|
+
|
|
165
|
+
# Try to parse response
|
|
166
|
+
try:
|
|
167
|
+
# First, try to parse as JSON
|
|
168
|
+
response_data = response.json()
|
|
169
|
+
|
|
170
|
+
# Check for different API formats
|
|
171
|
+
if "message" in response_data and "content" in response_data["message"]:
|
|
172
|
+
# Ollama format
|
|
173
|
+
response_text = response_data["message"]["content"].strip()
|
|
174
|
+
elif "choices" in response_data and len(response_data["choices"]) > 0:
|
|
175
|
+
# OpenAI format
|
|
176
|
+
response_text = response_data["choices"][0]["message"]["content"].strip()
|
|
177
|
+
else:
|
|
178
|
+
# Some other format
|
|
179
|
+
response_text = str(response_data).strip()
|
|
180
|
+
|
|
181
|
+
except Exception as e:
|
|
182
|
+
if self.debug_mode:
|
|
183
|
+
print(f"Error parsing JSON response: {e}")
|
|
184
|
+
|
|
185
|
+
# Handle streaming or non-JSON response
|
|
186
|
+
response_text = response.text.strip()
|
|
187
|
+
|
|
188
|
+
# Try to extract content from streaming format if possible
|
|
189
|
+
if response_text.startswith("{") and "content" in response_text:
|
|
190
|
+
try:
|
|
191
|
+
# Extract the last message if multiple streaming chunks
|
|
192
|
+
lines = response_text.strip().split("\n")
|
|
193
|
+
last_complete_line = lines[-1]
|
|
194
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
195
|
+
if '"done":true' in lines[i]:
|
|
196
|
+
last_complete_line = lines[i]
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
# Parse the message content
|
|
200
|
+
import json
|
|
201
|
+
data = json.loads(last_complete_line)
|
|
202
|
+
if "message" in data and "content" in data["message"]:
|
|
203
|
+
full_content = ""
|
|
204
|
+
for line in lines:
|
|
205
|
+
try:
|
|
206
|
+
chunk = json.loads(line)
|
|
207
|
+
if "message" in chunk and "content" in chunk["message"]:
|
|
208
|
+
full_content += chunk["message"]["content"]
|
|
209
|
+
except:
|
|
210
|
+
pass
|
|
211
|
+
response_text = full_content.strip()
|
|
212
|
+
except Exception as e:
|
|
213
|
+
if self.debug_mode:
|
|
214
|
+
print(f"Error extracting content from streaming response: {e}")
|
|
215
|
+
|
|
216
|
+
# Count assistant message tokens
|
|
217
|
+
self._count_tokens(response_text, "assistant")
|
|
218
|
+
|
|
219
|
+
# Add to message history
|
|
220
|
+
self.messages.append({"role": "assistant", "content": response_text})
|
|
221
|
+
|
|
222
|
+
# Display the response with color
|
|
223
|
+
print(f"{Colors.CYAN}{response_text}{Colors.END}")
|
|
224
|
+
|
|
225
|
+
# Speak the response if voice manager is available
|
|
226
|
+
if self.voice_manager:
|
|
227
|
+
self.voice_manager.speak(response_text)
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
print(f"Error: {e}")
|
|
231
|
+
if self.debug_mode:
|
|
232
|
+
import traceback
|
|
233
|
+
traceback.print_exc()
|
|
234
|
+
|
|
235
|
+
def _count_tokens(self, text, role):
|
|
236
|
+
"""Count tokens in text."""
|
|
237
|
+
try:
|
|
238
|
+
import tiktoken
|
|
239
|
+
|
|
240
|
+
# Initialize the tokenizer
|
|
241
|
+
encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
|
|
242
|
+
|
|
243
|
+
# Count tokens
|
|
244
|
+
token_count = len(encoding.encode(text))
|
|
245
|
+
|
|
246
|
+
# Update the token counts based on role
|
|
247
|
+
if role == "system":
|
|
248
|
+
self.system_tokens = token_count
|
|
249
|
+
elif role == "user":
|
|
250
|
+
self.user_tokens += token_count
|
|
251
|
+
elif role == "assistant":
|
|
252
|
+
self.assistant_tokens += token_count
|
|
253
|
+
|
|
254
|
+
# Calculate total tokens
|
|
255
|
+
total_tokens = self.system_tokens + self.user_tokens + self.assistant_tokens
|
|
256
|
+
|
|
257
|
+
if self.debug_mode:
|
|
258
|
+
print(f"{role.capitalize()} tokens: {token_count}")
|
|
259
|
+
print(f"Total tokens: {total_tokens}")
|
|
260
|
+
|
|
261
|
+
except ImportError:
|
|
262
|
+
# If tiktoken is not available, just don't count tokens
|
|
263
|
+
pass
|
|
264
|
+
except Exception as e:
|
|
265
|
+
if self.debug_mode:
|
|
266
|
+
print(f"Error counting tokens: {e}")
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
def _clean_response(self, text):
|
|
270
|
+
"""Clean LLM response text."""
|
|
271
|
+
patterns = [
|
|
272
|
+
r"user:.*", r"<\|user\|>.*",
|
|
273
|
+
r"assistant:.*", r"<\|assistant\|>.*",
|
|
274
|
+
r"<\|end\|>.*"
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
for pattern in patterns:
|
|
278
|
+
text = re.sub(pattern, "", text, flags=re.DOTALL)
|
|
279
|
+
|
|
280
|
+
return text.strip()
|
|
281
|
+
|
|
282
|
+
def do_voice(self, arg):
|
|
283
|
+
"""Control voice input mode.
|
|
284
|
+
|
|
285
|
+
Modes:
|
|
286
|
+
off - Disable voice input
|
|
287
|
+
full - Continuous listening, interrupts TTS on speech detection
|
|
288
|
+
wait - Pause listening while TTS is speaking (recommended)
|
|
289
|
+
stop - Only stops TTS on 'stop' keyword (planned)
|
|
290
|
+
ptt - Push-to-talk mode (planned)
|
|
291
|
+
"""
|
|
292
|
+
arg = arg.lower().strip()
|
|
293
|
+
|
|
294
|
+
# Handle legacy "on" argument
|
|
295
|
+
if arg == "on":
|
|
296
|
+
arg = "wait"
|
|
297
|
+
|
|
298
|
+
if arg in ["off", "full", "wait", "stop", "ptt"]:
|
|
299
|
+
# If switching from one mode to another, stop current mode first
|
|
300
|
+
if self.voice_mode_active and arg != "off":
|
|
301
|
+
self._voice_stop_callback()
|
|
302
|
+
|
|
303
|
+
self.voice_mode = arg
|
|
304
|
+
self.voice_manager.set_voice_mode(arg)
|
|
305
|
+
|
|
306
|
+
if arg == "off":
|
|
307
|
+
if self.voice_mode_active:
|
|
308
|
+
self._voice_stop_callback()
|
|
309
|
+
else:
|
|
310
|
+
# Start voice recognition for non-off modes
|
|
311
|
+
self.voice_mode_active = True
|
|
312
|
+
|
|
313
|
+
# Start listening with callbacks
|
|
314
|
+
self.voice_manager.listen(
|
|
315
|
+
on_transcription=self._voice_callback,
|
|
316
|
+
on_stop=lambda: self._voice_stop_callback()
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Print mode-specific instructions
|
|
320
|
+
if arg == "full":
|
|
321
|
+
print("Voice mode: FULL - Continuous listening, interrupts TTS on speech.")
|
|
322
|
+
print("Say 'stop' to exit.")
|
|
323
|
+
elif arg == "wait":
|
|
324
|
+
print("Voice mode: WAIT - Pauses listening while speaking (recommended).")
|
|
325
|
+
print("Say 'stop' to exit.")
|
|
326
|
+
elif arg == "stop":
|
|
327
|
+
print("Voice mode: STOP (Planned) - Only stops TTS on 'stop' keyword.")
|
|
328
|
+
print("Currently same as WAIT mode.")
|
|
329
|
+
elif arg == "ptt":
|
|
330
|
+
print("Voice mode: PTT (Planned) - Push-to-talk functionality.")
|
|
331
|
+
print("Currently same as WAIT mode.")
|
|
332
|
+
else:
|
|
333
|
+
print("Usage: /voice off | full | wait | stop | ptt")
|
|
334
|
+
print(" off - Disable voice input")
|
|
335
|
+
print(" full - Continuous listening, interrupts TTS on speech")
|
|
336
|
+
print(" wait - Pause listening while speaking (recommended)")
|
|
337
|
+
print(" stop - Only stop TTS on 'stop' keyword (planned)")
|
|
338
|
+
print(" ptt - Push-to-talk mode (planned)")
|
|
339
|
+
|
|
340
|
+
def _voice_callback(self, text):
|
|
341
|
+
"""Callback for voice recognition."""
|
|
342
|
+
# Print what the user said
|
|
343
|
+
print(f"\n> {text}")
|
|
344
|
+
|
|
345
|
+
# Check if the user said 'stop' to exit voice mode
|
|
346
|
+
if text.lower() == "stop":
|
|
347
|
+
self._voice_stop_callback()
|
|
348
|
+
# Don't process "stop" as a query
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
# Mode-specific handling
|
|
352
|
+
if self.voice_mode == "stop":
|
|
353
|
+
# In 'stop' mode, don't interrupt TTS - just queue the message
|
|
354
|
+
# But since we're in callback, TTS interrupt is already paused
|
|
355
|
+
pass
|
|
356
|
+
elif self.voice_mode == "ptt":
|
|
357
|
+
# In PTT mode, process immediately
|
|
358
|
+
pass
|
|
359
|
+
# 'full' mode has default behavior
|
|
360
|
+
|
|
361
|
+
# Process the user's query
|
|
362
|
+
self.process_query(text)
|
|
363
|
+
|
|
364
|
+
def _voice_stop_callback(self):
|
|
365
|
+
"""Callback when voice mode is stopped."""
|
|
366
|
+
self.voice_mode = "off"
|
|
367
|
+
self.voice_mode_active = False
|
|
368
|
+
self.voice_manager.stop_listening()
|
|
369
|
+
print("Voice mode disabled.")
|
|
370
|
+
|
|
371
|
+
def do_tts(self, arg):
|
|
372
|
+
"""Toggle text-to-speech."""
|
|
373
|
+
arg = arg.lower().strip()
|
|
374
|
+
|
|
375
|
+
if arg == "on":
|
|
376
|
+
self.use_tts = True
|
|
377
|
+
print("TTS enabled" if self.debug_mode else "")
|
|
378
|
+
elif arg == "off":
|
|
379
|
+
self.use_tts = False
|
|
380
|
+
print("TTS disabled" if self.debug_mode else "")
|
|
381
|
+
else:
|
|
382
|
+
print("Usage: /tts on | off")
|
|
383
|
+
|
|
384
|
+
def do_speed(self, arg):
|
|
385
|
+
"""Set the TTS speed multiplier."""
|
|
386
|
+
if not arg.strip():
|
|
387
|
+
print(f"Current TTS speed: {self.voice_manager.get_speed()}x")
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
speed = float(arg.strip())
|
|
392
|
+
if 0.5 <= speed <= 2.0:
|
|
393
|
+
self.voice_manager.set_speed(speed)
|
|
394
|
+
print(f"TTS speed set to {speed}x")
|
|
395
|
+
else:
|
|
396
|
+
print("Speed should be between 0.5 and 2.0")
|
|
397
|
+
except ValueError:
|
|
398
|
+
print("Usage: /speed <number> (e.g., /speed 1.5)")
|
|
399
|
+
|
|
400
|
+
def do_tts_model(self, arg):
|
|
401
|
+
"""Change TTS model.
|
|
402
|
+
|
|
403
|
+
Available models (quality ranking):
|
|
404
|
+
vits - BEST quality (requires espeak-ng)
|
|
405
|
+
fast_pitch - Good quality (works everywhere)
|
|
406
|
+
glow-tts - Alternative fallback
|
|
407
|
+
tacotron2-DDC - Legacy
|
|
408
|
+
|
|
409
|
+
Usage:
|
|
410
|
+
/tts_model vits
|
|
411
|
+
/tts_model fast_pitch
|
|
412
|
+
"""
|
|
413
|
+
model_shortcuts = {
|
|
414
|
+
'vits': 'tts_models/en/ljspeech/vits',
|
|
415
|
+
'fast_pitch': 'tts_models/en/ljspeech/fast_pitch',
|
|
416
|
+
'glow-tts': 'tts_models/en/ljspeech/glow-tts',
|
|
417
|
+
'tacotron2-DDC': 'tts_models/en/ljspeech/tacotron2-DDC',
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
arg = arg.strip()
|
|
421
|
+
if not arg:
|
|
422
|
+
print("Usage: /tts_model <model_name>")
|
|
423
|
+
print("Available models: vits (best), fast_pitch, glow-tts, tacotron2-DDC")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Get full model name
|
|
427
|
+
model_name = model_shortcuts.get(arg, arg)
|
|
428
|
+
|
|
429
|
+
print(f"Changing TTS model to: {model_name}")
|
|
430
|
+
try:
|
|
431
|
+
self.voice_manager.set_tts_model(model_name)
|
|
432
|
+
print("✓ TTS model changed successfully")
|
|
433
|
+
except Exception as e:
|
|
434
|
+
print(f"✗ Error changing model: {e}")
|
|
435
|
+
|
|
436
|
+
def do_whisper(self, arg):
|
|
437
|
+
"""Change Whisper model."""
|
|
438
|
+
model = arg.strip()
|
|
439
|
+
if not model:
|
|
440
|
+
print(f"Current Whisper model: {self.voice_manager.get_whisper()}")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
self.voice_manager.set_whisper(model)
|
|
444
|
+
|
|
445
|
+
def do_clear(self, arg):
|
|
446
|
+
"""Clear chat history."""
|
|
447
|
+
self.messages = [{"role": "system", "content": self.system_prompt}]
|
|
448
|
+
# Reset token counters
|
|
449
|
+
self.system_tokens = 0
|
|
450
|
+
self.user_tokens = 0
|
|
451
|
+
self.assistant_tokens = 0
|
|
452
|
+
# Recalculate system tokens
|
|
453
|
+
self._count_system_tokens()
|
|
454
|
+
print("History cleared")
|
|
455
|
+
|
|
456
|
+
def do_system(self, arg):
|
|
457
|
+
"""Set the system prompt."""
|
|
458
|
+
if arg.strip():
|
|
459
|
+
self.system_prompt = arg.strip()
|
|
460
|
+
self.messages = [{"role": "system", "content": self.system_prompt}]
|
|
461
|
+
print(f"System prompt set to: {self.system_prompt}")
|
|
462
|
+
else:
|
|
463
|
+
print(f"Current system prompt: {self.system_prompt}")
|
|
464
|
+
|
|
465
|
+
def do_exit(self, arg):
|
|
466
|
+
"""Exit the REPL."""
|
|
467
|
+
self.voice_manager.cleanup()
|
|
468
|
+
if self.debug_mode:
|
|
469
|
+
print("Goodbye!")
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
def do_q(self, arg):
|
|
473
|
+
"""Alias for exit."""
|
|
474
|
+
return self.do_exit(arg)
|
|
475
|
+
|
|
476
|
+
def do_quit(self, arg):
|
|
477
|
+
"""Alias for exit."""
|
|
478
|
+
return self.do_exit(arg)
|
|
479
|
+
|
|
480
|
+
def do_stop(self, arg):
|
|
481
|
+
"""Stop voice recognition or TTS playback."""
|
|
482
|
+
# If in voice mode, exit voice mode
|
|
483
|
+
if self.voice_mode_active:
|
|
484
|
+
self._voice_stop_callback()
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
# Even if not in voice mode, stop any ongoing TTS
|
|
488
|
+
if self.voice_manager:
|
|
489
|
+
self.voice_manager.stop_speaking()
|
|
490
|
+
# Do not show the "Stopped speech playback" message
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
def do_pause(self, arg):
|
|
494
|
+
"""Pause current TTS playback.
|
|
495
|
+
|
|
496
|
+
Usage: /pause
|
|
497
|
+
"""
|
|
498
|
+
if self.voice_manager:
|
|
499
|
+
if self.voice_manager.pause_speaking():
|
|
500
|
+
print("TTS playback paused. Use /resume to continue.")
|
|
501
|
+
else:
|
|
502
|
+
print("No active TTS playback to pause.")
|
|
503
|
+
else:
|
|
504
|
+
print("Voice manager not initialized.")
|
|
505
|
+
|
|
506
|
+
def _reset_terminal(self):
|
|
507
|
+
"""Reset terminal state to prevent I/O blocking."""
|
|
508
|
+
import sys
|
|
509
|
+
import os
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
# Flush all output streams
|
|
513
|
+
sys.stdout.flush()
|
|
514
|
+
sys.stderr.flush()
|
|
515
|
+
|
|
516
|
+
# Force terminal to reset input state
|
|
517
|
+
if hasattr(sys.stdin, 'flush'):
|
|
518
|
+
sys.stdin.flush()
|
|
519
|
+
|
|
520
|
+
# On Unix-like systems, reset terminal
|
|
521
|
+
if os.name == 'posix':
|
|
522
|
+
os.system('stty sane 2>/dev/null')
|
|
523
|
+
|
|
524
|
+
except Exception:
|
|
525
|
+
# Ignore errors in terminal reset
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
def do_resume(self, arg):
|
|
529
|
+
"""Resume paused TTS playback.
|
|
530
|
+
|
|
531
|
+
Usage: /resume
|
|
532
|
+
"""
|
|
533
|
+
if self.voice_manager:
|
|
534
|
+
if self.voice_manager.is_paused():
|
|
535
|
+
result = self.voice_manager.resume_speaking()
|
|
536
|
+
if result:
|
|
537
|
+
print("TTS playback resumed.")
|
|
538
|
+
else:
|
|
539
|
+
print("TTS was paused but playback already completed.")
|
|
540
|
+
# Reset terminal after resume operation
|
|
541
|
+
self._reset_terminal()
|
|
542
|
+
else:
|
|
543
|
+
print("No paused TTS playback to resume.")
|
|
544
|
+
else:
|
|
545
|
+
print("Voice manager not initialized.")
|
|
546
|
+
|
|
547
|
+
# If neither voice mode nor TTS is active - don't show any message
|
|
548
|
+
pass
|
|
549
|
+
|
|
550
|
+
def do_help(self, arg):
|
|
551
|
+
"""Show help information."""
|
|
552
|
+
print("Commands:")
|
|
553
|
+
print(" /exit, /q, /quit Exit REPL")
|
|
554
|
+
print(" /clear Clear history")
|
|
555
|
+
print(" /tts on|off Toggle TTS")
|
|
556
|
+
print(" /voice <mode> Voice input: off|full|wait|stop|ptt")
|
|
557
|
+
print(" /speed <number> Set TTS speed (0.5-2.0, default: 1.0, pitch preserved)")
|
|
558
|
+
print(" /tts_model <model> Switch TTS model: vits(best)|fast_pitch|glow-tts|tacotron2-DDC")
|
|
559
|
+
print(" /whisper <model> Switch Whisper model: tiny|base|small|medium|large")
|
|
560
|
+
print(" /system <prompt> Set system prompt")
|
|
561
|
+
print(" /stop Stop voice mode or TTS playback")
|
|
562
|
+
print(" /pause Pause current TTS playback")
|
|
563
|
+
print(" /resume Resume paused TTS playback")
|
|
564
|
+
print(" /tokens Display token usage stats")
|
|
565
|
+
print(" /help Show this help")
|
|
566
|
+
print(" /save <filename> Save chat history to file")
|
|
567
|
+
print(" /load <filename> Load chat history from file")
|
|
568
|
+
print(" /model <name> Change the LLM model")
|
|
569
|
+
print(" /temperature <val> Set temperature (0.0-2.0, default: 0.7)")
|
|
570
|
+
print(" /max_tokens <num> Set max tokens (default: 4096)")
|
|
571
|
+
print(" stop Stop voice mode or TTS (voice command)")
|
|
572
|
+
print(" <message> Send to LLM (text mode)")
|
|
573
|
+
print()
|
|
574
|
+
print("Note: ALL commands must start with / except 'stop'")
|
|
575
|
+
print("In voice mode, say 'stop' to exit voice mode.")
|
|
576
|
+
|
|
577
|
+
def emptyline(self):
|
|
578
|
+
"""Handle empty line input."""
|
|
579
|
+
# Do nothing when an empty line is entered
|
|
580
|
+
pass
|
|
581
|
+
|
|
582
|
+
def do_tokens(self, arg):
|
|
583
|
+
"""Display token usage information."""
|
|
584
|
+
try:
|
|
585
|
+
# Always recalculate tokens to ensure accuracy
|
|
586
|
+
self._reset_and_recalculate_tokens()
|
|
587
|
+
|
|
588
|
+
total_tokens = self.system_tokens + self.user_tokens + self.assistant_tokens
|
|
589
|
+
|
|
590
|
+
print(f"{Colors.YELLOW}Token usage:{Colors.END}")
|
|
591
|
+
print(f" System prompt: {self.system_tokens} tokens")
|
|
592
|
+
print(f" User messages: {self.user_tokens} tokens")
|
|
593
|
+
print(f" AI responses: {self.assistant_tokens} tokens")
|
|
594
|
+
print(f" {Colors.BOLD}Total: {total_tokens} tokens{Colors.END}")
|
|
595
|
+
except Exception as e:
|
|
596
|
+
if self.debug_mode:
|
|
597
|
+
print(f"Error displaying token count: {e}")
|
|
598
|
+
print("Token counting is not available.")
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def do_save(self, filename):
|
|
602
|
+
"""Save chat history to file."""
|
|
603
|
+
try:
|
|
604
|
+
# Add .mem extension if not specified
|
|
605
|
+
if not filename.endswith('.mem'):
|
|
606
|
+
filename = f"{filename}.mem"
|
|
607
|
+
|
|
608
|
+
# Prepare memory file structure
|
|
609
|
+
memory_data = {
|
|
610
|
+
"header": {
|
|
611
|
+
"timestamp_utc": self._get_current_timestamp(),
|
|
612
|
+
"model": self.model,
|
|
613
|
+
"version": __import__('abstractvoice').__version__ # Get version from package __init__.py
|
|
614
|
+
},
|
|
615
|
+
"system_prompt": self.system_prompt,
|
|
616
|
+
"token_stats": {
|
|
617
|
+
"system": self.system_tokens,
|
|
618
|
+
"user": self.user_tokens,
|
|
619
|
+
"assistant": self.assistant_tokens,
|
|
620
|
+
"total": self.system_tokens + self.user_tokens + self.assistant_tokens
|
|
621
|
+
},
|
|
622
|
+
"settings": {
|
|
623
|
+
"tts_speed": self.voice_manager.get_speed(),
|
|
624
|
+
"whisper_model": self.voice_manager.get_whisper(),
|
|
625
|
+
"temperature": self.temperature,
|
|
626
|
+
"max_tokens": self.max_tokens
|
|
627
|
+
},
|
|
628
|
+
"messages": self.messages
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
# Save to file with pretty formatting
|
|
632
|
+
with open(filename, 'w') as f:
|
|
633
|
+
json.dump(memory_data, f, indent=2)
|
|
634
|
+
|
|
635
|
+
print(f"Chat history saved to {filename}")
|
|
636
|
+
except Exception as e:
|
|
637
|
+
if self.debug_mode:
|
|
638
|
+
print(f"Error saving chat history: {e}")
|
|
639
|
+
print(f"Failed to save chat history to {filename}")
|
|
640
|
+
|
|
641
|
+
def _get_current_timestamp(self):
|
|
642
|
+
"""Get current timestamp in the format YYYY-MM-DD HH-MM-SS."""
|
|
643
|
+
from datetime import datetime
|
|
644
|
+
return datetime.utcnow().strftime("%Y-%m-%d %H-%M-%S")
|
|
645
|
+
|
|
646
|
+
def do_load(self, filename):
|
|
647
|
+
"""Load chat history from file."""
|
|
648
|
+
try:
|
|
649
|
+
# Add .mem extension if not specified
|
|
650
|
+
if not filename.endswith('.mem'):
|
|
651
|
+
filename = f"{filename}.mem"
|
|
652
|
+
|
|
653
|
+
if self.debug_mode:
|
|
654
|
+
print(f"Attempting to load from: {filename}")
|
|
655
|
+
|
|
656
|
+
with open(filename, 'r') as f:
|
|
657
|
+
memory_data = json.load(f)
|
|
658
|
+
|
|
659
|
+
if self.debug_mode:
|
|
660
|
+
print(f"Successfully loaded JSON data from {filename}")
|
|
661
|
+
|
|
662
|
+
# Handle both formats: new .mem format and legacy format (just messages array)
|
|
663
|
+
if isinstance(memory_data, dict) and "messages" in memory_data:
|
|
664
|
+
# New .mem format
|
|
665
|
+
if self.debug_mode:
|
|
666
|
+
print("Processing .mem format with messages")
|
|
667
|
+
|
|
668
|
+
# Update model if specified
|
|
669
|
+
if "header" in memory_data and "model" in memory_data["header"]:
|
|
670
|
+
old_model = self.model
|
|
671
|
+
self.model = memory_data["header"]["model"]
|
|
672
|
+
print(f"Model changed from {old_model} to {self.model}")
|
|
673
|
+
|
|
674
|
+
# Update system prompt
|
|
675
|
+
if "system_prompt" in memory_data:
|
|
676
|
+
self.system_prompt = memory_data["system_prompt"]
|
|
677
|
+
if self.debug_mode:
|
|
678
|
+
print(f"Updated system prompt: {self.system_prompt}")
|
|
679
|
+
|
|
680
|
+
# Load messages
|
|
681
|
+
if "messages" in memory_data and isinstance(memory_data["messages"], list):
|
|
682
|
+
self.messages = memory_data["messages"]
|
|
683
|
+
if self.debug_mode:
|
|
684
|
+
print(f"Loaded {len(self.messages)} messages")
|
|
685
|
+
else:
|
|
686
|
+
print("Invalid messages format in memory file")
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
# Recompute token stats if available
|
|
690
|
+
self._reset_and_recalculate_tokens()
|
|
691
|
+
|
|
692
|
+
# Restore settings if available
|
|
693
|
+
if "settings" in memory_data:
|
|
694
|
+
try:
|
|
695
|
+
settings = memory_data["settings"]
|
|
696
|
+
|
|
697
|
+
# Restore TTS speed
|
|
698
|
+
if "tts_speed" in settings:
|
|
699
|
+
speed = settings.get("tts_speed", 1.0)
|
|
700
|
+
self.voice_manager.set_speed(speed)
|
|
701
|
+
# Don't need to update the voice manager immediately as the
|
|
702
|
+
# speed will be used in the next speak() call
|
|
703
|
+
print(f"TTS speed set to {speed}x")
|
|
704
|
+
|
|
705
|
+
# Restore Whisper model
|
|
706
|
+
if "whisper_model" in settings:
|
|
707
|
+
whisper_model = settings.get("whisper_model", "tiny")
|
|
708
|
+
self.voice_manager.set_whisper(whisper_model)
|
|
709
|
+
|
|
710
|
+
# Restore temperature
|
|
711
|
+
if "temperature" in settings:
|
|
712
|
+
temp = settings.get("temperature", 0.4)
|
|
713
|
+
self.temperature = temp
|
|
714
|
+
print(f"Temperature set to {temp}")
|
|
715
|
+
|
|
716
|
+
# Restore max_tokens
|
|
717
|
+
if "max_tokens" in settings:
|
|
718
|
+
tokens = settings.get("max_tokens", 4096)
|
|
719
|
+
self.max_tokens = tokens
|
|
720
|
+
print(f"Max tokens set to {tokens}")
|
|
721
|
+
|
|
722
|
+
except Exception as e:
|
|
723
|
+
if self.debug_mode:
|
|
724
|
+
print(f"Error restoring settings: {e}")
|
|
725
|
+
# Continue loading even if settings restoration fails
|
|
726
|
+
|
|
727
|
+
elif isinstance(memory_data, list):
|
|
728
|
+
# Legacy format (just an array of messages)
|
|
729
|
+
self.messages = memory_data
|
|
730
|
+
|
|
731
|
+
# Reset token counts and recalculate
|
|
732
|
+
self._reset_and_recalculate_tokens()
|
|
733
|
+
|
|
734
|
+
# Extract system prompt if present
|
|
735
|
+
for msg in self.messages:
|
|
736
|
+
if isinstance(msg, dict) and msg.get("role") == "system":
|
|
737
|
+
self.system_prompt = msg.get("content", self.system_prompt)
|
|
738
|
+
break
|
|
739
|
+
else:
|
|
740
|
+
print("Invalid memory file format")
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
# Ensure there's a system message
|
|
744
|
+
self._ensure_system_message()
|
|
745
|
+
|
|
746
|
+
print(f"Chat history loaded from {filename}")
|
|
747
|
+
|
|
748
|
+
except FileNotFoundError:
|
|
749
|
+
print(f"File not found: {filename}")
|
|
750
|
+
except json.JSONDecodeError as e:
|
|
751
|
+
if self.debug_mode:
|
|
752
|
+
print(f"Invalid JSON format in {filename}: {e}")
|
|
753
|
+
print(f"Invalid JSON format in {filename}")
|
|
754
|
+
except Exception as e:
|
|
755
|
+
if self.debug_mode:
|
|
756
|
+
print(f"Error loading chat history: {str(e)}")
|
|
757
|
+
import traceback
|
|
758
|
+
traceback.print_exc()
|
|
759
|
+
print(f"Failed to load chat history from {filename}")
|
|
760
|
+
|
|
761
|
+
def _reset_and_recalculate_tokens(self):
|
|
762
|
+
"""Reset token counts and recalculate for all messages."""
|
|
763
|
+
self.system_tokens = 0
|
|
764
|
+
self.user_tokens = 0
|
|
765
|
+
self.assistant_tokens = 0
|
|
766
|
+
|
|
767
|
+
# Count tokens for all messages
|
|
768
|
+
for msg in self.messages:
|
|
769
|
+
if isinstance(msg, dict) and "content" in msg and "role" in msg:
|
|
770
|
+
self._count_tokens(msg["content"], msg["role"])
|
|
771
|
+
|
|
772
|
+
def _ensure_system_message(self):
|
|
773
|
+
"""Ensure there's a system message at the start of messages."""
|
|
774
|
+
has_system = False
|
|
775
|
+
for msg in self.messages:
|
|
776
|
+
if isinstance(msg, dict) and msg.get("role") == "system":
|
|
777
|
+
has_system = True
|
|
778
|
+
break
|
|
779
|
+
|
|
780
|
+
if not has_system:
|
|
781
|
+
# Prepend a system message if none exists
|
|
782
|
+
self.messages.insert(0, {"role": "system", "content": self.system_prompt})
|
|
783
|
+
|
|
784
|
+
def do_model(self, model_name):
|
|
785
|
+
"""Change the LLM model."""
|
|
786
|
+
if not model_name:
|
|
787
|
+
print(f"Current model: {self.model}")
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
old_model = self.model
|
|
791
|
+
self.model = model_name
|
|
792
|
+
print(f"Model changed from {old_model} to {model_name}")
|
|
793
|
+
|
|
794
|
+
# Don't add a system message about model change
|
|
795
|
+
|
|
796
|
+
def do_temperature(self, arg):
|
|
797
|
+
"""Set the temperature parameter for the LLM."""
|
|
798
|
+
if not arg.strip():
|
|
799
|
+
print(f"Current temperature: {self.temperature}")
|
|
800
|
+
return
|
|
801
|
+
|
|
802
|
+
try:
|
|
803
|
+
temp = float(arg.strip())
|
|
804
|
+
if 0.0 <= temp <= 2.0:
|
|
805
|
+
old_temp = self.temperature
|
|
806
|
+
self.temperature = temp
|
|
807
|
+
print(f"Temperature changed from {old_temp} to {temp}")
|
|
808
|
+
else:
|
|
809
|
+
print("Temperature should be between 0.0 and 2.0")
|
|
810
|
+
except ValueError:
|
|
811
|
+
print("Usage: temperature <number> (e.g., temperature 0.7)")
|
|
812
|
+
|
|
813
|
+
def do_max_tokens(self, arg):
|
|
814
|
+
"""Set the max_tokens parameter for the LLM."""
|
|
815
|
+
if not arg.strip():
|
|
816
|
+
print(f"Current max_tokens: {self.max_tokens}")
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
try:
|
|
820
|
+
tokens = int(arg.strip())
|
|
821
|
+
if tokens > 0:
|
|
822
|
+
old_tokens = self.max_tokens
|
|
823
|
+
self.max_tokens = tokens
|
|
824
|
+
print(f"Max tokens changed from {old_tokens} to {tokens}")
|
|
825
|
+
else:
|
|
826
|
+
print("Max tokens should be a positive integer")
|
|
827
|
+
except ValueError:
|
|
828
|
+
print("Usage: max_tokens <number> (e.g., max_tokens 2048)")
|
|
829
|
+
|
|
830
|
+
def parse_args():
|
|
831
|
+
"""Parse command line arguments."""
|
|
832
|
+
parser = argparse.ArgumentParser(description="AbstractVoice CLI Example")
|
|
833
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
|
834
|
+
parser.add_argument("--api", default="http://localhost:11434/api/chat",
|
|
835
|
+
help="LLM API URL")
|
|
836
|
+
parser.add_argument("--model", default="granite3.3:2b",
|
|
837
|
+
help="LLM model name")
|
|
838
|
+
return parser.parse_args()
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def main():
|
|
842
|
+
"""Entry point for the application."""
|
|
843
|
+
try:
|
|
844
|
+
# Parse command line arguments
|
|
845
|
+
args = parse_args()
|
|
846
|
+
|
|
847
|
+
# Initialize and run REPL
|
|
848
|
+
repl = VoiceREPL(
|
|
849
|
+
api_url=args.api,
|
|
850
|
+
model=args.model,
|
|
851
|
+
debug_mode=args.debug
|
|
852
|
+
)
|
|
853
|
+
repl.cmdloop()
|
|
854
|
+
except KeyboardInterrupt:
|
|
855
|
+
print("\nExiting...")
|
|
856
|
+
except Exception as e:
|
|
857
|
+
print(f"Application error: {e}")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
if __name__ == "__main__":
|
|
861
|
+
main()
|