commit 02d7c10f7d50b742bdfc576a172bb66175dd2a1f Author: Ketan Date: Mon Sep 21 01:55:42 2026 -0400 fix stuff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d15c42b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +core/__pycache__ +utils/__pycache__ +audio/__pycache__ +venv \ No newline at end of file diff --git a/audio/__init__.py b/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/audio/audio_queue.py b/audio/audio_queue.py new file mode 100644 index 0000000..af75920 --- /dev/null +++ b/audio/audio_queue.py @@ -0,0 +1,8 @@ +import queue + +q = queue.Queue() + +def callback(indata, frames, time_, status): + if status: + print(status) + q.put(bytes(indata)) diff --git a/audio/chime.py b/audio/chime.py new file mode 100644 index 0000000..b2225d5 --- /dev/null +++ b/audio/chime.py @@ -0,0 +1,15 @@ +import time + +import pygame + +from config import CHIME_FILE + + +def play_ding(): + pygame.mixer.init() + pygame.mixer.music.load(CHIME_FILE) + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + time.sleep(0.05) + pygame.mixer.music.unload() + pygame.mixer.quit() diff --git a/audio/gtts.py b/audio/gtts.py new file mode 100644 index 0000000..74fad18 --- /dev/null +++ b/audio/gtts.py @@ -0,0 +1,20 @@ +import os +import tempfile +import time + +import pygame +from gtts import gTTS + + +def speak(text): + with tempfile.TemporaryDirectory() as tmpdirname: + temp_path = os.path.join(tmpdirname, "speech.mp3") + tts = gTTS(text=text,lang='en', tld='com') + tts.save(temp_path) + pygame.mixer.init() + pygame.mixer.music.load(temp_path) + pygame.mixer.music.play() + while pygame.mixer.music.get_busy(): + time.sleep(0.1) + pygame.mixer.music.unload() + pygame.mixer.quit() \ No newline at end of file diff --git a/audio/tts.py b/audio/tts.py new file mode 100644 index 0000000..530c469 --- /dev/null +++ b/audio/tts.py @@ -0,0 +1,33 @@ +import asyncio +import os +import tempfile +import time + +import edge_tts +import pygame + + +def speak(text): + async def generate_speech(): + voice = "en-US-ChristopherNeural" + communicate = edge_tts.Communicate(text, voice) + await communicate.save(temp_path) + + with tempfile.TemporaryDirectory() as tmpdirname: + temp_path = os.path.join(tmpdirname, "speech.mp3") + + asyncio.run(generate_speech()) + + pygame.mixer.init() + pygame.mixer.music.load(temp_path) + pygame.mixer.music.play() + + while pygame.mixer.music.get_busy(): + time.sleep(0.1) + + pygame.mixer.music.unload() + pygame.mixer.quit() + + +if __name__ == "__main__": + speak("Hello! I am now speaking using Microsoft Edge's natural text to speech engine.") \ No newline at end of file diff --git a/bs/__init__.py b/bs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bs/marshallhatesthis.mp3 b/bs/marshallhatesthis.mp3 new file mode 100644 index 0000000..7b86627 Binary files /dev/null and b/bs/marshallhatesthis.mp3 differ diff --git a/bs/verity.py b/bs/verity.py new file mode 100644 index 0000000..a99ca48 --- /dev/null +++ b/bs/verity.py @@ -0,0 +1,21 @@ +import os +import time + +import pygame + + +def ouiouioui(): + temp_path = os.path.join(r"bs/marshallhatesthis.mp3") + pygame.mixer.init() + pygame.mixer.music.load(temp_path) + pygame.mixer.music.play() + + while pygame.mixer.music.get_busy(): + time.sleep(0.1) + + pygame.mixer.music.unload() + pygame.mixer.quit() + + +if __name__ == "__main__": + ouiouioui() \ No newline at end of file diff --git a/chime.mp3 b/chime.mp3 new file mode 100644 index 0000000..4c247b5 Binary files /dev/null and b/chime.mp3 differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..25ee584 --- /dev/null +++ b/config.py @@ -0,0 +1,14 @@ +# Model details +VOSK_MODEL_FOLDER = 'vosk-model-small-en-us-0.15' +VOSK_MODEL_URL = "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip" +VOSK_MODEL_ZIP = "voicemodel.zip" + +# Sound files +CHIME_FILE = "chime.mp3" + +# AI API +AI_URL = "http://localhost:11434/api/generate" +AI_MODEL = "deepseek-r1:1.5b" + +WAKE_WORDS = ["hey risa", "hey lisa", "risa", "lisa", "Hey Nate", "Nate"] +TTS_MODEL = "tts_models/en/ljspeech/vits" \ No newline at end of file diff --git a/core/speech_recognition.py b/core/speech_recognition.py new file mode 100644 index 0000000..7523dd2 --- /dev/null +++ b/core/speech_recognition.py @@ -0,0 +1,13 @@ +import json + +from audio.audio_queue import q + + +def listen_for_command(rec): + while True: + data = q.get() + if rec.AcceptWaveform(data): + result_json = json.loads(rec.Result()) + result = result_json.get("text", "").lower().strip() + if result: + return result diff --git a/core/ui.py b/core/ui.py new file mode 100644 index 0000000..fbc028f --- /dev/null +++ b/core/ui.py @@ -0,0 +1,211 @@ +from PySide6.QtCore import QEasingCurve, QPoint, QPropertyAnimation, Qt, Signal +from PySide6.QtWidgets import ( + QApplication, + QLabel, + QLineEdit, + QMainWindow, + QVBoxLayout, + QWidget, +) + + +class RisaAssistantPopup(QMainWindow): + text_submitted = Signal(str) + + def __init__(self): + super().__init__() + + self.setWindowFlags( + Qt.WindowType.FramelessWindowHint | + Qt.WindowType.WindowStaysOnTopHint | + Qt.WindowType.Tool + ) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + self.popup_width = 540 + self.font_style = "font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Arial, sans-serif;" + + self.main_container = QWidget(self) + self.main_layout = QVBoxLayout(self.main_container) + self.main_layout.setContentsMargins(10, 10, 10, 10) + self.main_layout.setSpacing(15) + + self.content_card = QWidget() + self.content_card.setObjectName("ContentCard") + self.content_layout = QVBoxLayout(self.content_card) + self.content_layout.setContentsMargins(22, 16, 22, 16) + + self.content_label = QLabel() + self.content_label.setTextFormat(Qt.TextFormat.RichText) + self.content_label.setWordWrap(True) + self.content_layout.addWidget(self.content_label) + self.main_layout.addWidget(self.content_card) + + self.input_card = QWidget() + self.input_card.setObjectName("InputCard") + self.input_layout = QVBoxLayout(self.input_card) + self.input_layout.setContentsMargins(20, 20, 20, 20) + self.input_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.star_icon = QLabel("✹") + self.star_icon.setStyleSheet("font-size: 42px; color: #000000; margin-bottom: 2px;") + self.star_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.input_layout.addWidget(self.star_icon) + + self.status_label = QLabel("Sleeping...") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setStyleSheet("font-size: 13px; color: #777777; font-weight: 600; margin-bottom: 5px;") + self.input_layout.addWidget(self.status_label) + + self.input_field = QLineEdit() + self.input_field.setPlaceholderText("Type or Speak to RISA...") + self.input_field.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.input_field.setFrame(False) + self.input_layout.addWidget(self.input_field) + self.input_field.returnPressed.connect(self._on_text_submitted) + + self.main_layout.addWidget(self.input_card) + self.setCentralWidget(self.main_container) + + self.setStyleSheet(""" + QWidget#ContentCard, QWidget#InputCard { + background-color: rgba(245, 245, 240, 0.78); + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 28px; + } + QLineEdit { + background: transparent; + color: #000000; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 18px; + } + QLineEdit::placeholder { + color: #555555; + } + """) + + self.content_card.hide() + self.anim = None + + def _on_text_submitted(self): + text = self.input_field.text().strip() + if text: + self.input_field.clear() + self.text_submitted.emit(text) + + def set_listening_state(self, state: str): + if state == "idle": + self.star_icon.setStyleSheet("font-size: 42px; color: #888888; margin-bottom: 2px;") + self.status_label.setText("Sleeping (Say 'Hey Risa')...") + self.status_label.setStyleSheet("font-size: 13px; color: #888888; font-weight: 600; margin-bottom: 5px;") + elif state == "active": + self.star_icon.setStyleSheet("font-size: 42px; color: #D4FF00; margin-bottom: 2px;") + self.status_label.setText("Listening...") + self.status_label.setStyleSheet("font-size: 13px; color: #ff8c00; font-weight: bold; margin-bottom: 5px;") + elif state == "thinking": + self.star_icon.setStyleSheet("font-size: 42px; color: #007acc; margin-bottom: 2px;") + self.status_label.setText("Thinking...") + self.status_label.setStyleSheet("font-size: 13px; color: #007acc; font-weight: bold; margin-bottom: 5px;") + + def update_layout_and_animate(self): + self.setFixedWidth(self.popup_width) + self.adjustSize() + self.resize(self.popup_width, self.sizeHint().height()) + self.animate_popup(self.height()) + + def show_initial_input(self): + self.content_card.hide() + self.input_layout.setContentsMargins(20, 25, 20, 25) + self.star_icon.show() + self.status_label.show() + self.input_field.setFocus() + self.update_layout_and_animate() + + def show_thinking(self, prompt_text: str): + html_content = f""" +
+ Risa • Asking AI...
+
"{prompt_text}"
+
✹ Thinking...
+
+ """ + self.content_label.setText(html_content) + self.content_card.show() + + self.input_layout.setContentsMargins(20, 20, 20, 20) + self.update_layout_and_animate() + + def display_command_card(self, mode: str, data: dict = None): + if data is None: + data = {} + + html_content = "" + + if mode == "weather": + html_content = f""" + + + + + + + + + +
{data.get('temp', '72')}°{data.get('humidity', '55')}% Humidity
+ {data.get('high', '85')}° High    {data.get('low', '63')}° Low
{data.get('condition', 'Sunny')}{data.get('location', 'Bridgewater NJ')}
+ """ + elif mode == "time": + html_content = f""" + + + + + + + + + +
{data.get('time', '3:30 PM')}{data.get('day', '')}
Sunny, rain soon.{data.get('date', '')}
+ """ + elif mode == "general": + html_content = f""" +
+ Risa Assistant
+
{data.get('text', '')}
+
+ """ + + if html_content: + self.content_label.setText(html_content) + self.content_card.show() + + self.input_layout.setContentsMargins(20, 20, 20, 20) + self.update_layout_and_animate() + + def animate_popup(self, current_height): + screen = QApplication.primaryScreen() + screen_geom = screen.availableGeometry() + + self.end_x = screen_geom.left() + int((screen_geom.width() - self.popup_width) / 2) + self.end_y = screen_geom.bottom() - current_height - 25 + self.start_y = screen_geom.bottom() + 50 + + if self.anim and self.anim.state() == QPropertyAnimation.State.Running: + self.anim.stop() + + if not self.isVisible(): + self.move(self.end_x, self.start_y) + self.show() + + self.anim = QPropertyAnimation(self, b"pos") + self.anim.setDuration(350) + self.anim.setStartValue(self.pos()) + self.anim.setEndValue(QPoint(self.end_x, self.end_y)) + self.anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self.anim.start() + + def changeEvent(self, event): + if event.type() == event.Type.ActivationChange and not self.isActiveWindow(): + self.hide() + super().changeEvent(event) \ No newline at end of file diff --git a/core/wake_word.py b/core/wake_word.py new file mode 100644 index 0000000..f481b00 --- /dev/null +++ b/core/wake_word.py @@ -0,0 +1,8 @@ +from config import WAKE_WORDS + +from .speech_recognition import listen_for_command + + +def detect_wake_word(rec): + phrase = listen_for_command(rec) + return any(phrase.startswith(word) for word in WAKE_WORDS) \ No newline at end of file diff --git a/ico/icon.png b/ico/icon.png new file mode 100644 index 0000000..0c62f20 Binary files /dev/null and b/ico/icon.png differ diff --git a/ico/image.png b/ico/image.png new file mode 100644 index 0000000..7ab638c Binary files /dev/null and b/ico/image.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..218038c --- /dev/null +++ b/main.py @@ -0,0 +1,375 @@ +import os +import subprocess +import sys +import threading +import time +import webbrowser +from datetime import date, datetime + +import pyautogui +import speech_recognition as sr +from PySide6.QtCore import QObject, Signal +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon + +from audio.tts import speak +from bs.verity import ouiouioui + +# External file imports +from core.ui import RisaAssistantPopup +from utils.ai_client import ask_ai +from utils.time_utils import time_to_words +from utils.weather import get_local_weather + +popup_window = None +trigger_bridge = None +tray_icon = None + +recognizer = sr.Recognizer() +microphone = sr.Microphone() + +recognizer.dynamic_energy_threshold = True +recognizer.pause_threshold = 0.8 + +class VoiceTriggerBridge(QObject): + hide_window = Signal() + show_input_panel = Signal() + show_thinking_panel = Signal(str) + update_display_card = Signal(str, dict) + show_timeout_card = Signal(str) + update_state = Signal(str) + +# Commands +def process_command(command: str) -> bool: + global trigger_bridge + command = command.lower().strip() + if not command: + return False + + print(f"Executing command: '{command}'") + + # Time Commands + if any(word in command for word in ["time", "what time", "current time"]): + now = datetime.now() + time_words = time_to_words(now) + if trigger_bridge: + time_data = { + "time": now.strftime("%I:%M %p").lstrip('0'), + "day": now.strftime("%A"), + "date": now.strftime("%B %d, %Y") + } + trigger_bridge.update_display_card.emit("time", time_data) + speak(f"The time is {time_words}") + return True + + elif any(word in command for word in ["date", "what's the date"]): + today_date = date.today().strftime("%B %d, %Y") + if trigger_bridge: + trigger_bridge.update_display_card.emit("general", {"text": f"Today is {today_date}."}) + speak(f"The date is {today_date}") + return True + + # Weather + elif any(word in command for word in ["weather", "weather today", "what is the weather"]): + weather = get_local_weather() + if weather: + city = weather['location']['city'] + temp_unit = weather['units']['temp'] + current_temp = weather['current_temp'] + high_temp = weather['high_temp'] + low_temp = weather['low_temp'] + clouds = weather['cloud_cover_pct'] + rain = weather['precipitation'] + + if clouds < 10: cloud_phrase = "Clear skies" + elif clouds < 40: cloud_phrase = "Partly cloudy skies" + elif clouds < 75: cloud_phrase = "Mostly cloudy skies" + else: cloud_phrase = "Completely overcast" + + if rain > 0: + cloud_phrase += ", rain soon." + + if trigger_bridge: + trigger_bridge.update_display_card.emit("weather", { + "temp": str(current_temp), + "humidity": str(clouds), + "high": str(high_temp), + "low": str(low_temp), + "condition": cloud_phrase, + "location": f"{city} NJ" + }) + print(f"Right now in {city}, it is {current_temp}{temp_unit} with {cloud_phrase.lower()}. Today's high will be {high_temp} degrees, and the low will be {low_temp} degrees.") + speak(f"Right now in {city}, it is {current_temp}{temp_unit} with {cloud_phrase.lower()}. Today's high will be {high_temp} degrees, and the low will be {low_temp} degrees.") + return True + return False + + # Identity + elif any(phrase in command for phrase in ["who are you", "your name", "what do you do"]): + if trigger_bridge: + trigger_bridge.update_display_card.emit("general", {"text": "Hi, my name is Risa. I'm your voice assistant."}) + speak("Hi, my name is Risa. I'm your voice assistant.") + return True + elif any(phrase in command for phrase in ["what is the capital of france", "What is the capital of france", "What's the capital of france"]): + if trigger_bridge: + trigger_bridge.update_display_card.emit("general", {"text": "Oui oui oui! It paris"}) + ouiouioui() + return True + # Media Controls + elif any(phrase in command for phrase in ["pause", "pause music", "pause video", "play", "resume"]): + pyautogui.press('playpause') + speak("Done") + return True + + elif "volume up" in command: + for _ in range(5): pyautogui.press("volumeup") + speak("Volume up") + return True + + elif "volume down" in command: + for _ in range(5): pyautogui.press("volumedown") + speak("Volume down") + return True + + elif "mute" in command: + pyautogui.press("volumemute") + return True + + # Web Controls + elif "open google" in command: + speak("Opening Google") + webbrowser.open("https://google.com") + return True + + elif "open youtube" in command: + speak("Opening Youtube") + webbrowser.open("https://youtube.com") + return True + + elif "search for" in command: + query = command.split("search for")[-1].strip() + if query: + if trigger_bridge: + trigger_bridge.update_display_card.emit("general", {"text": f"Searching Google for: {query}"}) + speak(f"Searching for {query}") + webbrowser.open(f"https://www.google.com/search?q={query}") + return True + + # Apps + elif "open calculator" in command: + speak("Opening Calculator") + if os.name == 'nt': subprocess.Popen('calc.exe') + else: subprocess.Popen('gnome-calculator' if os.name == 'posix' else 'open -a Calculator') + return True + # Sleep + elif any(phrase in command for phrase in ["stop listening", "go to sleep", "hide"]): + speak("Going to sleep.") + if popup_window and trigger_bridge: + trigger_bridge.hide_window.emit() + trigger_bridge.update_state.emit("idle") + return False + + + # Commands List + elif any(phrase in command for phrase in ["list commands", "what are your commands", "show commands"]): + commands_html = """ +
+ System Capabilities:
+ + + + + + + +
• Time / Date: "What time is it?", "What's the date?"
• Weather: "What is the weather today?"
• Controls: "Volume up/down", "Mute", "Pause/Play"
• Apps & Web: "Open Google", "Search for [query]"
• AI Core: "Ask AI [your question]"
• Sleep: "Stop listening", "Go to sleep"
+
+ """ + if trigger_bridge: + trigger_bridge.update_display_card.emit("general", {"text": commands_html}) + speak("Here is a list of commands.") + return True + + else: + # AI Fallback Query + prompt = command + if prompt.startswith("ask ai") or prompt.startswith("ask a i"): + for phrase in ["ask ai", "ask a i"]: + if prompt.startswith(phrase): + prompt = prompt.replace(phrase, "", 1).strip(" :") + break + + if prompt: + if trigger_bridge: + trigger_bridge.show_thinking_panel.emit(prompt) + trigger_bridge.update_state.emit("thinking") + + speak("Thinking about it...") + ai_response = ask_ai(prompt) + + if trigger_bridge and ai_response: + trigger_bridge.update_display_card.emit("general", {"text": str(ai_response)}) + elif trigger_bridge: + trigger_bridge.update_display_card.emit("general", {"text": f"Processed request: '{prompt}'"}) + + return True + else: + speak("What would you like to ask?") + return True + +# Voice listener loop +def voice_listener_loop(): + global trigger_bridge + + with microphone as source: + recognizer.adjust_for_ambient_noise(source, duration=1.5) + print("\nRisa is active... Say 'Hey Risa [your command]' in one sentence!") + in_conversation = False + timeout_emitted = False + + if trigger_bridge: + trigger_bridge.update_state.emit("idle") + + while True: + try: + if not in_conversation: + if trigger_bridge: + trigger_bridge.update_state.emit("idle") + print(".", end="", flush=True) + + audio = recognizer.listen(source, timeout=None, phrase_time_limit=10) + raw_text = recognizer.recognize_google(audio).lower() + + if "hey risa" in raw_text or "hey lisa" in raw_text: + print(f"\n[Triggered]: '{raw_text}'") + timeout_emitted = False + + if trigger_bridge: + trigger_bridge.show_input_panel.emit() + trigger_bridge.update_state.emit("active") + + if "risa" in raw_text: + command_text = raw_text.split("risa")[-1].strip() + else: + command_text = raw_text.split("lisa")[-1].strip() + + if command_text: + executed = process_command(command_text) + if executed: + in_conversation = True + else: + speak("How can I help?") + in_conversation = True + else: + print("\n[Follow-up Mode: Listening...]") + if trigger_bridge: + trigger_bridge.update_state.emit("active") + time.sleep(0.4) + + audio = recognizer.listen(source, timeout=6, phrase_time_limit=6) + command_text = recognizer.recognize_google(audio).lower() + print(f"[Follow-up Command Received]: '{command_text}'") + + timeout_emitted = False + executed = process_command(command_text) + if not executed: + in_conversation = False + + except (sr.UnknownValueError, sr.WaitTimeoutError, sr.RequestError) as e: + if in_conversation: + print("\n[Follow-up timeout or unrecognized audio: resetting context]") + in_conversation = False + + if not timeout_emitted: + if trigger_bridge: + error_msg = "Risa could not hear you clearly or the request timed out." + if isinstance(e, sr.WaitTimeoutError): + error_msg = "Follow-up timed out. No speech detected." + + html_error = f""" +
+ Timeout
+
{error_msg} Please reinitiate Risa by saying: Hey Risa
+
+ """ + trigger_bridge.update_display_card.emit("general", {"text": html_error}) + trigger_bridge.update_state.emit("idle") + timeout_emitted = True + + except Exception as e: + print(f"\nUnexpected error: {e}") + in_conversation = False + if trigger_bridge: + trigger_bridge.update_state.emit("idle") + +def handle_typed_command(command_text: str): + def run(): + if trigger_bridge: + trigger_bridge.show_input_panel.emit() + trigger_bridge.update_state.emit("active") + + process_command(command_text) + + threading.Thread(target=run, daemon=True).start() + +def trigger_from_tray(): + if popup_window: + popup_window.show() + popup_window.raise_() + popup_window.activateWindow() + if trigger_bridge: + trigger_bridge.show_input_panel.emit() + trigger_bridge.update_state.emit("active") + +def setup_system_tray(app): + global tray_icon + + app.setQuitOnLastWindowClosed(False) + + icon = QIcon("ico/icon.png") + + tray_icon = QSystemTrayIcon(icon, app) + tray_icon.setToolTip("Risa Assistant") + + tray_menu = QMenu() + + show_action = tray_menu.addAction("Open Risa") + show_action.triggered.connect(trigger_from_tray) + + tray_menu.addSeparator() + + quit_action = tray_menu.addAction("Quit") + quit_action.triggered.connect(app.quit) + + tray_icon.setContextMenu(tray_menu) + + def on_tray_activated(reason): + if reason in (QSystemTrayIcon.Trigger, QSystemTrayIcon.DoubleClick): + trigger_from_tray() + + tray_icon.activated.connect(on_tray_activated) + tray_icon.show() + +def main(): + global popup_window, trigger_bridge + app = QApplication(sys.argv) + + setup_system_tray(app) + + trigger_bridge = VoiceTriggerBridge() + popup_window = RisaAssistantPopup() + + trigger_bridge.show_input_panel.connect(popup_window.show_initial_input) + trigger_bridge.show_thinking_panel.connect(popup_window.show_thinking) + trigger_bridge.update_display_card.connect(popup_window.display_command_card) + trigger_bridge.hide_window.connect(popup_window.hide) + trigger_bridge.update_state.connect(popup_window.set_listening_state) + + popup_window.text_submitted.connect(handle_typed_command) + + listener_thread = threading.Thread(target=voice_listener_loop, daemon=True) + listener_thread.start() + + sys.exit(app.exec()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/main.spec b/main.spec new file mode 100644 index 0000000..e03e1a6 --- /dev/null +++ b/main.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='main', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='main', +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59258cc --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pip install ruff diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..04a685f --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,2 @@ +.venv\Scripts\Activate.ps1 +pip install -r requirements.txt \ No newline at end of file diff --git a/somerville_weather.mp3 b/somerville_weather.mp3 new file mode 100644 index 0000000..56779a4 Binary files /dev/null and b/somerville_weather.mp3 differ diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/ai_client.py b/utils/ai_client.py new file mode 100644 index 0000000..3fb2800 --- /dev/null +++ b/utils/ai_client.py @@ -0,0 +1,43 @@ +import re + +import ollama + +from audio.tts import speak + +AI_MODEL = "gemma4:e2b-it-qat" +AI_HOST = "http://localhost:11434" +def clean_response(text): + cleaned = re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE) + lines = [line for line in cleaned.splitlines() if line.strip()] + return "\n".join(lines).strip() +def ask_ai(prompt): + try: + full_prompt = ( + "You must answer in exactly one or two sentences.\n\n" + f"Question: {prompt}\n" + "Answer:" + ) + print("Thinking...", end="", flush=True) + client = ollama.Client(host=AI_HOST) + stream = client.generate( + model=AI_MODEL, + prompt=full_prompt, + stream=True + ) + ai_text = "" + for chunk in stream: + text_piece = chunk.get("response", "") + if text_piece: + print(text_piece, end="", flush=True) + ai_text += text_piece + print() + final_text = clean_response(ai_text) + if not final_text: + speak("Sorry, I got no response from the AI.") + else: + speak(final_text) + except Exception as e: + + print("\nAI Error:", e) + + speak("Sorry, I couldn't connect to the AI.") \ No newline at end of file diff --git a/utils/time_utils.py b/utils/time_utils.py new file mode 100644 index 0000000..61ea3da --- /dev/null +++ b/utils/time_utils.py @@ -0,0 +1,23 @@ +from datetime import datetime + + +def time_to_words(dt: datetime): + hour = dt.hour % 12 or 12 + minute = dt.minute + numbers = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", + "eighteen", "nineteen", "twenty", "twenty-one", "twenty-two", "twenty-three", + "twenty-four", "twenty-five", "twenty-six", "twenty-seven", "twenty-eight", + "twenty-nine", "thirty", "thirty-one", "thirty-two", "thirty-three", "thirty-four", + "thirty-five", "thirty-six", "thirty-seven", "thirty-eight", "thirty-nine", + "forty", "forty-one", "forty-two", "forty-three", "forty-four", "forty-five", + "forty-six", "forty-seven", "forty-eight", "forty-nine", "fifty", "fifty-one", + "fifty-two", "fifty-three", "fifty-four", "fifty-five", "fifty-six", "fifty-seven", + "fifty-eight", "fifty-nine"] + am_pm = "AM" if dt.hour < 12 else "PM" + if minute == 0: + return f"{numbers[hour]} o'clock {am_pm}" + elif minute < 10: + return f"{numbers[hour]} oh {numbers[minute]} {am_pm}" + else: + return f"{numbers[hour]} {numbers[minute]} {am_pm}" diff --git a/utils/vosk_setup.py b/utils/vosk_setup.py new file mode 100644 index 0000000..41b9fc2 --- /dev/null +++ b/utils/vosk_setup.py @@ -0,0 +1,22 @@ +import os +import zipfile + +import requests +import vosk + +from config import VOSK_MODEL_FOLDER, VOSK_MODEL_URL, VOSK_MODEL_ZIP + + +def setup_vosk_model(): + if not os.path.isdir(VOSK_MODEL_FOLDER): + print("Downloading Vosk model") + response = requests.get(VOSK_MODEL_URL) + with open(VOSK_MODEL_ZIP, "wb") as file: + file.write(response.content) + with zipfile.ZipFile(VOSK_MODEL_ZIP, 'r') as zip_ref: + zip_ref.extractall() + print("Downloaded!") + + model = vosk.Model(VOSK_MODEL_FOLDER) + rec = vosk.KaldiRecognizer(model, 16000) + return rec diff --git a/utils/weather.py b/utils/weather.py new file mode 100644 index 0000000..fc41194 --- /dev/null +++ b/utils/weather.py @@ -0,0 +1,95 @@ +import requests + + +def get_local_weather( + fallback_lat=40.59, + fallback_lon=-74.62, + fallback_city="Bridgewater", + fallback_region="New Jersey", + fallback_country_code="US" +): + """ + Fetches the user's current location and automatically determines + whether to return Metric or Imperial units based on their country. + """ + lat, lon = fallback_lat, fallback_lon + city, region = fallback_city, fallback_region + country_code = fallback_country_code + + # Geolocation Lookup + try: + geo_url = "http://ip-api.com/json" + geo_response = requests.get(geo_url, timeout=5).json() + + if geo_response.get("status") == "success": + lat = geo_response["lat"] + lon = geo_response["lon"] + city = geo_response["city"] + region = geo_response["regionName"] + country_code = geo_response.get("countryCode", "US") + else: + print("IP lookup API returned an error status. Using fallback coordinates.") + except Exception: + print("Network error during IP lookup. Using fallback coordinates.") + + # Unit System 'n Weather URL + is_imperial = country_code in ["US", "LR", "MM"] + + unit_suffix = "" + if is_imperial: + unit_suffix = "&temperature_unit=fahrenheit&precipitation_unit=inch" + + weather_url = ( + f"https://api.open-meteo.com/v1/forecast?" + f"latitude={lat}&longitude={lon}" + f"¤t=temperature_2m,precipitation,cloud_cover" + f"&daily=temperature_2m_max,temperature_2m_min" + f"&timezone=auto" + f"{unit_suffix}" + ) + + try: + response = requests.get(weather_url, timeout=5) + + if response.status_code == 200: + data = response.json() + current = data["current"] + daily = data["daily"] + + temp_unit = "°F" if is_imperial else "°C" + precip_unit = "inch" if is_imperial else "mm" + + return { + "location": { + "city": city, + "region": region, + "country_code": country_code, + "lat": lat, + "lon": lon + }, + "unit_system": "imperial" if is_imperial else "metric", + "units": {"temp": temp_unit, "precipitation": precip_unit}, + "current_temp": current['temperature_2m'], + "high_temp": daily['temperature_2m_max'][0], + "low_temp": daily['temperature_2m_min'][0], + "precipitation": current['precipitation'], + "cloud_cover_pct": current['cloud_cover'] + } + else: + print(f"Weather API returned error code: {response.status_code}") + return None + + except Exception as e: + print(f"Network error while fetching weather: {e}") + return None + +if __name__ == "__main__": + # Test block + weather_data = get_local_weather() + if weather_data: + u = weather_data["units"] + print(f"\n--- Location: {weather_data['location']['city']}, {weather_data['location']['region']} ({weather_data['location']['country_code']}) ---") + print(f"System Chosen: {weather_data['unit_system'].upper()}") + print(f"Current Temperature: {weather_data['current_temp']}{u['temp']}") + print(f"Today's High: {weather_data['high_temp']}{u['temp']}") + print(f"Precipitation: {weather_data['precipitation']} {u['precipitation']}") \ No newline at end of file