Initial commit: meeting assistant
This commit is contained in:
104
.gitignore
vendored
Normal file
104
.gitignore
vendored
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE and Editor files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
*.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Configuration files with sensitive data
|
||||||
|
config.yaml
|
||||||
|
config/local.yaml
|
||||||
|
config/production.yaml
|
||||||
|
*.local.yaml
|
||||||
|
*.secret.yaml
|
||||||
|
|
||||||
|
# Model files (large binary files)
|
||||||
|
models/*.gguf
|
||||||
|
models/*.bin
|
||||||
|
models/whisper/model.bin
|
||||||
|
models/whisper/*.gguf
|
||||||
|
|
||||||
|
# Cache directories
|
||||||
|
.cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# pyre type checker
|
||||||
|
.pyre/models/whisper/model.bin
|
||||||
14
config/settings.py
Normal file
14
config/settings.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
class Config:
|
||||||
|
def __init__(self):
|
||||||
|
# Audio settings
|
||||||
|
self.sample_rate = 16000
|
||||||
|
self.chunk_duration = 2.0
|
||||||
|
self.vad_threshold = 0.5
|
||||||
|
|
||||||
|
# AI settings
|
||||||
|
self.context_window = 8192
|
||||||
|
self.max_history = 10
|
||||||
|
|
||||||
|
# Recognition settings
|
||||||
|
self.whisper_model = "base" # 'tiny', 'base', 'small', 'medium'
|
||||||
|
self.language = "en"
|
||||||
151
main.py
Normal file
151
main.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Meeting Assistant - Real-time AI Copilot for Meetings
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from src.audio_listener import AudioListener
|
||||||
|
from src.ai_engine import AIEngine
|
||||||
|
from src.overlay import InvisibleOverlay
|
||||||
|
from src.context_manager import ContextManager
|
||||||
|
from src.utils import setup_logging, get_platform
|
||||||
|
|
||||||
|
# Question words that indicate the speaker wants an answer
|
||||||
|
_QUESTION_WORDS = (
|
||||||
|
"what", "why", "how", "when", "where", "who", "which",
|
||||||
|
"can you", "could you", "would you", "do you", "did you",
|
||||||
|
"is there", "are there", "is it", "are you",
|
||||||
|
"tell me", "explain", "describe", "define",
|
||||||
|
"your name", "help me",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phrases to skip even if they contain question words (noise)
|
||||||
|
_NOISE_PHRASES = (
|
||||||
|
"timer cannot", "qobject", "qml", "pyside", "pyqt",
|
||||||
|
"def ", "class ", "import ", "return ", "self.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingAssistant:
|
||||||
|
def __init__(self, config_path="config.yaml"):
|
||||||
|
print("🚀 Starting Meeting Assistant...")
|
||||||
|
|
||||||
|
config_file = Path(__file__).parent / config_path
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
self.config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
self.logger = setup_logging()
|
||||||
|
print(f"💻 Platform: {get_platform()}")
|
||||||
|
|
||||||
|
self.context_manager = ContextManager(self.config)
|
||||||
|
self.ai_engine = AIEngine(self.config)
|
||||||
|
self.overlay = InvisibleOverlay(self.config)
|
||||||
|
self.audio_listener = AudioListener(self.config, self.on_audio_transcript)
|
||||||
|
|
||||||
|
self._last_question = "" # Simple debounce
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Audio callback — runs in the audio listener thread
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_audio_transcript(self, text, timestamp):
|
||||||
|
self.context_manager.add_audio_context(text, timestamp)
|
||||||
|
|
||||||
|
if self._is_question(text):
|
||||||
|
print(f"❓ Question: {text}")
|
||||||
|
self._generate_answer(text)
|
||||||
|
else:
|
||||||
|
print(f" Context: {text}")
|
||||||
|
|
||||||
|
def _is_question(self, text):
|
||||||
|
if len(text.split()) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
text_lower = text.lower().strip()
|
||||||
|
|
||||||
|
# Skip obvious noise / code text
|
||||||
|
if any(p in text_lower for p in _NOISE_PHRASES):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ends with "?" → always a question
|
||||||
|
if text.rstrip().endswith("?"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Starts with or contains a question word
|
||||||
|
return any(text_lower.startswith(w) or f" {w} " in text_lower for w in _QUESTION_WORDS)
|
||||||
|
|
||||||
|
def _generate_answer(self, question):
|
||||||
|
# Debounce: skip exact repeat within same run
|
||||||
|
if question.lower() == self._last_question.lower():
|
||||||
|
return
|
||||||
|
self._last_question = question
|
||||||
|
|
||||||
|
context = self.context_manager.get_context()
|
||||||
|
answer = self.ai_engine.answer_question(question, context)
|
||||||
|
self.overlay.show_answer(answer, question)
|
||||||
|
self.logger.info(f"Q: {question}")
|
||||||
|
self.logger.info(f"A: {answer}")
|
||||||
|
print(f"💡 Answer: {answer}\n")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Manual terminal input for testing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _terminal_input_loop(self):
|
||||||
|
print("\n Type a question and press Enter to test the AI directly.")
|
||||||
|
print(" (Leave blank to skip)\n")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = input()
|
||||||
|
if line.strip():
|
||||||
|
self._generate_answer(line.strip())
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
break
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Run / shutdown
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
print("\n✅ Meeting Assistant Ready!")
|
||||||
|
print("=" * 50)
|
||||||
|
print("🎤 Listening for questions via microphone")
|
||||||
|
print(" Answers appear in the overlay (top-right)")
|
||||||
|
print(" Type a question here + Enter to test AI")
|
||||||
|
print(" Ctrl+C to stop")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
self.overlay.start()
|
||||||
|
self.audio_listener.start()
|
||||||
|
|
||||||
|
# Terminal input runs in a background daemon thread
|
||||||
|
t = threading.Thread(target=self._terminal_input_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.overlay.app:
|
||||||
|
sys.exit(self.overlay.app.exec_())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.shutdown()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
print("\n🛑 Shutting down...")
|
||||||
|
self.audio_listener.stop()
|
||||||
|
self.overlay.stop()
|
||||||
|
print("👋 Goodbye!")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
assistant = MeetingAssistant()
|
||||||
|
assistant.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
42
models/whisper/README.md
Normal file
42
models/whisper/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
language:
|
||||||
|
- en
|
||||||
|
tags:
|
||||||
|
- audio
|
||||||
|
- automatic-speech-recognition
|
||||||
|
license: mit
|
||||||
|
library_name: ctranslate2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Whisper base.en model for CTranslate2
|
||||||
|
|
||||||
|
This repository contains the conversion of [openai/whisper-base.en](https://huggingface.co/openai/whisper-base.en) to the [CTranslate2](https://github.com/OpenNMT/CTranslate2) model format.
|
||||||
|
|
||||||
|
This model can be used in CTranslate2 or projects based on CTranslate2 such as [faster-whisper](https://github.com/systran/faster-whisper).
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
model = WhisperModel("base.en")
|
||||||
|
|
||||||
|
segments, info = model.transcribe("audio.mp3")
|
||||||
|
for segment in segments:
|
||||||
|
print("[%.2fs -> %.2fs] %s" % (segment.start, segment.end, segment.text))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conversion details
|
||||||
|
|
||||||
|
The original model was converted with the following command:
|
||||||
|
|
||||||
|
```
|
||||||
|
ct2-transformers-converter --model openai/whisper-base.en --output_dir faster-whisper-base.en \
|
||||||
|
--copy_files tokenizer.json --quantization float16
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the model weights are saved in FP16. This type can be changed when the model is loaded using the [`compute_type` option in CTranslate2](https://opennmt.net/CTranslate2/quantization.html).
|
||||||
|
|
||||||
|
## More information
|
||||||
|
|
||||||
|
**For more information about the original model, see its [model card](https://huggingface.co/openai/whisper-base.en).**
|
||||||
220
models/whisper/config.json
Normal file
220
models/whisper/config.json
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
{
|
||||||
|
"alignment_heads": [
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
4,
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
5,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
5,
|
||||||
|
7
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"lang_ids": [
|
||||||
|
50259,
|
||||||
|
50260,
|
||||||
|
50261,
|
||||||
|
50262,
|
||||||
|
50263,
|
||||||
|
50264,
|
||||||
|
50265,
|
||||||
|
50266,
|
||||||
|
50267,
|
||||||
|
50268,
|
||||||
|
50269,
|
||||||
|
50270,
|
||||||
|
50271,
|
||||||
|
50272,
|
||||||
|
50273,
|
||||||
|
50274,
|
||||||
|
50275,
|
||||||
|
50276,
|
||||||
|
50277,
|
||||||
|
50278,
|
||||||
|
50279,
|
||||||
|
50280,
|
||||||
|
50281,
|
||||||
|
50282,
|
||||||
|
50283,
|
||||||
|
50284,
|
||||||
|
50285,
|
||||||
|
50286,
|
||||||
|
50287,
|
||||||
|
50288,
|
||||||
|
50289,
|
||||||
|
50290,
|
||||||
|
50291,
|
||||||
|
50292,
|
||||||
|
50293,
|
||||||
|
50294,
|
||||||
|
50295,
|
||||||
|
50296,
|
||||||
|
50297,
|
||||||
|
50298,
|
||||||
|
50299,
|
||||||
|
50300,
|
||||||
|
50301,
|
||||||
|
50302,
|
||||||
|
50303,
|
||||||
|
50304,
|
||||||
|
50305,
|
||||||
|
50306,
|
||||||
|
50307,
|
||||||
|
50308,
|
||||||
|
50309,
|
||||||
|
50310,
|
||||||
|
50311,
|
||||||
|
50312,
|
||||||
|
50313,
|
||||||
|
50314,
|
||||||
|
50315,
|
||||||
|
50316,
|
||||||
|
50317,
|
||||||
|
50318,
|
||||||
|
50319,
|
||||||
|
50320,
|
||||||
|
50321,
|
||||||
|
50322,
|
||||||
|
50323,
|
||||||
|
50324,
|
||||||
|
50325,
|
||||||
|
50326,
|
||||||
|
50327,
|
||||||
|
50328,
|
||||||
|
50329,
|
||||||
|
50330,
|
||||||
|
50331,
|
||||||
|
50332,
|
||||||
|
50333,
|
||||||
|
50334,
|
||||||
|
50335,
|
||||||
|
50336,
|
||||||
|
50337,
|
||||||
|
50338,
|
||||||
|
50339,
|
||||||
|
50340,
|
||||||
|
50341,
|
||||||
|
50342,
|
||||||
|
50343,
|
||||||
|
50344,
|
||||||
|
50345,
|
||||||
|
50346,
|
||||||
|
50347,
|
||||||
|
50348,
|
||||||
|
50349,
|
||||||
|
50350,
|
||||||
|
50351,
|
||||||
|
50352,
|
||||||
|
50353,
|
||||||
|
50354,
|
||||||
|
50355,
|
||||||
|
50356
|
||||||
|
],
|
||||||
|
"suppress_ids": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
25,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
31,
|
||||||
|
58,
|
||||||
|
59,
|
||||||
|
60,
|
||||||
|
61,
|
||||||
|
62,
|
||||||
|
63,
|
||||||
|
90,
|
||||||
|
91,
|
||||||
|
92,
|
||||||
|
93,
|
||||||
|
357,
|
||||||
|
366,
|
||||||
|
438,
|
||||||
|
532,
|
||||||
|
685,
|
||||||
|
705,
|
||||||
|
796,
|
||||||
|
930,
|
||||||
|
1058,
|
||||||
|
1220,
|
||||||
|
1267,
|
||||||
|
1279,
|
||||||
|
1303,
|
||||||
|
1343,
|
||||||
|
1377,
|
||||||
|
1391,
|
||||||
|
1635,
|
||||||
|
1782,
|
||||||
|
1875,
|
||||||
|
2162,
|
||||||
|
2361,
|
||||||
|
2488,
|
||||||
|
3467,
|
||||||
|
4008,
|
||||||
|
4211,
|
||||||
|
4600,
|
||||||
|
4808,
|
||||||
|
5299,
|
||||||
|
5855,
|
||||||
|
6329,
|
||||||
|
7203,
|
||||||
|
9609,
|
||||||
|
9959,
|
||||||
|
10563,
|
||||||
|
10786,
|
||||||
|
11420,
|
||||||
|
11709,
|
||||||
|
11907,
|
||||||
|
13163,
|
||||||
|
13697,
|
||||||
|
13700,
|
||||||
|
14808,
|
||||||
|
15306,
|
||||||
|
16410,
|
||||||
|
16791,
|
||||||
|
17992,
|
||||||
|
19203,
|
||||||
|
19510,
|
||||||
|
20724,
|
||||||
|
22305,
|
||||||
|
22935,
|
||||||
|
27007,
|
||||||
|
30109,
|
||||||
|
30420,
|
||||||
|
33409,
|
||||||
|
34949,
|
||||||
|
40283,
|
||||||
|
40493,
|
||||||
|
40549,
|
||||||
|
47282,
|
||||||
|
49146,
|
||||||
|
50257,
|
||||||
|
50357,
|
||||||
|
50358,
|
||||||
|
50359,
|
||||||
|
50360,
|
||||||
|
50361
|
||||||
|
],
|
||||||
|
"suppress_ids_begin": [
|
||||||
|
220,
|
||||||
|
50256
|
||||||
|
]
|
||||||
|
}
|
||||||
101342
models/whisper/tokenizer.json
Normal file
101342
models/whisper/tokenizer.json
Normal file
File diff suppressed because it is too large
Load Diff
51864
models/whisper/vocabulary.txt
Normal file
51864
models/whisper/vocabulary.txt
Normal file
File diff suppressed because it is too large
Load Diff
35
requirements.txt
Normal file
35
requirements.txt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Core dependencies
|
||||||
|
torch
|
||||||
|
torchvision
|
||||||
|
|
||||||
|
# Audio processing
|
||||||
|
sounddevice
|
||||||
|
SpeechRecognition
|
||||||
|
webrtcvad
|
||||||
|
pyaudio; platform_system == "Windows"
|
||||||
|
|
||||||
|
# Screen capture & OCR
|
||||||
|
mss
|
||||||
|
Pillow
|
||||||
|
pytesseract
|
||||||
|
easyocr
|
||||||
|
opencv-python
|
||||||
|
|
||||||
|
# Local LLM
|
||||||
|
ctransformers
|
||||||
|
llama-cpp-python
|
||||||
|
|
||||||
|
# GUI
|
||||||
|
PyQt5
|
||||||
|
pyobjc-framework-Cocoa; platform_system == "Darwin"
|
||||||
|
pywin32; platform_system == "Windows"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pyautogui
|
||||||
|
keyboard
|
||||||
|
pyyaml
|
||||||
|
numpy
|
||||||
|
scipy
|
||||||
|
faster-whisper
|
||||||
|
pyaudio
|
||||||
|
sentence-transformers
|
||||||
14
run.bat
Normal file
14
run.bat
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starting Meeting Assistant...
|
||||||
|
|
||||||
|
REM Check Python
|
||||||
|
python --version >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Python not found. Please install Python 3.8+
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Run the assistant
|
||||||
|
python main.py
|
||||||
|
pause
|
||||||
26
run.sh
Executable file
26
run.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "🎙️ Starting Meeting Assistant..."
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
if [ -d ".venv" ]; then
|
||||||
|
source .venv/bin/activate
|
||||||
|
echo "✅ Using virtual environment: .venv"
|
||||||
|
else
|
||||||
|
echo "⚠️ No .venv found — using system Python"
|
||||||
|
echo " Run: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install PortAudio if needed (macOS)
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
if command -v brew &> /dev/null && ! brew list portaudio &> /dev/null 2>&1; then
|
||||||
|
echo "📦 Installing PortAudio via Homebrew..."
|
||||||
|
brew install portaudio
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 main.py
|
||||||
94
setup.py
Normal file
94
setup.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
First-time setup script for Meeting Assistant
|
||||||
|
Downloads required AI models and sets up environment
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
|
def install_dependencies():
|
||||||
|
"""Install Python dependencies"""
|
||||||
|
print("📦 Installing Python dependencies...")
|
||||||
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
|
||||||
|
|
||||||
|
|
||||||
|
def download_models():
|
||||||
|
"""Download AI models"""
|
||||||
|
print("🤖 Downloading AI models...")
|
||||||
|
|
||||||
|
# Create models directory
|
||||||
|
os.makedirs("models", exist_ok=True)
|
||||||
|
|
||||||
|
# Download TinyLlama (small, fast, works on CPU)
|
||||||
|
model_urls = {
|
||||||
|
"tinyllama-1.1b.Q4_K_M.gguf": "https://huggingface.co/TheBloke/TinyLlama-1.1B-GGUF/resolve/main/tinyllama-1.1b.Q4_K_M.gguf"
|
||||||
|
}
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
for model_name, url in model_urls.items():
|
||||||
|
model_path = os.path.join("models", model_name)
|
||||||
|
|
||||||
|
if not os.path.exists(model_path):
|
||||||
|
print(f"Downloading {model_name}...")
|
||||||
|
try:
|
||||||
|
urllib.request.urlretrieve(url, model_path)
|
||||||
|
print(f"✅ Downloaded {model_name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to download {model_name}: {e}")
|
||||||
|
print("⚠️ Will run in fallback mode without local LLM")
|
||||||
|
else:
|
||||||
|
print(f"✅ {model_name} already exists")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_audio():
|
||||||
|
"""Test audio setup"""
|
||||||
|
print("🎤 Testing audio input...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
devices = sd.query_devices()
|
||||||
|
input_devices = [d for d in devices if d['max_input_channels'] > 0]
|
||||||
|
|
||||||
|
if input_devices:
|
||||||
|
print(f"✅ Found {len(input_devices)} audio input devices")
|
||||||
|
for device in input_devices:
|
||||||
|
print(f" - {device['name']}")
|
||||||
|
else:
|
||||||
|
print("⚠️ No audio input devices found")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Audio setup error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run setup"""
|
||||||
|
print("🔧 Meeting Assistant - Setup")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
if sys.version_info < (3, 8):
|
||||||
|
print("❌ Python 3.8 or higher required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Platform info
|
||||||
|
print(f"💻 Platform: {platform.system()}")
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
install_dependencies()
|
||||||
|
|
||||||
|
# Download models
|
||||||
|
download_models()
|
||||||
|
|
||||||
|
# Setup audio
|
||||||
|
setup_audio()
|
||||||
|
|
||||||
|
print("\n✅ Setup complete!")
|
||||||
|
print("Run 'python main.py' to start the assistant")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
172
src/ai_engine.py
Normal file
172
src/ai_engine.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime
|
||||||
|
from rapidfuzz import fuzz
|
||||||
|
|
||||||
|
|
||||||
|
class AIEngine:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.model = None
|
||||||
|
|
||||||
|
self.memory = deque(maxlen=8)
|
||||||
|
self.load_model()
|
||||||
|
|
||||||
|
def load_model(self):
|
||||||
|
print("🤖 Loading AI model...")
|
||||||
|
|
||||||
|
model_path = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "models"
|
||||||
|
/ "Qwen2.5-7B-Instruct-Q4_K_M.gguf"
|
||||||
|
)
|
||||||
|
|
||||||
|
from llama_cpp import Llama
|
||||||
|
|
||||||
|
self.model = Llama(
|
||||||
|
model_path=str(model_path),
|
||||||
|
|
||||||
|
# PERFORMANCE
|
||||||
|
n_gpu_layers=-1,
|
||||||
|
n_ctx=4096,
|
||||||
|
n_batch=1024,
|
||||||
|
n_threads=max(os.cpu_count() - 1, 1),
|
||||||
|
|
||||||
|
# SPEED
|
||||||
|
offload_kqv=True,
|
||||||
|
flash_attn=True,
|
||||||
|
use_mmap=True,
|
||||||
|
use_mlock=False,
|
||||||
|
|
||||||
|
# STABILITY
|
||||||
|
verbose=False
|
||||||
|
)
|
||||||
|
|
||||||
|
print("✅ AI ready")
|
||||||
|
|
||||||
|
def answer_question(self, question, context):
|
||||||
|
question = self._normalize_question(question)
|
||||||
|
|
||||||
|
response = self._generate(question, context)
|
||||||
|
|
||||||
|
self.memory.append({
|
||||||
|
"question": question,
|
||||||
|
"response": response,
|
||||||
|
"time": datetime.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _normalize_question(self, question):
|
||||||
|
question = question.strip()
|
||||||
|
|
||||||
|
fixes = {
|
||||||
|
"jav": "java",
|
||||||
|
"py": "python",
|
||||||
|
"js": "javascript",
|
||||||
|
"api": "API"
|
||||||
|
}
|
||||||
|
|
||||||
|
words = question.split()
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
lowered = word.lower()
|
||||||
|
|
||||||
|
if lowered in fixes:
|
||||||
|
normalized.append(fixes[lowered])
|
||||||
|
else:
|
||||||
|
normalized.append(word)
|
||||||
|
|
||||||
|
question = " ".join(normalized)
|
||||||
|
|
||||||
|
# Fix cut-off endings
|
||||||
|
if question.endswith("between"):
|
||||||
|
if self.memory:
|
||||||
|
last = self.memory[-1]["question"]
|
||||||
|
question += f" and {last}"
|
||||||
|
|
||||||
|
return question
|
||||||
|
|
||||||
|
def _generate(self, question, context):
|
||||||
|
recent_audio = context.get("audio", "")[-1500:]
|
||||||
|
recent_screen = context.get("screen", "")[-700:]
|
||||||
|
|
||||||
|
memory_context = ""
|
||||||
|
|
||||||
|
if self.memory:
|
||||||
|
recent = list(self.memory)[-2:]
|
||||||
|
|
||||||
|
for item in recent:
|
||||||
|
memory_context += (
|
||||||
|
f"Q: {item['question']}\n"
|
||||||
|
f"A: {item['response']}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = """
|
||||||
|
You are a realtime meeting copilot.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Answer immediately.
|
||||||
|
- Never ask for clarification unless impossible.
|
||||||
|
- Never invent missing words.
|
||||||
|
- Never complete partial speech.
|
||||||
|
- Keep answers under 80 words.
|
||||||
|
- Prioritize only explicit meaning.
|
||||||
|
- Ignore minor transcription mistakes.
|
||||||
|
- If speech is incomplete, answer conservatively.
|
||||||
|
- Sound precise and direct.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_prompt = f"""
|
||||||
|
Recent conversation:
|
||||||
|
{memory_context}
|
||||||
|
|
||||||
|
Meeting transcript:
|
||||||
|
{recent_audio}
|
||||||
|
|
||||||
|
Screen:
|
||||||
|
{recent_screen}
|
||||||
|
|
||||||
|
User question:
|
||||||
|
{question}
|
||||||
|
"""
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"<|im_start|>system\n"
|
||||||
|
f"{system_prompt}"
|
||||||
|
f"<|im_end|>\n"
|
||||||
|
f"<|im_start|>user\n"
|
||||||
|
f"{user_prompt}"
|
||||||
|
f"<|im_end|>\n"
|
||||||
|
f"<|im_start|>assistant\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output = self.model(
|
||||||
|
prompt,
|
||||||
|
|
||||||
|
max_tokens=120,
|
||||||
|
temperature=0.2,
|
||||||
|
top_p=0.85,
|
||||||
|
repeat_penalty=1.05,
|
||||||
|
stop=["<|im_end|>"]
|
||||||
|
)
|
||||||
|
|
||||||
|
text = output["choices"][0]["text"]
|
||||||
|
|
||||||
|
return self._clean(text)
|
||||||
|
|
||||||
|
def _clean(self, text):
|
||||||
|
text = re.sub(r"<.*?>", "", text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return "No response generated."
|
||||||
|
|
||||||
|
if text[-1] not in ".!?":
|
||||||
|
text += "."
|
||||||
|
|
||||||
|
return text
|
||||||
466
src/audio_listener.py
Normal file
466
src/audio_listener.py
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
import webrtcvad
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("ctranslate2").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
os.environ["HF_HUB_DISABLE_SSL_VERIFY"] = "1"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 🔧 TUNING
|
||||||
|
# ============================================================
|
||||||
|
MIN_SPEECH_SECONDS = 1.2 # Need at least 1.2s of speech
|
||||||
|
SILENCE_SECONDS = 0.7 # 0.7s silence to end
|
||||||
|
MAX_SPEECH_SECONDS = 8.0 # Max before force
|
||||||
|
SPEECH_THRESHOLD_MULTIPLIER = 1.3
|
||||||
|
MIN_THRESHOLD_GAP = 400
|
||||||
|
CONSECUTIVE_SPEECH_TO_START = 8 # Must have 8 consecutive speech frames to start
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class AudioListener:
|
||||||
|
def __init__(self, config, callback):
|
||||||
|
self.config = config
|
||||||
|
self.callback = callback
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
self.sample_rate = 16000
|
||||||
|
self.frame_duration_ms = 30
|
||||||
|
self.frame_size = int(self.sample_rate * self.frame_duration_ms / 1000)
|
||||||
|
|
||||||
|
self.vad = webrtcvad.Vad(2)
|
||||||
|
|
||||||
|
self.audio_queue = queue.Queue()
|
||||||
|
self.current_audio = bytearray()
|
||||||
|
self.history = deque(maxlen=20)
|
||||||
|
|
||||||
|
# Speech state machine
|
||||||
|
self.is_speaking = False
|
||||||
|
self.speech_frames = 0
|
||||||
|
self.silence_frames = 0
|
||||||
|
self.consecutive_speech = 0 # Consecutive speech frames
|
||||||
|
self.consecutive_silence = 0 # Consecutive silence frames
|
||||||
|
self.total_frames_in_utterance = 0
|
||||||
|
|
||||||
|
self.noise_floor = 0
|
||||||
|
self.speech_threshold = 2000
|
||||||
|
self.silence_threshold = 1500
|
||||||
|
|
||||||
|
self.recent_rms = deque(maxlen=30)
|
||||||
|
self.speech_rms_values = []
|
||||||
|
self.peak_rms = 0
|
||||||
|
self.speech_start_time = 0
|
||||||
|
|
||||||
|
self.last_transcription_time = 0
|
||||||
|
self.min_transcription_interval = 0.5
|
||||||
|
|
||||||
|
self.calibrated = False
|
||||||
|
|
||||||
|
print("🔄 Loading Whisper model...")
|
||||||
|
self.model = WhisperModel(
|
||||||
|
"models/whisper",
|
||||||
|
device="cpu",
|
||||||
|
compute_type="int8",
|
||||||
|
local_files_only=True,
|
||||||
|
cpu_threads=max(os.cpu_count() - 1, 1)
|
||||||
|
)
|
||||||
|
print("✅ Whisper model ready")
|
||||||
|
|
||||||
|
self.command_starters = {
|
||||||
|
"what", "why", "how", "when", "where", "who", "which",
|
||||||
|
"can", "could", "would", "will", "do", "does", "did",
|
||||||
|
"is", "are", "was", "were", "should", "shall", "may",
|
||||||
|
"have", "has", "had", "am",
|
||||||
|
"tell", "explain", "describe", "compare", "define",
|
||||||
|
"find", "show", "give", "list", "name", "provide",
|
||||||
|
"write", "create", "make", "generate", "build", "code",
|
||||||
|
"calculate", "compute", "solve", "convert", "translate",
|
||||||
|
"search", "look", "check", "get", "fetch", "run",
|
||||||
|
"start", "stop", "open", "close", "save", "delete",
|
||||||
|
"draw", "plot", "graph", "print", "display", "output",
|
||||||
|
"summarize", "analyze", "review", "evaluate",
|
||||||
|
}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
print("\n🎤 Available audio devices:\n")
|
||||||
|
devices = sd.query_devices()
|
||||||
|
for i, dev in enumerate(devices):
|
||||||
|
print(f" [{i}] {dev['name']} (in: {dev['max_input_channels']}, out: {dev['max_output_channels']})")
|
||||||
|
|
||||||
|
input_device = self._select_input_device()
|
||||||
|
device_name = sd.query_devices(input_device)['name']
|
||||||
|
print(f"\n🎤 Using input device: {device_name}\n")
|
||||||
|
|
||||||
|
print("🔧 Calibrating background noise (3 seconds)...")
|
||||||
|
print(" Please stay COMPLETELY silent...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
calibration_audio = sd.rec(
|
||||||
|
int(3.0 * self.sample_rate),
|
||||||
|
samplerate=self.sample_rate,
|
||||||
|
channels=1,
|
||||||
|
dtype="int16",
|
||||||
|
device=input_device
|
||||||
|
)
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
|
cal_np = np.frombuffer(calibration_audio.tobytes(), dtype=np.int16).astype(np.float32)
|
||||||
|
|
||||||
|
chunk_size = self.frame_size
|
||||||
|
rms_values = []
|
||||||
|
for i in range(0, len(cal_np) - chunk_size, chunk_size):
|
||||||
|
chunk = cal_np[i:i + chunk_size]
|
||||||
|
rms = np.sqrt(np.mean(chunk ** 2))
|
||||||
|
rms_values.append(rms)
|
||||||
|
|
||||||
|
self.noise_floor = np.median(rms_values)
|
||||||
|
|
||||||
|
self.speech_threshold = max(
|
||||||
|
self.noise_floor * SPEECH_THRESHOLD_MULTIPLIER,
|
||||||
|
self.noise_floor + MIN_THRESHOLD_GAP
|
||||||
|
)
|
||||||
|
self.silence_threshold = self.noise_floor * 1.15
|
||||||
|
|
||||||
|
print(f"📊 Noise floor: {self.noise_floor:.0f} RMS")
|
||||||
|
print(f"📊 Speech threshold: {self.speech_threshold:.0f} RMS")
|
||||||
|
print(f"📊 Silence threshold: {self.silence_threshold:.0f} RMS")
|
||||||
|
|
||||||
|
self.calibrated = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Calibration failed: {e}")
|
||||||
|
self.noise_floor = 1500
|
||||||
|
self.speech_threshold = 2200
|
||||||
|
self.silence_threshold = 1700
|
||||||
|
self.calibrated = True
|
||||||
|
|
||||||
|
print(f"\n📋 Settings:")
|
||||||
|
print(f" Min speech: {MIN_SPEECH_SECONDS}s | Max: {MAX_SPEECH_SECONDS}s")
|
||||||
|
print(f" Silence to end: {SILENCE_SECONDS}s")
|
||||||
|
print(f" Consecutive speech to start: {CONSECUTIVE_SPEECH_TO_START} frames")
|
||||||
|
|
||||||
|
self.stream = sd.RawInputStream(
|
||||||
|
samplerate=self.sample_rate,
|
||||||
|
blocksize=self.frame_size,
|
||||||
|
dtype="int16",
|
||||||
|
channels=1,
|
||||||
|
device=input_device,
|
||||||
|
callback=self._audio_callback
|
||||||
|
)
|
||||||
|
self.stream.start()
|
||||||
|
|
||||||
|
threading.Thread(target=self._processing_loop, daemon=True).start()
|
||||||
|
|
||||||
|
print(f"\n🎤 Listening... Speak now!")
|
||||||
|
print(" Press Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
def _select_input_device(self):
|
||||||
|
devices = sd.query_devices()
|
||||||
|
default_input = sd.default.device[0]
|
||||||
|
if default_input is not None and default_input < len(devices):
|
||||||
|
dev = devices[default_input]
|
||||||
|
if dev['max_input_channels'] > 0:
|
||||||
|
return default_input
|
||||||
|
for i, dev in enumerate(devices):
|
||||||
|
if dev['max_input_channels'] > 0:
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
if hasattr(self, 'stream'):
|
||||||
|
self.stream.stop()
|
||||||
|
self.stream.close()
|
||||||
|
print("🛑 Stopped")
|
||||||
|
|
||||||
|
def _audio_callback(self, indata, frames, time_info, status):
|
||||||
|
if status:
|
||||||
|
print(f"⚠️ Audio status: {status}")
|
||||||
|
audio_bytes = bytes(indata)
|
||||||
|
rms = int(np.sqrt(np.mean(
|
||||||
|
np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) ** 2
|
||||||
|
)))
|
||||||
|
self.audio_queue.put((audio_bytes, rms))
|
||||||
|
|
||||||
|
def _processing_loop(self):
|
||||||
|
# Pre-speech buffer - capture audio BEFORE we confirm speech
|
||||||
|
pre_speech_buffer = bytearray()
|
||||||
|
pre_speech_frames = 0
|
||||||
|
MAX_PRE_SPEECH = 15 # Keep ~0.45s of audio before speech confirmation
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
audio_bytes, rms = self.audio_queue.get(timeout=1)
|
||||||
|
self.recent_rms.append(rms)
|
||||||
|
|
||||||
|
# Always keep a small buffer of recent audio
|
||||||
|
pre_speech_buffer.extend(audio_bytes)
|
||||||
|
pre_speech_frames += 1
|
||||||
|
if pre_speech_frames > MAX_PRE_SPEECH:
|
||||||
|
# Trim oldest frames
|
||||||
|
excess = pre_speech_frames - MAX_PRE_SPEECH
|
||||||
|
bytes_to_trim = excess * self.frame_size
|
||||||
|
pre_speech_buffer = pre_speech_buffer[bytes_to_trim:]
|
||||||
|
pre_speech_frames = MAX_PRE_SPEECH
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# FRAME CLASSIFICATION
|
||||||
|
# ========================
|
||||||
|
is_voice_frame = False
|
||||||
|
if rms >= self.speech_threshold:
|
||||||
|
try:
|
||||||
|
is_voice_frame = self.vad.is_speech(audio_bytes, self.sample_rate)
|
||||||
|
except:
|
||||||
|
is_voice_frame = True
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# STATE: NOT SPEAKING
|
||||||
|
# ========================
|
||||||
|
if not self.is_speaking:
|
||||||
|
if is_voice_frame:
|
||||||
|
self.consecutive_speech += 1
|
||||||
|
self.consecutive_silence = 0
|
||||||
|
|
||||||
|
# Need CONSECUTIVE_SPEECH_TO_START frames to confirm speech
|
||||||
|
if self.consecutive_speech >= CONSECUTIVE_SPEECH_TO_START:
|
||||||
|
# CONFIRMED SPEECH - start capturing
|
||||||
|
self.is_speaking = True
|
||||||
|
self.speech_start_time = time.time()
|
||||||
|
|
||||||
|
# Include pre-speech buffer for context
|
||||||
|
self.current_audio = bytearray(pre_speech_buffer)
|
||||||
|
self.speech_frames = pre_speech_frames
|
||||||
|
self.silence_frames = 0
|
||||||
|
self.speech_rms_values = list(self.recent_rms)[-pre_speech_frames:]
|
||||||
|
self.peak_rms = max(self.speech_rms_values) if self.speech_rms_values else rms
|
||||||
|
|
||||||
|
print(f"\n🔴 SPEAKING (peak: {self.peak_rms})")
|
||||||
|
else:
|
||||||
|
self.consecutive_speech = 0
|
||||||
|
self.consecutive_silence += 1
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# STATE: SPEAKING
|
||||||
|
# ========================
|
||||||
|
else:
|
||||||
|
self.current_audio.extend(audio_bytes)
|
||||||
|
self.speech_frames += 1
|
||||||
|
self.speech_rms_values.append(rms)
|
||||||
|
|
||||||
|
if rms > self.peak_rms:
|
||||||
|
self.peak_rms = rms
|
||||||
|
|
||||||
|
# Dynamic silence threshold based on actual speech levels
|
||||||
|
if len(self.speech_rms_values) > 15:
|
||||||
|
speech_median = np.median(self.speech_rms_values)
|
||||||
|
# Silence = below 60% of your speech median, or below noise-based threshold
|
||||||
|
dynamic_silence = max(
|
||||||
|
self.silence_threshold,
|
||||||
|
speech_median * 0.6
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dynamic_silence = self.silence_threshold
|
||||||
|
|
||||||
|
# Check if this frame is silence
|
||||||
|
if rms < dynamic_silence and not is_voice_frame:
|
||||||
|
self.silence_frames += 1
|
||||||
|
else:
|
||||||
|
# Reset silence counter if we hear voice
|
||||||
|
if is_voice_frame:
|
||||||
|
self.silence_frames = 0
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# DEBUG DISPLAY
|
||||||
|
# ========================
|
||||||
|
if self.is_speaking:
|
||||||
|
bar_len = max(0, min(int((rms - self.noise_floor) / 60), 35))
|
||||||
|
bar = "█" * bar_len
|
||||||
|
sil = f" 🔇{self.silence_frames}" if self.silence_frames > 0 else ""
|
||||||
|
dyn = f" thr:{dynamic_silence:.0f}" if len(self.speech_rms_values) > 15 else ""
|
||||||
|
print(f"\r🎙️ {rms:5d} |{bar}{sil}{dyn} ", end="")
|
||||||
|
elif self.consecutive_speech > 0:
|
||||||
|
print(f"\r👂 {rms:5d} | detecting... {self.consecutive_speech}/{CONSECUTIVE_SPEECH_TO_START} ",
|
||||||
|
end="")
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# TRANSCRIPTION TRIGGERS
|
||||||
|
# ========================
|
||||||
|
if self.is_speaking:
|
||||||
|
speech_duration = time.time() - self.speech_start_time
|
||||||
|
silence_duration = (self.silence_frames * self.frame_duration_ms) / 1000
|
||||||
|
|
||||||
|
# Trigger 1: Sufficient silence after minimum speech
|
||||||
|
if (speech_duration >= MIN_SPEECH_SECONDS and
|
||||||
|
silence_duration >= SILENCE_SECONDS):
|
||||||
|
print(f"\n✅ End ({speech_duration:.1f}s, peak: {self.peak_rms})")
|
||||||
|
self._safe_transcribe()
|
||||||
|
self._reset_speech_state()
|
||||||
|
pre_speech_buffer = bytearray()
|
||||||
|
pre_speech_frames = 0
|
||||||
|
|
||||||
|
# Trigger 2: Max duration
|
||||||
|
elif speech_duration >= MAX_SPEECH_SECONDS:
|
||||||
|
print(f"\n⏰ Max ({speech_duration:.1f}s, peak: {self.peak_rms})")
|
||||||
|
self._safe_transcribe()
|
||||||
|
self._reset_speech_state()
|
||||||
|
pre_speech_buffer = bytearray()
|
||||||
|
pre_speech_frames = 0
|
||||||
|
|
||||||
|
except queue.Empty:
|
||||||
|
if self.is_speaking:
|
||||||
|
elapsed = time.time() - self.speech_start_time
|
||||||
|
silence_duration = (self.silence_frames * self.frame_duration_ms) / 1000
|
||||||
|
if elapsed >= MIN_SPEECH_SECONDS and silence_duration >= SILENCE_SECONDS:
|
||||||
|
print(f"\n✅ Final ({elapsed:.1f}s)")
|
||||||
|
self._safe_transcribe()
|
||||||
|
self._reset_speech_state()
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _reset_speech_state(self):
|
||||||
|
self.is_speaking = False
|
||||||
|
self.current_audio = bytearray()
|
||||||
|
self.speech_frames = 0
|
||||||
|
self.silence_frames = 0
|
||||||
|
self.consecutive_speech = 0
|
||||||
|
self.peak_rms = 0
|
||||||
|
self.speech_rms_values = []
|
||||||
|
|
||||||
|
def _safe_transcribe(self):
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
if current_time - self.last_transcription_time < self.min_transcription_interval:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.last_transcription_time = current_time
|
||||||
|
|
||||||
|
audio_duration = len(self.current_audio) / (2 * self.sample_rate)
|
||||||
|
if audio_duration < 0.5:
|
||||||
|
print(" (Too short)")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._transcribe()
|
||||||
|
|
||||||
|
def _transcribe(self):
|
||||||
|
try:
|
||||||
|
audio_np = np.frombuffer(self.current_audio, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
segments, info = self.model.transcribe(
|
||||||
|
audio_np,
|
||||||
|
language="en",
|
||||||
|
beam_size=5,
|
||||||
|
best_of=5,
|
||||||
|
temperature=[0.0, 0.2, 0.4],
|
||||||
|
condition_on_previous_text=False,
|
||||||
|
compression_ratio_threshold=1.8,
|
||||||
|
no_speech_threshold=0.5,
|
||||||
|
log_prob_threshold=-0.8,
|
||||||
|
word_timestamps=False,
|
||||||
|
vad_filter=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_parts = []
|
||||||
|
for segment in segments:
|
||||||
|
text = segment.text.strip()
|
||||||
|
if text:
|
||||||
|
text_parts.append(text)
|
||||||
|
|
||||||
|
full_text = " ".join(text_parts)
|
||||||
|
full_text = self._clean_text(full_text)
|
||||||
|
|
||||||
|
if not full_text:
|
||||||
|
print(" (No text)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._is_repetitive(full_text):
|
||||||
|
print(f" 🚫 Repetitive")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n🎤 \"{full_text}\"")
|
||||||
|
|
||||||
|
self.history.append({
|
||||||
|
"text": full_text,
|
||||||
|
"timestamp": datetime.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
is_command = self._is_command(full_text)
|
||||||
|
tag = "🎯 Command" if is_command else "📝 Speech"
|
||||||
|
print(f"{tag}: {full_text}")
|
||||||
|
|
||||||
|
self.callback(full_text, datetime.now())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Transcription error: {e}")
|
||||||
|
|
||||||
|
def _clean_text(self, text):
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = re.sub(r'\b(\w+)( \1\b){2,}', r'\1', text, flags=re.IGNORECASE)
|
||||||
|
text = ''.join(char for char in text if char.isprintable() or char in ' \t\n\r')
|
||||||
|
if text:
|
||||||
|
text = text[0].upper() + text[1:] if len(text) > 1 else text.upper()
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _is_repetitive(self, text):
|
||||||
|
words = text.lower().split()
|
||||||
|
if len(words) < 4:
|
||||||
|
return False
|
||||||
|
unique_ratio = len(set(words)) / len(words)
|
||||||
|
if unique_ratio < 0.3 and len(words) > 8:
|
||||||
|
return True
|
||||||
|
mid = len(words) // 2
|
||||||
|
first = " ".join(words[:mid])
|
||||||
|
second = " ".join(words[mid:mid * 2])
|
||||||
|
if first == second and len(first) > 15:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_command(self, text):
|
||||||
|
lowered = text.lower().strip()
|
||||||
|
words = lowered.split()
|
||||||
|
if words and words[0] in self.command_starters:
|
||||||
|
return True
|
||||||
|
command_phrases = [
|
||||||
|
"what is", "what are", "how do", "how to", "how can",
|
||||||
|
"can you", "could you", "will you", "would you",
|
||||||
|
"tell me", "explain", "describe", "show me",
|
||||||
|
"i want", "i need", "please", "give me",
|
||||||
|
"where is", "when did", "why do", "why is",
|
||||||
|
"difference between", "compare",
|
||||||
|
"write a", "write me", "create a", "create me",
|
||||||
|
"generate", "make a", "build a", "code a",
|
||||||
|
"calculate", "compute", "solve", "find",
|
||||||
|
"list", "name", "provide", "search for",
|
||||||
|
"what does", "what would", "what if",
|
||||||
|
]
|
||||||
|
for phrase in command_phrases:
|
||||||
|
if phrase in lowered:
|
||||||
|
return True
|
||||||
|
if lowered.endswith('?'):
|
||||||
|
return True
|
||||||
|
if len(words) >= 3:
|
||||||
|
filler_words = {"um", "uh", "like", "you know", "i mean", "okay", "alright", "so", "well", "actually",
|
||||||
|
"basically"}
|
||||||
|
content_words = [w for w in words if w not in filler_words]
|
||||||
|
if len(content_words) >= 3:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
49
src/context_manager.py
Normal file
49
src/context_manager.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from collections import deque
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class ContextManager:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.audio_context = deque(maxlen=10) # Last 10 audio chunks
|
||||||
|
self.screen_context = deque(maxlen=5) # Last 5 screen captures
|
||||||
|
self.last_text = ""
|
||||||
|
|
||||||
|
def add_audio_context(self, text, timestamp):
|
||||||
|
"""Add audio transcript to context"""
|
||||||
|
self.audio_context.append({
|
||||||
|
'text': text,
|
||||||
|
'timestamp': timestamp.isoformat(),
|
||||||
|
'type': 'audio'
|
||||||
|
})
|
||||||
|
self.last_text = text
|
||||||
|
|
||||||
|
def add_screen_context(self, text, timestamp, region):
|
||||||
|
"""Add screen text to context"""
|
||||||
|
self.screen_context.append({
|
||||||
|
'text': text,
|
||||||
|
'timestamp': timestamp.isoformat(),
|
||||||
|
'region': region,
|
||||||
|
'type': 'screen'
|
||||||
|
})
|
||||||
|
self.last_text = text
|
||||||
|
|
||||||
|
def get_context(self):
|
||||||
|
"""Get current context for LLM"""
|
||||||
|
context = {
|
||||||
|
'audio': ' '.join([item['text'] for item in self.audio_context]),
|
||||||
|
'screen': ' '.join([item['text'] for item in self.screen_context]),
|
||||||
|
'recent_audio': list(self.audio_context)[-3:],
|
||||||
|
'recent_screen': list(self.screen_context)[-2:]
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_last_text(self):
|
||||||
|
"""Get last detected text"""
|
||||||
|
return self.last_text
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear all context"""
|
||||||
|
self.audio_context.clear()
|
||||||
|
self.screen_context.clear()
|
||||||
|
self.last_text = ""
|
||||||
182
src/overlay.py
Normal file
182
src/overlay.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QMainWindow,
|
||||||
|
QTextEdit,
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import Qt, QTimer, QObject, pyqtSignal
|
||||||
|
from PyQt5.QtGui import QFont, QTextCursor, QColor
|
||||||
|
|
||||||
|
|
||||||
|
class _Bridge(QObject):
|
||||||
|
"""Signal bridge so audio/AI threads can safely update the Qt UI."""
|
||||||
|
answer_ready = pyqtSignal(str, str) # answer, question
|
||||||
|
status_ready = pyqtSignal(str)
|
||||||
|
hide_now = pyqtSignal()
|
||||||
|
|
||||||
|
|
||||||
|
class InvisibleOverlay:
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.app = None
|
||||||
|
self.window = None
|
||||||
|
self.text_widget = None
|
||||||
|
self.header_label = None
|
||||||
|
self.hide_timer = None
|
||||||
|
self._bridge = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Startup
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
print("🪟 Creating overlay window...")
|
||||||
|
|
||||||
|
self.app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
# All Qt objects must be created AFTER QApplication exists
|
||||||
|
self._bridge = _Bridge()
|
||||||
|
self._bridge.answer_ready.connect(self._on_answer)
|
||||||
|
self._bridge.status_ready.connect(self._on_status)
|
||||||
|
self._bridge.hide_now.connect(self._auto_hide)
|
||||||
|
|
||||||
|
self.hide_timer = QTimer()
|
||||||
|
self.hide_timer.setSingleShot(True)
|
||||||
|
self.hide_timer.timeout.connect(self._auto_hide)
|
||||||
|
|
||||||
|
self._build_window()
|
||||||
|
print("✅ Overlay window ready")
|
||||||
|
|
||||||
|
def _build_window(self):
|
||||||
|
self.window = QMainWindow()
|
||||||
|
self.window.setWindowTitle("Meeting Assistant")
|
||||||
|
self.window.setWindowFlags(
|
||||||
|
Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint | Qt.Tool
|
||||||
|
)
|
||||||
|
self.window.setAttribute(Qt.WA_TranslucentBackground)
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
central.setStyleSheet("background: transparent;")
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
self.text_widget = QTextEdit()
|
||||||
|
self.text_widget.setReadOnly(True)
|
||||||
|
self.text_widget.setStyleSheet("""
|
||||||
|
QTextEdit {
|
||||||
|
background-color: rgba(10, 10, 10, 235);
|
||||||
|
color: #00ff99;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: Menlo, Monaco, "Courier New", monospace;
|
||||||
|
border: 1px solid rgba(0, 255, 153, 0.25);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: transparent;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: rgba(0, 255, 153, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout.addWidget(self.text_widget)
|
||||||
|
central.setLayout(layout)
|
||||||
|
self.window.setCentralWidget(central)
|
||||||
|
|
||||||
|
# Position: top-right corner
|
||||||
|
width, height = 620, 250
|
||||||
|
screen = self.app.primaryScreen().geometry()
|
||||||
|
x = screen.width() - width - 20
|
||||||
|
y = 20
|
||||||
|
self.window.setGeometry(x, y, width, height)
|
||||||
|
|
||||||
|
self.text_widget.setPlainText("Meeting Assistant Ready\n\nListening for questions...")
|
||||||
|
self.window.show()
|
||||||
|
|
||||||
|
# Must be called after show() so the native NSWindow is fully realised
|
||||||
|
self._exclude_from_screen_capture()
|
||||||
|
|
||||||
|
def _exclude_from_screen_capture(self):
|
||||||
|
"""macOS only: set NSWindowSharingNone so Zoom/screen-record can't see this window."""
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import objc
|
||||||
|
from ctypes import c_void_p
|
||||||
|
# winId() returns a pointer to the native NSView backing the Qt window
|
||||||
|
nsview = objc.objc_object(c_void_p=int(self.window.winId()))
|
||||||
|
nswindow = nsview.window()
|
||||||
|
# NSWindowSharingNone = 0 (defined in AppKit/NSWindow.h)
|
||||||
|
# This prevents ANY screen-capture process (Zoom, QuickTime, etc.) from seeing the window
|
||||||
|
nswindow.setSharingType_(0)
|
||||||
|
print("🔒 Overlay hidden from screen capture")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Screen capture exclusion unavailable: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API — safe to call from any thread
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def show_answer(self, answer, question=None):
|
||||||
|
if self._bridge:
|
||||||
|
self._bridge.answer_ready.emit(answer or "", question or "")
|
||||||
|
|
||||||
|
def show_status(self, status):
|
||||||
|
if self._bridge:
|
||||||
|
self._bridge.status_ready.emit(status or "")
|
||||||
|
|
||||||
|
def toggle_visibility(self):
|
||||||
|
if self.window:
|
||||||
|
if self.window.isVisible():
|
||||||
|
self.window.hide()
|
||||||
|
else:
|
||||||
|
self.window.show()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self.window:
|
||||||
|
self.window.close()
|
||||||
|
if self.app:
|
||||||
|
self.app.quit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Private slots — always run on the main Qt thread
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_answer(self, answer, question):
|
||||||
|
lines = []
|
||||||
|
if question:
|
||||||
|
lines.append(f"Q: {question}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"A: {answer}")
|
||||||
|
self.text_widget.setPlainText("\n".join(lines))
|
||||||
|
|
||||||
|
cursor = self.text_widget.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.Start)
|
||||||
|
self.text_widget.setTextCursor(cursor)
|
||||||
|
|
||||||
|
self.window.show()
|
||||||
|
self.window.raise_()
|
||||||
|
self.hide_timer.start(12000)
|
||||||
|
|
||||||
|
def _on_status(self, status):
|
||||||
|
self.text_widget.setPlainText(status)
|
||||||
|
self.window.show()
|
||||||
|
self.hide_timer.start(4000)
|
||||||
|
|
||||||
|
def _auto_hide(self):
|
||||||
|
if self.window:
|
||||||
|
self.window.hide()
|
||||||
|
|
||||||
|
# Keep old name for backward compatibility
|
||||||
|
def hide_overlay(self):
|
||||||
|
self._auto_hide()
|
||||||
179
src/screen_scanner.py
Normal file
179
src/screen_scanner.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# import threading
|
||||||
|
# import time
|
||||||
|
# from datetime import datetime
|
||||||
|
# import re
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# class ScreenScanner:
|
||||||
|
# def __init__(self, config, callback):
|
||||||
|
# self.config = config
|
||||||
|
# self.callback = callback
|
||||||
|
# self.running = False
|
||||||
|
# self.thread = None
|
||||||
|
# self.last_text = ""
|
||||||
|
# self.last_scan_time = 0
|
||||||
|
#
|
||||||
|
# def start(self):
|
||||||
|
# self.running = True
|
||||||
|
# self.thread = threading.Thread(target=self._scan_loop)
|
||||||
|
# self.thread.daemon = True
|
||||||
|
# self.thread.start()
|
||||||
|
# print("📸 Screen scanner started")
|
||||||
|
#
|
||||||
|
# def _is_code_or_ide_text(self, text):
|
||||||
|
# """Filter out code, IDE text, and non-question content"""
|
||||||
|
# if not text or len(text) < 15:
|
||||||
|
# return True
|
||||||
|
#
|
||||||
|
# # Patterns that indicate code or IDE text
|
||||||
|
# code_patterns = [
|
||||||
|
# r'\.py$', r'\.yaml$', r'\.txt$', r'def\s+\w+', r'class\s+\w+',
|
||||||
|
# r'import\s+\w+', r'return\s+', r'self\.', r'__init__', r'\.\.\.',
|
||||||
|
# r'{\s*$', r'}\s*$', r'\[\s*$', r'\]\s*$', r'=\s*\w+',
|
||||||
|
# r'print\(', r'if\s+.*:', r'else:', r'try:', r'except:',
|
||||||
|
# r'while\s+', r'for\s+', r'in\s+range', r'logger\.', r'\.error',
|
||||||
|
# r'\.info\(', r'\.warning', r'QObject::', r'Timer cannot'
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# # IDE/editor text patterns
|
||||||
|
# ide_patterns = [
|
||||||
|
# r'External Libraries', r'Scratches and Consoles', r'\.venv',
|
||||||
|
# r'Python \d+\.\d+', r'meeting_assistant', r'src/', r'models/',
|
||||||
|
# r'logs/', r'config\.yaml', r'requirements\.txt', r'\.\.\.$',
|
||||||
|
# r'^\s*\.{3,}', r'^\s*→', r'^\s*›', r'^\s*❓'
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# # Combine patterns
|
||||||
|
# all_patterns = code_patterns + ide_patterns
|
||||||
|
#
|
||||||
|
# for pattern in all_patterns:
|
||||||
|
# if re.search(pattern, text, re.IGNORECASE):
|
||||||
|
# return True
|
||||||
|
#
|
||||||
|
# # Check if it looks like a real question
|
||||||
|
# question_indicators = ['?', 'what', 'how', 'why', 'when', 'where', 'who',
|
||||||
|
# 'can you', 'could you', 'would you', 'please explain']
|
||||||
|
# has_question = any(indicator in text.lower() for indicator in question_indicators)
|
||||||
|
#
|
||||||
|
# # Only keep real questions with proper length
|
||||||
|
# if has_question and 15 < len(text) < 200:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# return True
|
||||||
|
#
|
||||||
|
# def _is_valid_question(self, text):
|
||||||
|
# """Check if text is a valid question worth answering"""
|
||||||
|
# # Avoid repeating the same text
|
||||||
|
# if text == self.last_text:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# # Minimum length for a real question
|
||||||
|
# if len(text) < 20 or len(text) > 300:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# # Must have question words or question mark
|
||||||
|
# question_words = ['what', 'how', 'why', 'when', 'where', 'who', 'which',
|
||||||
|
# 'can you', 'could you', 'would you', 'do you', 'is it']
|
||||||
|
#
|
||||||
|
# text_lower = text.lower()
|
||||||
|
# has_question_word = any(word in text_lower for word in question_words)
|
||||||
|
# has_question_mark = '?' in text
|
||||||
|
#
|
||||||
|
# if not (has_question_word or has_question_mark):
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# return True
|
||||||
|
#
|
||||||
|
# def _scan_loop(self):
|
||||||
|
# """Continuous screen capture and OCR"""
|
||||||
|
# import easyocr
|
||||||
|
# import mss
|
||||||
|
# import numpy as np
|
||||||
|
# from PIL import Image
|
||||||
|
#
|
||||||
|
# # Initialize OCR once
|
||||||
|
# print("📸 Initializing OCR for screen scanning...")
|
||||||
|
# try:
|
||||||
|
# reader = easyocr.Reader(['en'], gpu=False, verbose=False)
|
||||||
|
# print("✅ OCR ready")
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"⚠️ OCR initialization failed: {e}")
|
||||||
|
# print("Screen scanning disabled")
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# with mss.mss() as sct:
|
||||||
|
# while self.running:
|
||||||
|
# try:
|
||||||
|
# # Capture screen
|
||||||
|
# screenshot = sct.grab(sct.monitors[1])
|
||||||
|
#
|
||||||
|
# # Convert to PIL Image
|
||||||
|
# img = Image.frombytes("RGB", screenshot.size, screenshot.bgra, "raw", "BGRX")
|
||||||
|
#
|
||||||
|
# # Convert to numpy array
|
||||||
|
# import cv2
|
||||||
|
# img_np = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
#
|
||||||
|
# # Perform OCR - handle different return formats
|
||||||
|
# results = reader.readtext(img_np, paragraph=True)
|
||||||
|
#
|
||||||
|
# # Process results safely
|
||||||
|
# if results and len(results) > 0:
|
||||||
|
# for result in results:
|
||||||
|
# try:
|
||||||
|
# # Handle different result formats
|
||||||
|
# if len(result) == 3:
|
||||||
|
# bbox, text, confidence = result
|
||||||
|
# elif len(result) == 2:
|
||||||
|
# bbox, text = result
|
||||||
|
# confidence = 0.5
|
||||||
|
# else:
|
||||||
|
# continue
|
||||||
|
#
|
||||||
|
# # Filter and process
|
||||||
|
# if confidence > 0.4 and not self._is_code_or_ide_text(text):
|
||||||
|
# if self._is_valid_question(text):
|
||||||
|
# timestamp = datetime.now()
|
||||||
|
# print(f"❓ Question detected: {text[:100]}")
|
||||||
|
# self.callback(text, timestamp, None)
|
||||||
|
# self.last_text = text
|
||||||
|
# time.sleep(3) # Prevent rapid-fire
|
||||||
|
#
|
||||||
|
# except Exception as e:
|
||||||
|
# continue
|
||||||
|
#
|
||||||
|
# # Scan every 5 seconds
|
||||||
|
# time.sleep(5)
|
||||||
|
#
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f"Screen scan error: {e}")
|
||||||
|
# time.sleep(3)
|
||||||
|
#
|
||||||
|
# def stop(self):
|
||||||
|
# self.running = False
|
||||||
|
# if self.thread:
|
||||||
|
# self.thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenScanner:
|
||||||
|
def __init__(self, config, callback):
|
||||||
|
self.config = config
|
||||||
|
self.callback = callback
|
||||||
|
self.running = False
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
print("📸 Screen scanner is DISABLED - using audio only mode")
|
||||||
|
print(" (Screen OCR requires better filtering, will be added later)")
|
||||||
|
# Don't start scanning to avoid spam
|
||||||
|
return
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
if self.thread:
|
||||||
|
self.thread.join(timeout=2)
|
||||||
57
src/utils.py
Normal file
57
src/utils.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Setup logging configuration"""
|
||||||
|
log_dir = Path("logs")
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(log_dir / "meeting_assistant.log"),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform():
|
||||||
|
"""Detect operating system"""
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Darwin":
|
||||||
|
return "macOS"
|
||||||
|
elif system == "Windows":
|
||||||
|
return "Windows"
|
||||||
|
else:
|
||||||
|
return "Linux"
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies():
|
||||||
|
"""Check if all dependencies are installed"""
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
missing.append("torch")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import sounddevice
|
||||||
|
except ImportError:
|
||||||
|
missing.append("sounddevice")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import easyocr
|
||||||
|
except ImportError:
|
||||||
|
missing.append("easyocr")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(f"Missing dependencies: {', '.join(missing)}")
|
||||||
|
print("Run: pip install -r requirements.txt")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
Reference in New Issue
Block a user