# The codebase:
```
# ==================================================================================================
#
#   ██╗ ███████╗  ██████╗  ██████╗ ██╗██████╗ ██╗   ██╗██╗████████╗
#   ██║ ██╔════╝ ██╔═══██╗██╔═══██╗██║██╔══██╗██║   ██║██║╚══██╔══╝
#   ██║ ███████╗ ██║   ██║██║   ██║██║██████╔╝██║   ██║██║   ██║
#   ██║ ╚════██║ ██║   ██║██║   ██║██║██╔═══╝ ╚██╗ ██╔╝██║   ██║
#   ██║ ███████║ ╚██████╔╝╚██████╔╝██║██║      ╚████╔╝ ██║   ██║
#   ╚═╝ ╚══════╝  ╚═════╝  ╚═════╝ ╚═╝╚═╝       ╚═══╝  ╚═╝   ╚═╝
#   iOS Interview Co-pilot - v2.0 (Final)
#
# ==================================================================================================
#   SECTION 1: DOCUMENTATION
#
# --------------------------------------------------------------------------------------------------
#   PART 1.1: THE "WHY" - PURPOSE & USE CASE
# --------------------------------------------------------------------------------------------------
#
# This script was created for an experienced iOS developer preparing for remote job interviews.
#
# The Problem:
# Job interviews, especially for technical roles, often test for specific, memorized knowledge
# that is different from the day-to-day problem-solving skills used on the job. An experienced
# engineer might be deeply involved in complex business logic but forget the exact name of an
# API or a specific detail during a high-stress interview. This can create a false impression
# of a lack of knowledge. The user wanted a "safety net" to bridge this gap, not to cheat,
# but to augment their own expertise and relieve the anxiety of "blanking out."
#
# The Initial Idea & Its Flaw:
# The user's initial idea was to have a large document (20,000+ words) of personal notes open.
# However, manually searching such a large document during a live interview is slow, awkward,
# and unnatural. It would create a long, suspicious pause.
#
# The Solution - This "AI Co-pilot":
# This script creates a real-time intelligence augmentation tool. It listens to the entire
# interview conversation and, with a single button press, provides the user with the most
# relevant snippets from their *own* knowledge base.
#
# The Intended Workflow:
# 1. The user runs this script before a remote interview call.
# 2. The script's window is placed discreetly on the screen.
# 3. During the interview, the "Live Transcript" window shows a real-time transcription of
#    the conversation, automatically labeling who is speaking ("Me" vs. "Interviewer").
# 4. If the interviewer asks a question and the user feels nervous or fears they might
#    forget a key detail, they press the "Analyze" button.
# 5. The script instantly takes the last X minutes of the conversation and the user's
#    entire knowledge base and sends them to a powerful LLM (Gemini).
# 6. The LLM's response, extracted *directly*
#    from the user's knowledge base and relevant to the interviewer's last question,
#    appears in the "AI Assistant Notes" window and naturally continues conversation flow.
# 7. The user can then deliver a
#    confident, detailed answer.
#
#
# --------------------------------------------------------------------------------------------------
#   PART 1.2: THE "WHAT" - KEY FEATURES
# --------------------------------------------------------------------------------------------------
#
# - Dual Audio Capture: Simultaneously records from the user's microphone and the system's
#   speaker output (the interviewer's voice) as two separate streams.
# - Live Transcription & Diarization: Merges the audio streams and sends them to the Groq API
#   for ultra-fast transcription via Whisper-v3. It then uses a Voice Activity Detection
#   (VAD) algorithm to determine who spoke which part of the transcript.
# - On-Demand AI Analysis: On a button press, sends the recent transcript and a pre-loaded
#   knowledge base to the Google Gemini API for context-aware note retrieval.
# - Streaming AI Response: The AI's notes are streamed token-by-token into the UI,
#   providing near-instant feedback.
# - Customizable Knowledge Base: Loads its "brain" from a simple `knowledge_base.txt` file.
# - Microphone Amplification: Allows the user's voice to be digitally amplified to ensure
#   it's clearly distinguished from the interviewer's audio.
# - Real-time Volume Meters: Provides visual feedback on the audio levels from both the
#   microphone and the system speakers for easy debugging.
# - Debug Audio Saving: Includes a button to save the captured audio streams as `.wav` files,
#   which was critical for debugging audio driver and diarization issues.
# - Polished UI: A clean, dark-themed, resizable window built with PyQt6.
#
#
# --------------------------------------------------------------------------------------------------
#   PART 1.3: THE "HOW" - TECHNICAL IMPLEMENTATION
# --------------------------------------------------------------------------------------------------
#
# - UI Framework: PyQt6 is used for its robustness and control over desktop application styling.
#
# - Audio Backend: `sounddevice` was chosen after testing showed it was the most reliable
#   for capturing both a specific microphone by name and the system's loopback audio
#   (e.g., "Stereo Mix" or a "Monitor" of a Realtek device) on Windows. It captures audio
#   in separate threads to avoid blocking the UI.
#
# - Audio Amplification: The successful method ("Version 2") applies a NumPy-based
#   amplification factor directly to the microphone's audio chunks *as they are recorded*.
#   This ensures the audio buffer always contains the louder audio, simplifying all
#   downstream logic.
#
# - Speaker Diarization (Who spoke when?):
#   1. Voice Activity Detection (VAD): The `webrtcvad` library is run on the user's mic
#      audio and the interviewer's audio *separately* to get timestamps of speech for each.
#   2. Merged Transcription: The two audio streams are merged into one and sent to Groq.
#      Whisper transcribes the merged audio and returns text segments with timestamps.
#   3. Mapping: The script calculates the "overlap" between each Whisper text segment's
#      timestamp and the speech timestamps from the VAD step. The speaker with the most
#      overlap is assigned to that text segment.
#
# - AI & APIs:
#   - Transcription: `groq` API for Whisper-v3. Chosen for its industry-leading low latency.
#   - Analysis: `google-genai` API for Gemini 2.5 Flash. Chosen for its large context
#     window, fast response time, and excellent instruction-following capabilities.
#
# - Concurrency: Python's `threading` and PyQt6's `QThread` and `pyqtSignal` system are used
#   extensively to perform all audio recording and API calls in the background, ensuring
#   the UI remains perfectly responsive at all times.
# ==================================================================================================
#   SECTION 2: SETUP & USAGE
# ==================================================================================================
#
# 1. PREREQUISITES:
#    - Python 3.8+ installed.
#
# 2. INSTALL DEPENDENCIES:
#    pip install PyQt6 sounddevice webrtcvad-wheels groq google-genai numpy matplotlib
#
# 3. SET API KEYS:
#    Set the following as environment variables on your system:
#    - GROQ_API_KEY
#    - GOOGLE_API_KEY
#
# 4. CREATE KNOWLEDGE BASE:
#    - Create a file named `knowledge_base.txt` in the same directory as this script.
#    - Paste all of your iOS developer notes into this file.
#
# 5. CONFIGURE MODELS & AUDIO DEVICES:
#    - Run the script. If the default audio devices don't work, an error will appear
#      in the transcript window.
#    - Click the "⚙️ Settings" button.
#    - Select your microphone and the correct loopback/stereo mix device from the dropdowns.
#    - You can now also select the AI Transcription and Analysis Models from this dialog.
#    - Click "OK". The settings will be applied. Audio will restart if devices were changed.
#
# 6. RUN THE SCRIPT:
#    python copilot.py
#
# 7. CHECK PERFORMANCE:
#    - After running an analysis, check your project folder for `api_timings.json` and
#      `performance_*.png` graph files.
#
# ==================================================================================================

# --- PyQt6 must be imported first ---
from PyQt6.QtWidgets import (QApplication, QMainWindow, QTextEdit, QPushButton,
                             QVBoxLayout, QWidget, QHBoxLayout, QLabel, QProgressBar,
                             QDialog, QComboBox, QDialogButtonBox, QFormLayout)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QColor, QPalette

import sys, os, threading, queue, io, wave, time, json
from collections import deque
import numpy as np

# --- 3rd Party Libraries ---
import sounddevice as sd
import webrtcvad
from groq import Groq
from google import genai
from google.genai import types
import matplotlib
matplotlib.use('Agg') # Use a non-interactive backend for saving files
import matplotlib.pyplot as plt


# ==============================================================================
#   SECTION 3: SCRIPT CODE
# ==============================================================================

# Default audio device names. These can be changed in the settings.
DEFAULT_MIC_NAME = "Microphone (USB Audio Device)"
DEFAULT_SPEAKER_NAME = "Speakers (Realtek(R) Audio)"

# --- Configuration ---
CONFIG = {
    "SAMPLE_RATE": 16000,
    "MY_MIC_AMPLIFICATION_FACTOR": 20,
    "BUFFER_DURATION_SECONDS": 60,
    "VAD_AGGRESSIVENESS": 3,
    "VAD_FRAME_MS": 30,
    "WINDOW_OPACITY": 0.9,
    "TRANSCRIPTION_MODEL": "whisper-large-v3", # Default model, can be changed in UI settings
    "AVAILABLE_TRANSCRIPTION_MODELS": [
        "whisper-large-v3",
        "distil-whisper-large-v3-en",
        # Note: "whisper-large-v3-turbo" is a user-provided name. Verify its availability on Groq.
        "whisper-large-v3-turbo",
    ],
    "ANALYSIS_MODEL": "gemini-2.5-flash", # Default model, can be changed in UI settings
    "AVAILABLE_ANALYSIS_MODELS": [
        "gemini-2.5-flash",
        "gemini-2.5-pro",
        "gemini-2.5-flash-lite-preview-06-17"
    ],
    "KNOWLEDGE_BASE_FILE": "knowledge_base.txt"
}
CONFIG["BLOCK_SIZE"] = int(CONFIG["SAMPLE_RATE"] * (CONFIG["VAD_FRAME_MS"] / 1000.0))

# --- Load API Keys ---
try:
    GROQ_API_KEY, GOOGLE_API_KEY = os.environ["GROQ_API_KEY"], os.environ["GOOGLE_API_KEY"]
except KeyError as e:
    print(f"FATAL ERROR: Environment variable {e} not set."); sys.exit(1)

# --- Performance Logging and Graphing ---
class PerformanceLogger:
    def __init__(self, log_file="api_timings.json", max_entries=50):
        self.log_file = log_file
        self.max_entries = max_entries
        self.timings = self._load_timings()

    def _load_timings(self):
        try:
            with open(self.log_file, 'r') as f:
                return json.load(f)
        except (FileNotFoundError, json.JSONDecodeError):
            return {"groq": {}, "gemini": {}}

    def add_timing(self, api_type, model_name, duration):
        if model_name not in self.timings.get(api_type, {}):
            self.timings.setdefault(api_type, {})[model_name] = []
        
        model_timings = self.timings[api_type][model_name]
        model_timings.append(duration)
        if len(model_timings) > self.max_entries:
            self.timings[api_type][model_name] = model_timings[-self.max_entries:]
        print(f"Logged timing: {api_type}/{model_name} - {duration:.3f}s. Total entries: {len(self.timings[api_type][model_name])}")

    def _save_timings(self):
        with open(self.log_file, 'w') as f:
            json.dump(self.timings, f, indent=4)

    def generate_and_save_all(self):
        self._save_timings()
        print("Generating performance graphs...")
        for api_type, models in self.timings.items():
            for model_name, durations in models.items():
                if not durations: continue
                try:
                    plt.figure(figsize=(10, 6))
                    x_values = range(1, len(durations) + 1)
                    plt.plot(x_values, durations, marker='o', linestyle='-', label='Response Time')
                    avg_time = sum(durations) / len(durations)
                    plt.axhline(y=avg_time, color='r', linestyle='--', label=f'Average: {avg_time:.2f}s')
                    plt.title(f'API Performance: {api_type.capitalize()} - {model_name}')
                    plt.xlabel(f'Request # (Last {len(durations)})'); plt.ylabel('Response Time (s)')
                    plt.legend(); plt.grid(True)
                    safe_model_name = model_name.replace('/', '_').replace(':', '_')
                    filename = f"performance_{api_type}_{safe_model_name}.png"
                    plt.savefig(filename); plt.close()
                    print(f" -> Saved graph: {filename}")
                except Exception as e:
                    print(f"Could not generate graph for {model_name}: {e}")
        print("Graph generation complete.")

# --- Audio/Worker Threads ---
class AudioRecorder(threading.Thread):
    def __init__(self, device_index, audio_queue):
        super().__init__(daemon=True)
        self.device_index, self.audio_queue, self._running = device_index, audio_queue, threading.Event()
        self._running.set()
    def stop(self): self._running.clear()
    def _callback(self, indata, frames, time, status):
        if status: print(f"Audio Stream Status: {status}", file=sys.stderr)
        self.audio_queue.put(indata.copy())
    def run(self):
        try:
            with sd.InputStream(samplerate=CONFIG["SAMPLE_RATE"], blocksize=CONFIG["BLOCK_SIZE"], device=self.device_index, channels=1, dtype='int16', callback=self._callback) as stream:
                while self._running.is_set(): sd.sleep(100)
        except Exception as e: print(f"FATAL ERROR in audio thread for device {self.device_index}: {e}")

class TranscriptionWorker(QThread):
    transcript_ready = pyqtSignal(str, float); error = pyqtSignal(str)
    def __init__(self, my_audio_data, interviewer_audio_data, start_time):
        super().__init__()
        self.my_audio_data, self.interviewer_audio_data = my_audio_data, interviewer_audio_data
        self.groq_client = Groq(api_key=GROQ_API_KEY); self.start_time = start_time
    def run(self):
        try:
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - Starting VAD processing...")
            my_speech_times = self._get_speech_timestamps(self.my_audio_data)
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - VAD 'Me' complete.")
            interviewer_speech_times = self._get_speech_timestamps(self.interviewer_audio_data)
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - VAD 'Interviewer' complete.")
            merged_audio_file = self._merge_audio(self.my_audio_data, self.interviewer_audio_data)
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - Audio merged. Starting transcription...")
            transcription_start_time = time.time()
            transcript_segments = self._transcribe(merged_audio_file)
            transcription_duration = time.time() - transcription_start_time
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - Transcription complete ({transcription_duration:.3f}s). Starting diarization...")
            labeled_transcript = self._diarize(transcript_segments, my_speech_times, interviewer_speech_times)
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - Diarization complete. Emitting transcript.")
            self.transcript_ready.emit(labeled_transcript, transcription_duration)
        except Exception as e: self.error.emit(f"Transcription Error: {e}")
    def _get_speech_timestamps(self, audio_data):
        vad, sr = webrtcvad.Vad(CONFIG["VAD_AGGRESSIVENESS"]), CONFIG["SAMPLE_RATE"]
        fd_ms, bps = CONFIG["VAD_FRAME_MS"], 2; f_size = int(sr * (fd_ms / 1000.0) * bps)
        ts, is_s, s_start = [], False, 0
        for i in range(0, len(audio_data), f_size):
            frame = audio_data[i:i+f_size]
            if len(frame) < f_size: break
            try:
                if vad.is_speech(frame, sr):
                    if not is_s: is_s, s_start = True, (i / bps) / sr
                elif is_s: is_s = False; ts.append((s_start, (i / bps) / sr))
            except: continue
        if is_s: ts.append((s_start, (len(audio_data) / bps) / sr))
        return ts
    @staticmethod
    def _merge_audio(my_data, int_data):
        my_np, int_np = np.frombuffer(my_data, dtype=np.int16), np.frombuffer(int_data, dtype=np.int16)
        min_len = min(len(my_np), len(int_np)); merged_np = np.clip((my_np[:min_len].astype(np.float32) + int_np[:min_len].astype(np.float32)) / 2, -32768, 32767).astype(np.int16)
        mem_file = io.BytesIO()
        with wave.open(mem_file, 'wb') as wf: wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(CONFIG["SAMPLE_RATE"]); wf.writeframes(merged_np.tobytes())
        mem_file.seek(0); return mem_file
    def _transcribe(self, audio_file): return self.groq_client.audio.transcriptions.create(file=("interview.wav", audio_file.read()), model=CONFIG["TRANSCRIPTION_MODEL"], language="en", response_format="verbose_json", temperature=0.0).segments
    def _diarize(self, segments, my_times, int_times):
        txt = ""
        for seg in segments:
            s_start, s_end = seg['start'], seg['end']
            my_o = sum(max(0, min(s_end, t_e) - max(s_start, t_s)) for t_s, t_e in my_times)
            int_o = sum(max(0, min(s_end, t_e) - max(s_start, t_s)) for t_s, t_e in int_times)
            spk = "Interviewer" if my_o < int_o + 0.1 else "Me"; txt += f"**{spk}:** {seg['text'].strip()}\n\n"
        return txt

class GeminiWorker(QThread):
    chunk_received, analysis_finished, error = pyqtSignal(str), pyqtSignal(float), pyqtSignal(str)
    def __init__(self, prompt, start_time):
        super().__init__()
        self.prompt, self.start_time, self.first_chunk_logged = prompt, start_time, False
    def run(self):
        worker_start_time = time.time()
        try:
            now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - Gemini: Calling API...")
            client = genai.Client(api_key=GOOGLE_API_KEY); contents = [types.Content(role="user", parts=[types.Part.from_text(text=self.prompt)])]
            current_model = CONFIG["ANALYSIS_MODEL"]
            thinking_budget = 128 if "gemini-2.5-pro" in current_model else 0
            if thinking_budget > 0: print(f"Using thinking_budget={thinking_budget} for {current_model}")
            generate_content_config = types.GenerateContentConfig(thinking_config=types.ThinkingConfig(thinking_budget=thinking_budget), response_mime_type="text/plain")
            stream = client.models.generate_content_stream(model=current_model, contents=contents, config=generate_content_config)
            for chunk in stream:
                if chunk.text:
                    if not self.first_chunk_logged:
                        now = time.time(); print(f"[PROFILING] {now - self.start_time:.3f}s - Gemini: FIRST CHUNK RECEIVED!")
                        self.first_chunk_logged = True
                    self.chunk_received.emit(chunk.text)
        except Exception as e: self.error.emit(f"Gemini Error: {e}")
        finally:
            duration = time.time() - worker_start_time
            self.analysis_finished.emit(duration)

# --- Settings Dialog ---
class SettingsDialog(QDialog):
    def __init__(self, parent=None, current_mic="", current_loopback="", current_analysis_model="", current_transcription_model=""):
        super().__init__(parent)
        self.setWindowTitle("Settings"); self.setModal(True)
        self.mic_name, self.loopback_name, self.analysis_model, self.transcription_model = None, None, None, None
        self.mics, self.loopbacks = self._get_devices()
        self.mic_combo = QComboBox(); self.mic_combo.addItems([name for name, idx in self.mics])
        if current_mic in [name for name, idx in self.mics]: self.mic_combo.setCurrentText(current_mic)
        self.loopback_combo = QComboBox(); self.loopback_combo.addItems([name for name, idx in self.loopbacks])
        if current_loopback in [name for name, idx in self.loopbacks]: self.loopback_combo.setCurrentText(current_loopback)
        self.analysis_model_combo = QComboBox(); self.analysis_model_combo.addItems(CONFIG["AVAILABLE_ANALYSIS_MODELS"])
        if current_analysis_model in CONFIG["AVAILABLE_ANALYSIS_MODELS"]: self.analysis_model_combo.setCurrentText(current_analysis_model)
        self.transcription_model_combo = QComboBox(); self.transcription_model_combo.addItems(CONFIG["AVAILABLE_TRANSCRIPTION_MODELS"])
        if current_transcription_model in CONFIG["AVAILABLE_TRANSCRIPTION_MODELS"]: self.transcription_model_combo.setCurrentText(current_transcription_model)
        layout = QFormLayout(self)
        layout.addRow("Your Microphone:", self.mic_combo); layout.addRow("Interviewer's Audio (Loopback):", self.loopback_combo)
        layout.addRow("AI Analysis Model:", self.analysis_model_combo); layout.addRow("AI Transcription Model:", self.transcription_model_combo)
        self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        self.button_box.accepted.connect(self.accept); self.button_box.rejected.connect(self.reject); layout.addWidget(self.button_box)
    def _get_devices(self):
        devices = sd.query_devices()
        mics = [(d['name'], i) for i, d in enumerate(devices) if d['max_input_channels'] > 0]
        loopbacks = [(d['name'], i) for i, d in enumerate(devices) if d['max_input_channels'] > 0 and ('loopback' in d['name'].lower() or 'stereo mix' in d['name'].lower() or 'what u hear' in d['name'].lower() or 'monitor' in d['name'].lower())]
        if not loopbacks: loopbacks = mics
        return mics, loopbacks
    def accept(self):
        self.mic_name, self.loopback_name = self.mic_combo.currentText(), self.loopback_combo.currentText()
        self.analysis_model, self.transcription_model = self.analysis_model_combo.currentText(), self.transcription_model_combo.currentText()
        super().accept()

# --- Main Application UI ---
class InterviewCopilot(QMainWindow):
    volume_updated = pyqtSignal(str, int)
    def __init__(self):
        super().__init__()
        self.is_analyzing, self.knowledge_base_content, self.analysis_start_time = False, "", 0
        self.my_mic_name, self.interviewer_loopback_name = DEFAULT_MIC_NAME, DEFAULT_SPEAKER_NAME
        self.my_mic_device_idx, self.interviewer_loopback_device_idx = None, None
        self.my_recorder_thread, self.interviewer_recorder_thread = None, None
        self.performance_logger = PerformanceLogger()
        self.setWindowTitle("iOS Interview Co-pilot (v2.0 - Final)"); self.setGeometry(100, 100, 1000, 700); self.setWindowOpacity(CONFIG["WINDOW_OPACITY"])
        self.setup_ui(); self.load_knowledge_base(); self.setup_and_start_audio()
    def setup_ui(self):
        central_widget = QWidget(); self.setCentralWidget(central_widget); main_layout = QVBoxLayout(central_widget)

        # Content Layout (Transcript and AI Notes)
        content_layout = QHBoxLayout(); left_layout = QVBoxLayout(); right_layout = QVBoxLayout()
        self.scribe_window = QTextEdit(); self.scribe_window.setReadOnly(True); self.scribe_window.setMarkdown("**Ready to start...**"); left_layout.addWidget(self.scribe_window)
        self.prompter_window = QTextEdit(); self.prompter_window.setReadOnly(True); self.prompter_window.setMarkdown("*Your cheat sheet will appear here...*"); right_layout.addWidget(self.prompter_window)
        content_layout.addLayout(left_layout, 33); content_layout.addLayout(right_layout, 67)

        # Button Layout
        button_layout = QHBoxLayout()
        self.analyze_button = QPushButton(f"Analyze Last {int(CONFIG['BUFFER_DURATION_SECONDS']/60)} Minutes")
        self.analyze_button.clicked.connect(self.on_analyze_triggered)
        self.save_button = QPushButton("Save Debug Audio"); self.save_button.clicked.connect(self.save_debug_audio)
        self.settings_button = QPushButton("⚙️ Settings"); self.settings_button.clicked.connect(self.open_settings_dialog)
        button_layout.addWidget(self.analyze_button); button_layout.addWidget(self.save_button); button_layout.addWidget(self.settings_button)

        # Volume/Activity Layout
        volume_layout = QHBoxLayout(); me_volume_layout = QVBoxLayout(); interviewer_volume_layout = QVBoxLayout()
        me_volume_layout.addWidget(QLabel("Me (Mic Activity)")); self.me_volume_bar = QProgressBar(); self.me_volume_bar.setRange(0, 100); self.me_volume_bar.setTextVisible(False); me_volume_layout.addWidget(self.me_volume_bar)
        interviewer_volume_layout.addWidget(QLabel("Interviewer (Speaker Activity)")); self.interviewer_volume_bar = QProgressBar(); self.interviewer_volume_bar.setRange(0, 100); self.interviewer_volume_bar.setTextVisible(False); interviewer_volume_layout.addWidget(self.interviewer_volume_bar)
        volume_layout.addLayout(me_volume_layout); volume_layout.addLayout(interviewer_volume_layout)

        # Add layouts to main layout in the correct order
        main_layout.addLayout(content_layout)
        main_layout.addLayout(button_layout)
        main_layout.addLayout(volume_layout)

        # UI Styling and Signal Connection
        palette = self.palette(); palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)); palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white); palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)); palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white); palette.setColor(QPalette.ColorRole.Button, QColor(200, 200, 200)); palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black); self.setPalette(palette)
        self.volume_updated.connect(self.update_volume_meter)
    def load_knowledge_base(self):
        try:
            with open(CONFIG["KNOWLEDGE_BASE_FILE"], 'r', encoding='utf-8') as f: self.knowledge_base_content = f.read()
            if not self.knowledge_base_content: self.scribe_window.append("\n**WARNING:** 'knowledge_base.txt' is empty!")
            print(f"Knowledge base loaded ({len(self.knowledge_base_content)} chars).")
        except FileNotFoundError: self.scribe_window.append(f"\n**FATAL ERROR:** '{CONFIG['KNOWLEDGE_BASE_FILE']}' not found.")
    def update_volume_meter(self, speaker_name, volume): self.me_volume_bar.setValue(volume) if speaker_name == "Me" else self.interviewer_volume_bar.setValue(volume)
    def setup_and_start_audio(self):
        try:
            self.my_mic_device_idx, self.interviewer_loopback_device_idx = find_sounddevice_indices(self.my_mic_name, self.interviewer_loopback_name)
            self.start_recording()
        except Exception as e: self.scribe_window.append(f"\n**AUDIO ERROR:** {e}\n\nPlease check your audio devices in **⚙️ Settings**."); print(f"Initial audio setup failed: {e}")
    def open_settings_dialog(self):
        dialog = SettingsDialog(self, self.my_mic_name, self.interviewer_loopback_name, CONFIG["ANALYSIS_MODEL"], CONFIG["TRANSCRIPTION_MODEL"])
        if dialog.exec():
            new_mic, new_loopback = dialog.mic_name, dialog.loopback_name
            new_analysis_model, new_transcription_model = dialog.analysis_model, dialog.transcription_model
            audio_changed = new_mic != self.my_mic_name or new_loopback != self.interviewer_loopback_name
            analysis_model_changed = new_analysis_model != CONFIG["ANALYSIS_MODEL"]
            transcription_model_changed = new_transcription_model != CONFIG["TRANSCRIPTION_MODEL"]
            if analysis_model_changed:
                print(f"New analysis model selected: '{new_analysis_model}'")
                CONFIG["ANALYSIS_MODEL"] = new_analysis_model
                self.scribe_window.append(f"\n*AI Analysis Model set to: **{new_analysis_model}***")
            if transcription_model_changed:
                print(f"New transcription model selected: '{new_transcription_model}'")
                CONFIG["TRANSCRIPTION_MODEL"] = new_transcription_model
                self.scribe_window.append(f"\n*AI Transcription Model set to: **{new_transcription_model}***")
            if audio_changed:
                print(f"New audio settings selected. Mic: '{new_mic}', Loopback: '{new_loopback}'")
                self.my_mic_name, self.interviewer_loopback_name = new_mic, new_loopback
                self.scribe_window.append("\n*Audio settings changed. Restarting recording...*"); self.restart_recording()
    def stop_recording(self):
        print("Stopping recording threads...")
        if self.my_recorder_thread and self.my_recorder_thread.is_alive(): self.my_recorder_thread.stop(); self.my_recorder_thread.join(timeout=1)
        if self.interviewer_recorder_thread and self.interviewer_recorder_thread.is_alive(): self.interviewer_recorder_thread.stop(); self.interviewer_recorder_thread.join(timeout=1)
        self.my_recorder_thread, self.interviewer_recorder_thread = None, None
        print("Recording stopped.")
    def restart_recording(self): self.stop_recording(); self.setup_and_start_audio()
    def start_recording(self):
        buffer_size_samples = int(CONFIG["SAMPLE_RATE"] * CONFIG["BUFFER_DURATION_SECONDS"])
        self.my_audio_buffer = deque(maxlen=buffer_size_samples * 2); self.interviewer_audio_buffer = deque(maxlen=buffer_size_samples * 2)
        self.my_audio_queue, self.interviewer_audio_queue = queue.Queue(), queue.Queue()
        self.my_recorder_thread = AudioRecorder(self.my_mic_device_idx, self.my_audio_queue)
        self.interviewer_recorder_thread = AudioRecorder(self.interviewer_loopback_device_idx, self.interviewer_audio_queue)
        self.my_recorder_thread.start(); self.interviewer_recorder_thread.start()
        threading.Thread(target=self._fill_buffer, args=(self.my_audio_queue, self.my_audio_buffer, "Me"), daemon=True).start()
        threading.Thread(target=self._fill_buffer, args=(self.interviewer_audio_queue, self.interviewer_audio_buffer, "Interviewer"), daemon=True).start()
        print("Recording started.")
    def _fill_buffer(self, q, buffer, speaker_name):
        while True:
            data_np = q.get()
            if speaker_name == "Me":
                amp_factor = CONFIG["MY_MIC_AMPLIFICATION_FACTOR"]
                if amp_factor != 1.0:
                    amplified_np = np.clip(data_np.astype(np.float32) * amp_factor, -32768, 32767).astype(np.int16)
                    buffer.extend(amplified_np.tobytes()); data_np = amplified_np
                else: buffer.extend(data_np.tobytes())
            else: buffer.extend(data_np.tobytes())
            rms = np.sqrt(np.mean(data_np.astype(np.float64)**2)) if data_np.size > 0 else 0
            self.volume_updated.emit(speaker_name, int(min(1.0, rms / 3000) * 100))
    def save_debug_audio(self):
        print("Saving debug audio files... (Buffer is already amplified)")
        my_data, int_data = bytes(self.my_audio_buffer), bytes(self.interviewer_audio_buffer)
        self._write_wav_file("debug_me.wav", my_data); self._write_wav_file("debug_interviewer.wav", int_data)
        if my_data and int_data: self._write_wav_file("debug_merged.wav", TranscriptionWorker._merge_audio(my_data, int_data).read())
        self.scribe_window.append("\n*Debug audio files saved.*"); print(" -> Debug files saved.")
    def _write_wav_file(self, filename, audio_data):
        with wave.open(filename, 'wb') as wf: wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(CONFIG["SAMPLE_RATE"]); wf.writeframes(audio_data)
    def on_analyze_triggered(self):
        if self.is_analyzing: return
        self.is_analyzing = True; self.analysis_start_time = time.time()
        print(f"\n[PROFILING] {self.analysis_start_time - self.analysis_start_time:.3f}s - Analysis triggered. Capturing audio buffers...")
        self.analyze_button.setText("Analyzing..."); self.analyze_button.setEnabled(False)
        self.scribe_window.setMarkdown("*Processing audio...*"); self.prompter_window.setMarkdown("*Generating notes...*")
        my_data, interviewer_data = bytes(self.my_audio_buffer), bytes(self.interviewer_audio_buffer)
        self.transcription_worker = TranscriptionWorker(my_data, interviewer_data, self.analysis_start_time)
        self.transcription_worker.transcript_ready.connect(self.on_transcript_received); self.transcription_worker.error.connect(self.on_worker_error); self.transcription_worker.start()
    def on_transcript_received(self, transcript, duration):
        now = time.time(); print(f"[PROFILING] {now - self.analysis_start_time:.3f}s - Transcript received. Triggering Gemini analysis...")
        self.scribe_window.setMarkdown(transcript)
        self.performance_logger.add_timing("groq", CONFIG["TRANSCRIPTION_MODEL"], duration)
        self.trigger_gemini_analysis(transcript)
    def trigger_gemini_analysis(self, transcript):
        prompt = f"""
-- KNOWLEDGE BASE START ---
{self.knowledge_base_content}
-- KNOWLEDGE BASE END ---
You are a world-class AI assistant for an experienced iOS Developer who is in a job interview RIGHT NOW. Your task is to act as an instant "cheat sheet" retriever.
Above is the developer's personal, comprehensive knowledge base on iOS development.
Below is the most recent {CONFIG["BUFFER_DURATION_SECONDS"]} seconds of transcript of the interview conversation that is happening right now.
Transcription is done by Whisper-v3, so it is NOT very accurate, and may(and sometimes will) contain errors, especially when it comes to technical terms (e.g. "Combine" may be transcribed as "combined", "SwiftUI" as "swift UI", etc., or even more severe and less obvious mistakes).
--- TRANSCRIPT START ---
{transcript}
--- TRANSCRIPT END ---
The interviewer has just asked their last question.
Based on the interviewer's final question and the immediate context, find the most relevant, concise Q A D block from the KNOWLEDGE BASE.
Your output MUST be **Conversational, but not too much:** You can see that the KNOWLEDGE BASE is a bit conversational already, but not everywhere and sometimes lacks it. Your task is to **PERFECTLY MIMIC** the conversational style that is **already present** in the KNOWLEDGE BASE, but add more of **same** conversational style where needed. Add some conversational words where needed(e.g. "Well...", "So..." in the beginning of an answer, or "also", "and" or others that fit well in between sentences to "glue" them and make the answer "seem" more natural after the "." sign pause), at the same time all explanations should remain same. The answer that you will provide is supposed to be spoken "as is" live on the interview, so it shouldn't feel robotic or AI-generated.
So basically I expect you to provide me a relevant "A" block, but slightly reworked as I've described just above.
Answer should not contain any bullet points.
Answer should naturally continue the conversation based on the interviewer's final question & immediate context.
See the question about differences between classes and structs in the KNOWLEDGE BASE? That's a good example of conversational style.
See the question about Encapsulation? The answer for it looks not conversational enough. Here's the example of what I would expect from you as an output:
---
Encapsulation is all about access control modifiers, so we can apply them to a method, or a property, or the entire object like class, struct or enum. And how that works is very simple: so if something is private – it is accessible only in current scope. And if it's fileprivate – it's accessible in the current scope + the current file. And if it's internal — same story as fileprivate, but + the current module. And if it's public - again, same story as internal, but + all other modules in the project. And open is same as public but it also allows inheritance.
And internal encapsulation is the default one. What else can I tell... Also things that are public or open – they are included in the protocol most of the time, because they need to be accessed somehow.
And encapsulation can be very flexible, for example we can make some property public private(set), so it will be public for getting but private for setting.
---
"""
        self.prompter_window.clear()
        self.gemini_worker = GeminiWorker(prompt, self.analysis_start_time)
        self.gemini_worker.chunk_received.connect(self.prompter_window.insertPlainText)
        self.gemini_worker.analysis_finished.connect(self.on_analysis_finished); self.gemini_worker.error.connect(self.on_worker_error); self.gemini_worker.start()
    def on_analysis_finished(self, duration):
        now = time.time(); print(f"[PROFILING] {now - self.analysis_start_time:.3f}s - Gemini: Full analysis complete. UI reset.")
        self.performance_logger.add_timing("gemini", CONFIG["ANALYSIS_MODEL"], duration)
        self.performance_logger.generate_and_save_all()
        self.is_analyzing = False; self.analyze_button.setText(f"Analyze Last {int(CONFIG['BUFFER_DURATION_SECONDS']/60)} Minutes"); self.analyze_button.setEnabled(True)
    def on_worker_error(self, error_message):
        if self.is_analyzing: now = time.time(); print(f"[PROFILING] {now - self.analysis_start_time:.3f}s - WORKER ERROR OCCURRED.")
        print(f"ERROR: {error_message}"); self.prompter_window.setMarkdown(f"**An error occurred:**\n\n{error_message}")
        self.is_analyzing = False; self.analyze_button.setText(f"Analyze Last {int(CONFIG['BUFFER_DURATION_SECONDS']/60)} Minutes"); self.analyze_button.setEnabled(True)

# --- Script Execution Logic ---
def find_sounddevice_indices(mic_name, speaker_name):
    print(f"Searching for audio devices: Mic='{mic_name}', Speaker='{speaker_name}'"); devices = sd.query_devices()
    mic_idx, loopback_idx = None, None
    for i, d in enumerate(devices):
        if mic_name == d['name'] and d['max_input_channels'] > 0: mic_idx = i; break
    if mic_idx is None:
        for i, d in enumerate(devices):
            if mic_name in d['name'] and d['max_input_channels'] > 0: mic_idx = i; break
    for i, d in enumerate(devices):
        if speaker_name == d['name'] and d['max_input_channels'] > 0: loopback_idx = i; break
    if loopback_idx is None:
        for i, d in enumerate(devices):
            if speaker_name in d['name'] and d['max_input_channels'] > 0: loopback_idx = i; break
    if loopback_idx is None:
        print("Specific loopback device not found, checking for common names...")
        speaker_name_lower = speaker_name.lower()
        for i, d in enumerate(devices):
            if d['max_input_channels'] > 0 and i != mic_idx and ('loopback' in d['name'].lower() or 'stereo mix' in d['name'].lower() or 'what u hear' in d['name'].lower()): loopback_idx = i; break
        if loopback_idx is None:
            for i, d in enumerate(devices):
                if d['max_input_channels'] > 0 and i != mic_idx and f"monitor of {speaker_name_lower}" in d['name'].lower(): loopback_idx = i; break
    if mic_idx is None: raise RuntimeError(f"Could not find Mic '{mic_name}'")
    if loopback_idx is None: raise RuntimeError(f"Could not find a loopback device for '{speaker_name}'.\n -> TIP: Try another device in settings. Ensure 'Stereo Mix' or similar is enabled in OS Sound settings.")
    print(f"  [OK] Found Mic: '{devices[mic_idx]['name']}' at index {mic_idx}")
    print(f"  [OK] Found Loopback: '{devices[loopback_idx]['name']}' at index {loopback_idx}")
    return (mic_idx, loopback_idx)

if __name__ == '__main__':
    try:
        app = QApplication(sys.argv)
        copilot = InterviewCopilot()
        copilot.show()
        sys.exit(app.exec())
    except Exception as e:
        print(f"\nFATAL UNHANDLED ERROR: {e}")
        try:
            app = QApplication(sys.argv)
            error_dialog = QTextEdit(f"<h1>Application Failed to Start</h1><hr><pre>{e}</pre>")
            error_dialog.setWindowTitle("Fatal Error"); error_dialog.setReadOnly(True); error_dialog.resize(600, 200); error_dialog.show()
            sys.exit(app.exec())
        except: sys.exit(1)
```

# Prompt Template: Shell Script Explanation.
**This part of the prompt most likely was just copy-pasted here**
I am very lazy and don't have much time to manually copy and paste code blocks you provide. Because of that, you will provide me a single shell script in one code block, which is supposed to be executed from the root project directory. The script must delete or create necessary files/directories and do everything for me (instead of me) in one command.

The root project directory contains a git repository, so you must not delete it or do anything that could corrupt it.

Creating Directories


1. To create new directories, use
```
mkdir -p Sources/MyFeature/Views
```

2. For new files or when a file needs a complete/significant rewrite(approximately more than 50% of lines of code in the file need a rewrite), use a quoted here-document with cat:

```
cat > Sources/MyFeature/ViewController.swift <<'EOF'
import UIKit

class ViewController: UIViewController {
    let titleLabel = UILabel()
    // other code...
}
EOF
```
3. To remove - use the standard rm or rm -r commands:
```
rm Sources/MyFeature/Something.swift
```

4. Modifying a Part of an Existing File (Preferred for small to medium size changes, when approximately less than 50% lines of code in the file will be affected). To avoid overwriting an entire file for a small/medium change, you will use a special helper function to replace a specific block of code. You must define this function at the top of the script if you intend to use it:
```
# Helper function to replace a block of text in a file.
# It is safe and will fail if the 'find' block is not found or is found multiple times.
# Usage: replace_block "path/to/file" "BLOCK_TO_FIND" "BLOCK_TO_REPLACE_WITH"
replace_block() {
    local file_path="$1"
    local old_content="$2"
    local new_content="$3"

    python3 -c '
import sys

file_path = sys.argv
old_content = sys.argv
new_content = sys.argv

try:
    with open(file_path, "r", encoding="utf-8") as f:
        content = f.read()
except FileNotFoundError:
    print(f"Error: File not found at {file_path}", file=sys.stderr)
    sys.exit(1)

if old_content not in content:
    print(f"Error: The block to be replaced was not found in {file_path}.", file=sys.stderr)
    print("==== BLOCK NOT FOUND ====", file=sys.stderr)
    print(old_content, file=sys.stderr)
    print("=========================", file=sys.stderr)
    sys.exit(1)

if content.count(old_content) > 1:
    print(f"Error: The block to be replaced was found multiple times in {file_path}. Aborting for safety.", file=sys.stderr)
    sys.exit(1)

new_file_content = content.replace(old_content, new_content, 1)

with open(file_path, "w", encoding="utf-8") as f:
    f.write(new_file_content)
' "$file_path" "$old_content" "$new_content"
}
```



To use this function, you must first define the code to find and the code to replace it with. For this, use the `VAR=$(cat <<'EOF' ... EOF)` syntax, which is a robust way to assign multi-line strings to variables. Then, call the `replace_block` function.

**Modification Example:** Let's say you want to add a `dataSource` property to `ViewController.swift`.


```
# 1. Define the block of code you want to find. If many replacing operations required - design some other variable name so the 'FIND_BLOCK' and 'REPLACE_BLOCK' don't appear several times.
FIND_BLOCK=$(cat <<'EOF'
class ViewController: UIViewController {
    let titleLabel = UILabel()
EOF
)

# 2. Define the new block of code that will replace the old one.
REPLACE_BLOCK=$(cat <<'EOF'
class ViewController: UIViewController {
    var dataSource: MyDataSource?
    let titleLabel = UILabel()
EOF
)

# 3. Call the helper function to perform the replacement.
replace_block "Sources/MyFeature/ViewController.swift" "$FIND_BLOCK" "$REPLACE_BLOCK"
```



**Final Script Structure**  
Every script you generate must start with this boilerplate for error handling, followed by the optional replace_block helper if needed, and then the sequence of commands.

```
#!/bin/bash
set -euo pipefail
error_handler() {
  local exit_code=$?
  echo -e "\e[31mERROR OCCURRED\e[0m"
  echo "Exited with status $exit_code"
  echo "Error on line $1: command '$2'"
  exit $exit_code
}
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
if [ ! -d ".git" ]; then
    echo -e "\e[31mERROR: This script must be run from the root directory of the git repository.\e[0m"
    exit 1
fi

### PASTE HELPER FUNCTION(S) HERE IF NEEDED ###

### YOUR COMMANDS (cat, rm, replace_block, etc.) GO HERE ###

echo -e "\n\e[32m✅ Script completed successfully.\e[0m"
```


# The task:
Problem: Once you click "Analyze," the process is locked in until it finishes or fails.
What Happens: If you accidentally clicked the button, or if the analysis is taking too long and the conversation has already moved on, you have no choice but to wait.
Consequence: This is a frustrating user experience. You can't stop the request and are forced to wait for a now-irrelevant answer.
How to fix: As soon as "Analyze" button was tapped - any previous requests to Groq and Gemini should be cancelled/invalidated and not presented on the UI, but instead, a new request should be made to both Groq and Gemini.
"# Prompt Template: Shell Script Explanation" above explains the kind of answer I am expecting
"The codebase" is provided above and copilot.py is the only thing in the root folder that we're working with right now.