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