WIP: refactoring listen
This commit is contained in:
		
							parent
							
								
									a18e93583f
								
							
						
					
					
						commit
						80a3c01656
					
				@ -1,10 +1,9 @@
 | 
			
		||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
 | 
			
		||||
import { CustomizeView } from '../features/customize/CustomizeView.js';
 | 
			
		||||
import { AssistantView } from '../features/listen/AssistantView.js';
 | 
			
		||||
import { OnboardingView } from '../features/onboarding/OnboardingView.js';
 | 
			
		||||
import { AskView } from '../features/ask/AskView.js';
 | 
			
		||||
 | 
			
		||||
import '../features/listen/renderer.js';
 | 
			
		||||
import '../features/listen/renderer/renderer.js';
 | 
			
		||||
 | 
			
		||||
export class PickleGlassApp extends LitElement {
 | 
			
		||||
    static styles = css`
 | 
			
		||||
 | 
			
		||||
@ -1024,10 +1024,10 @@ function createWindows() {
 | 
			
		||||
 | 
			
		||||
        if (windowToToggle) {
 | 
			
		||||
            if (featureName === 'listen') {
 | 
			
		||||
                const liveSummaryService = require('../features/listen/liveSummaryService');
 | 
			
		||||
                if (liveSummaryService.isSessionActive()) {
 | 
			
		||||
                const listenService = global.listenService;
 | 
			
		||||
                if (listenService && listenService.isSessionActive()) {
 | 
			
		||||
                    console.log('[WindowManager] Listen session is active, closing it via toggle.');
 | 
			
		||||
                    await liveSummaryService.closeSession();
 | 
			
		||||
                    await listenService.closeSession();
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,5 @@
 | 
			
		||||
const { ipcMain, BrowserWindow } = require('electron');
 | 
			
		||||
const { makeStreamingChatCompletionWithPortkey } = require('../../common/services/aiProviderService');
 | 
			
		||||
const { getConversationHistory } = require('../listen/liveSummaryService');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider, windowPool, captureScreenshot } = require('../../electron/windowManager');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
@ -174,6 +173,12 @@ function formatConversationForPrompt(conversationTexts) {
 | 
			
		||||
    return conversationTexts.slice(-30).join('\n');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Access conversation history via the global listenService instance created in index.js
 | 
			
		||||
function getConversationHistory() {
 | 
			
		||||
    const listenService = global.listenService;
 | 
			
		||||
    return listenService ? listenService.getConversationHistory() : [];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function sendMessage(userPrompt) {
 | 
			
		||||
    if (!userPrompt || userPrompt.trim().length === 0) {
 | 
			
		||||
        console.warn('[AskService] Cannot process empty message');
 | 
			
		||||
 | 
			
		||||
@ -1,123 +0,0 @@
 | 
			
		||||
const fs = require('fs');
 | 
			
		||||
const path = require('path');
 | 
			
		||||
 | 
			
		||||
function pcmToWav(pcmBuffer, outputPath, sampleRate = 24000, channels = 1, bitDepth = 16) {
 | 
			
		||||
    const byteRate = sampleRate * channels * (bitDepth / 8);
 | 
			
		||||
    const blockAlign = channels * (bitDepth / 8);
 | 
			
		||||
    const dataSize = pcmBuffer.length;
 | 
			
		||||
 | 
			
		||||
    const header = Buffer.alloc(44);
 | 
			
		||||
 | 
			
		||||
    header.write('RIFF', 0);
 | 
			
		||||
    header.writeUInt32LE(dataSize + 36, 4);
 | 
			
		||||
    header.write('WAVE', 8);
 | 
			
		||||
 | 
			
		||||
    header.write('fmt ', 12);
 | 
			
		||||
    header.writeUInt32LE(16, 16);
 | 
			
		||||
    header.writeUInt16LE(1, 20);
 | 
			
		||||
    header.writeUInt16LE(channels, 22);
 | 
			
		||||
    header.writeUInt32LE(sampleRate, 24);
 | 
			
		||||
    header.writeUInt32LE(byteRate, 28);
 | 
			
		||||
    header.writeUInt16LE(blockAlign, 32);
 | 
			
		||||
    header.writeUInt16LE(bitDepth, 34);
 | 
			
		||||
 | 
			
		||||
    header.write('data', 36);
 | 
			
		||||
    header.writeUInt32LE(dataSize, 40);
 | 
			
		||||
 | 
			
		||||
    const wavBuffer = Buffer.concat([header, pcmBuffer]);
 | 
			
		||||
 | 
			
		||||
    fs.writeFileSync(outputPath, wavBuffer);
 | 
			
		||||
 | 
			
		||||
    return outputPath;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function analyzeAudioBuffer(buffer, label = 'Audio') {
 | 
			
		||||
    const int16Array = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.length / 2);
 | 
			
		||||
 | 
			
		||||
    let minValue = 32767;
 | 
			
		||||
    let maxValue = -32768;
 | 
			
		||||
    let avgValue = 0;
 | 
			
		||||
    let rmsValue = 0;
 | 
			
		||||
    let silentSamples = 0;
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i < int16Array.length; i++) {
 | 
			
		||||
        const sample = int16Array[i];
 | 
			
		||||
        minValue = Math.min(minValue, sample);
 | 
			
		||||
        maxValue = Math.max(maxValue, sample);
 | 
			
		||||
        avgValue += sample;
 | 
			
		||||
        rmsValue += sample * sample;
 | 
			
		||||
 | 
			
		||||
        if (Math.abs(sample) < 100) {
 | 
			
		||||
            silentSamples++;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    avgValue /= int16Array.length;
 | 
			
		||||
    rmsValue = Math.sqrt(rmsValue / int16Array.length);
 | 
			
		||||
 | 
			
		||||
    const silencePercentage = (silentSamples / int16Array.length) * 100;
 | 
			
		||||
 | 
			
		||||
    console.log(`${label} Analysis:`);
 | 
			
		||||
    console.log(`  Samples: ${int16Array.length}`);
 | 
			
		||||
    console.log(`  Min: ${minValue}, Max: ${maxValue}`);
 | 
			
		||||
    console.log(`  Average: ${avgValue.toFixed(2)}`);
 | 
			
		||||
    console.log(`  RMS: ${rmsValue.toFixed(2)}`);
 | 
			
		||||
    console.log(`  Silence: ${silencePercentage.toFixed(1)}%`);
 | 
			
		||||
    console.log(`  Dynamic Range: ${20 * Math.log10(maxValue / (rmsValue || 1))} dB`);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        minValue,
 | 
			
		||||
        maxValue,
 | 
			
		||||
        avgValue,
 | 
			
		||||
        rmsValue,
 | 
			
		||||
        silencePercentage,
 | 
			
		||||
        sampleCount: int16Array.length,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function saveDebugAudio(buffer, type, timestamp = Date.now()) {
 | 
			
		||||
    const homeDir = require('os').homedir();
 | 
			
		||||
    const debugDir = path.join(homeDir, '.pickle-glass', 'debug');
 | 
			
		||||
 | 
			
		||||
    if (!fs.existsSync(debugDir)) {
 | 
			
		||||
        fs.mkdirSync(debugDir, { recursive: true });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const pcmPath = path.join(debugDir, `${type}_${timestamp}.pcm`);
 | 
			
		||||
    const wavPath = path.join(debugDir, `${type}_${timestamp}.wav`);
 | 
			
		||||
    const metaPath = path.join(debugDir, `${type}_${timestamp}.json`);
 | 
			
		||||
 | 
			
		||||
    fs.writeFileSync(pcmPath, buffer);
 | 
			
		||||
 | 
			
		||||
    pcmToWav(buffer, wavPath);
 | 
			
		||||
 | 
			
		||||
    const analysis = analyzeAudioBuffer(buffer, type);
 | 
			
		||||
    fs.writeFileSync(
 | 
			
		||||
        metaPath,
 | 
			
		||||
        JSON.stringify(
 | 
			
		||||
            {
 | 
			
		||||
                timestamp,
 | 
			
		||||
                type,
 | 
			
		||||
                bufferSize: buffer.length,
 | 
			
		||||
                analysis,
 | 
			
		||||
                format: {
 | 
			
		||||
                    sampleRate: 24000,
 | 
			
		||||
                    channels: 1,
 | 
			
		||||
                    bitDepth: 16,
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
            null,
 | 
			
		||||
            2
 | 
			
		||||
        )
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    console.log(`Debug audio saved: ${wavPath}`);
 | 
			
		||||
 | 
			
		||||
    return { pcmPath, wavPath, metaPath };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    pcmToWav,
 | 
			
		||||
    analyzeAudioBuffer,
 | 
			
		||||
    saveDebugAudio,
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										263
									
								
								src/features/listen/listenService.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								src/features/listen/listenService.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,263 @@
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const SttService = require('./stt/sttService');
 | 
			
		||||
const SummaryService = require('./summary/summaryService');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
const sttRepository = require('./stt/repositories');
 | 
			
		||||
 | 
			
		||||
class ListenService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.sttService = new SttService();
 | 
			
		||||
        this.summaryService = new SummaryService();
 | 
			
		||||
        this.currentSessionId = null;
 | 
			
		||||
        this.isInitializingSession = false;
 | 
			
		||||
        
 | 
			
		||||
        this.setupServiceCallbacks();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setupServiceCallbacks() {
 | 
			
		||||
        // STT service callbacks
 | 
			
		||||
        this.sttService.setCallbacks({
 | 
			
		||||
            onTranscriptionComplete: (speaker, text) => {
 | 
			
		||||
                this.handleTranscriptionComplete(speaker, text);
 | 
			
		||||
            },
 | 
			
		||||
            onStatusUpdate: (status) => {
 | 
			
		||||
                this.sendToRenderer('update-status', status);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Summary service callbacks
 | 
			
		||||
        this.summaryService.setCallbacks({
 | 
			
		||||
            onAnalysisComplete: (data) => {
 | 
			
		||||
                console.log('📊 Analysis completed:', data);
 | 
			
		||||
            },
 | 
			
		||||
            onStatusUpdate: (status) => {
 | 
			
		||||
                this.sendToRenderer('update-status', status);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleTranscriptionComplete(speaker, text) {
 | 
			
		||||
        console.log(`[ListenService] Transcription complete: ${speaker} - ${text}`);
 | 
			
		||||
        
 | 
			
		||||
        // Save to database
 | 
			
		||||
        await this.saveConversationTurn(speaker, text);
 | 
			
		||||
        
 | 
			
		||||
        // Add to summary service for analysis
 | 
			
		||||
        this.summaryService.addConversationTurn(speaker, text);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async saveConversationTurn(speaker, transcription) {
 | 
			
		||||
        if (!this.currentSessionId) {
 | 
			
		||||
            console.error('[DB] Cannot save turn, no active session ID.');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        if (transcription.trim() === '') return;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            await sessionRepository.touch(this.currentSessionId);
 | 
			
		||||
            await sttRepository.addTranscript({
 | 
			
		||||
                sessionId: this.currentSessionId,
 | 
			
		||||
                speaker: speaker,
 | 
			
		||||
                text: transcription.trim(),
 | 
			
		||||
            });
 | 
			
		||||
            console.log(`[DB] Saved transcript for session ${this.currentSessionId}: (${speaker})`);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Failed to save transcript to DB:', error);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initializeNewSession() {
 | 
			
		||||
        try {
 | 
			
		||||
            const uid = authService.getCurrentUserId();
 | 
			
		||||
            if (!uid) {
 | 
			
		||||
                throw new Error("Cannot initialize session: user not logged in.");
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            this.currentSessionId = await sessionRepository.getOrCreateActive(uid, 'listen');
 | 
			
		||||
            console.log(`[DB] New listen session ensured: ${this.currentSessionId}`);
 | 
			
		||||
 | 
			
		||||
            // Set session ID for summary service
 | 
			
		||||
            this.summaryService.setSessionId(this.currentSessionId);
 | 
			
		||||
            
 | 
			
		||||
            // Reset conversation history
 | 
			
		||||
            this.summaryService.resetConversationHistory();
 | 
			
		||||
 | 
			
		||||
            console.log('New conversation session started:', this.currentSessionId);
 | 
			
		||||
            return true;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Failed to initialize new session in DB:', error);
 | 
			
		||||
            this.currentSessionId = null;
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initializeSession(language = 'en') {
 | 
			
		||||
        if (this.isInitializingSession) {
 | 
			
		||||
            console.log('Session initialization already in progress.');
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.isInitializingSession = true;
 | 
			
		||||
        this.sendToRenderer('session-initializing', true);
 | 
			
		||||
        this.sendToRenderer('update-status', 'Initializing sessions...');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            // Initialize database session
 | 
			
		||||
            const sessionInitialized = await this.initializeNewSession();
 | 
			
		||||
            if (!sessionInitialized) {
 | 
			
		||||
                throw new Error('Failed to initialize database session');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Initialize STT sessions
 | 
			
		||||
            await this.sttService.initializeSttSessions(language);
 | 
			
		||||
 | 
			
		||||
            console.log('✅ Listen service initialized successfully.');
 | 
			
		||||
            
 | 
			
		||||
            this.sendToRenderer('session-state-changed', { isActive: true });
 | 
			
		||||
            this.sendToRenderer('update-status', 'Connected. Ready to listen.');
 | 
			
		||||
            
 | 
			
		||||
            return true;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('❌ Failed to initialize listen service:', error);
 | 
			
		||||
            this.sendToRenderer('update-status', 'Initialization failed.');
 | 
			
		||||
            return false;
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.isInitializingSession = false;
 | 
			
		||||
            this.sendToRenderer('session-initializing', false);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async sendAudioContent(data, mimeType) {
 | 
			
		||||
        return await this.sttService.sendAudioContent(data, mimeType);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async startMacOSAudioCapture() {
 | 
			
		||||
        if (process.platform !== 'darwin') {
 | 
			
		||||
            throw new Error('macOS audio capture only available on macOS');
 | 
			
		||||
        }
 | 
			
		||||
        return await this.sttService.startMacOSAudioCapture();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async stopMacOSAudioCapture() {
 | 
			
		||||
        this.sttService.stopMacOSAudioCapture();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isSessionActive() {
 | 
			
		||||
        return this.sttService.isSessionActive();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async closeSession() {
 | 
			
		||||
        try {
 | 
			
		||||
            // Close STT sessions
 | 
			
		||||
            await this.sttService.closeSessions();
 | 
			
		||||
 | 
			
		||||
            // End database session
 | 
			
		||||
            if (this.currentSessionId) {
 | 
			
		||||
                await sessionRepository.end(this.currentSessionId);
 | 
			
		||||
                console.log(`[DB] Session ${this.currentSessionId} ended.`);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Reset state
 | 
			
		||||
            this.currentSessionId = null;
 | 
			
		||||
            this.summaryService.resetConversationHistory();
 | 
			
		||||
 | 
			
		||||
            this.sendToRenderer('session-state-changed', { isActive: false });
 | 
			
		||||
            this.sendToRenderer('session-did-close');
 | 
			
		||||
 | 
			
		||||
            console.log('Listen service session closed.');
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error closing listen service session:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getCurrentSessionData() {
 | 
			
		||||
        return {
 | 
			
		||||
            sessionId: this.currentSessionId,
 | 
			
		||||
            conversationHistory: this.summaryService.getConversationHistory(),
 | 
			
		||||
            totalTexts: this.summaryService.getConversationHistory().length,
 | 
			
		||||
            analysisData: this.summaryService.getCurrentAnalysisData(),
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getConversationHistory() {
 | 
			
		||||
        return this.summaryService.getConversationHistory();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setupIpcHandlers() {
 | 
			
		||||
        const { ipcMain } = require('electron');
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('is-session-active', async () => {
 | 
			
		||||
            const isActive = this.isSessionActive();
 | 
			
		||||
            console.log(`Checking session status. Active: ${isActive}`);
 | 
			
		||||
            return isActive;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
 | 
			
		||||
            console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
 | 
			
		||||
            const success = await this.initializeSession(language);
 | 
			
		||||
            return success;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
 | 
			
		||||
            try {
 | 
			
		||||
                await this.sendAudioContent(data, mimeType);
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error sending user audio:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('start-macos-audio', async () => {
 | 
			
		||||
            if (process.platform !== 'darwin') {
 | 
			
		||||
                return { success: false, error: 'macOS audio capture only available on macOS' };
 | 
			
		||||
            }
 | 
			
		||||
            try {
 | 
			
		||||
                const success = await this.startMacOSAudioCapture();
 | 
			
		||||
                return { success };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error starting macOS audio capture:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('stop-macos-audio', async () => {
 | 
			
		||||
            try {
 | 
			
		||||
                this.stopMacOSAudioCapture();
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error stopping macOS audio capture:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('close-session', async () => {
 | 
			
		||||
            return await this.closeSession();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('update-google-search-setting', async (event, enabled) => {
 | 
			
		||||
            try {
 | 
			
		||||
                console.log('Google Search setting updated to:', enabled);
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error updating Google Search setting:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        console.log('✅ Listen service IPC handlers registered');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = ListenService; 
 | 
			
		||||
@ -1,973 +0,0 @@
 | 
			
		||||
require('dotenv').config();
 | 
			
		||||
const { BrowserWindow, ipcMain } = require('electron');
 | 
			
		||||
const { spawn } = require('child_process');
 | 
			
		||||
const { saveDebugAudio } = require('./audioUtils.js');
 | 
			
		||||
const { getSystemPrompt } = require('../../common/prompts/promptBuilder.js');
 | 
			
		||||
const { connectToGeminiSession } = require('../../common/services/googleGeminiClient.js');
 | 
			
		||||
const { connectToOpenAiSession, createOpenAiGenerativeClient, getOpenAiGenerativeModel } = require('../../common/services/openAiClient.js');
 | 
			
		||||
const { makeChatCompletionWithPortkey } = require('../../common/services/aiProviderService.js');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
const listenRepository = require('./repositories');
 | 
			
		||||
 | 
			
		||||
const { getStoredApiKey, getStoredProvider } = require('../../electron/windowManager');
 | 
			
		||||
 | 
			
		||||
const MAX_BUFFER_LENGTH_CHARS = 2000;
 | 
			
		||||
const COMPLETION_DEBOUNCE_MS = 2000;
 | 
			
		||||
 | 
			
		||||
async function getApiKey() {
 | 
			
		||||
    const storedKey = await getStoredApiKey();
 | 
			
		||||
 | 
			
		||||
    if (storedKey) {
 | 
			
		||||
        console.log('[LiveSummaryService] Using stored API key');
 | 
			
		||||
        return storedKey;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const envKey = process.env.OPENAI_API_KEY;
 | 
			
		||||
    if (envKey) {
 | 
			
		||||
        console.log('[LiveSummaryService] Using environment API key');
 | 
			
		||||
        return envKey;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.error('[LiveSummaryService] No API key found in storage or environment');
 | 
			
		||||
    return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getAiProvider() {
 | 
			
		||||
    try {
 | 
			
		||||
        const { ipcRenderer } = require('electron');
 | 
			
		||||
        const provider = await ipcRenderer.invoke('get-ai-provider');
 | 
			
		||||
        return provider || 'openai';
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        // If we're in the main process, get it directly
 | 
			
		||||
        return getStoredProvider ? getStoredProvider() : 'openai';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let currentSessionId = null;
 | 
			
		||||
let conversationHistory = [];
 | 
			
		||||
let isInitializingSession = false;
 | 
			
		||||
 | 
			
		||||
let mySttSession = null;
 | 
			
		||||
let theirSttSession = null;
 | 
			
		||||
let myCurrentUtterance = '';
 | 
			
		||||
let theirCurrentUtterance = '';
 | 
			
		||||
 | 
			
		||||
let myLastPartialText = '';
 | 
			
		||||
let theirLastPartialText = '';
 | 
			
		||||
let myInactivityTimer = null;
 | 
			
		||||
let theirInactivityTimer = null;
 | 
			
		||||
const INACTIVITY_TIMEOUT = 3000;
 | 
			
		||||
 | 
			
		||||
const SESSION_IDLE_TIMEOUT_SECONDS = 30 * 60; // 30 minutes
 | 
			
		||||
 | 
			
		||||
let previousAnalysisResult = null;
 | 
			
		||||
let analysisHistory = [];
 | 
			
		||||
 | 
			
		||||
// ---------------------------------------------------------------------------
 | 
			
		||||
// 🎛️  Turn-completion debouncing
 | 
			
		||||
// ---------------------------------------------------------------------------
 | 
			
		||||
// Very aggressive VAD (e.g. 50 ms) tends to split one spoken sentence into
 | 
			
		||||
// many "completed" events.  To avoid creating a separate chat bubble for each
 | 
			
		||||
// of those micro-turns we debounce the *completed* events per speaker.  Any
 | 
			
		||||
// completions that arrive within this window are concatenated and flushed as
 | 
			
		||||
// **one** final turn.
 | 
			
		||||
 | 
			
		||||
let myCompletionBuffer = '';
 | 
			
		||||
let theirCompletionBuffer = '';
 | 
			
		||||
let myCompletionTimer = null;
 | 
			
		||||
let theirCompletionTimer = null;
 | 
			
		||||
 | 
			
		||||
function flushMyCompletion() {
 | 
			
		||||
    if (!myCompletionBuffer.trim()) return;
 | 
			
		||||
 | 
			
		||||
    const finalText = myCompletionBuffer.trim();
 | 
			
		||||
    // Save to DB & send to renderer as final
 | 
			
		||||
    saveConversationTurn('Me', finalText);
 | 
			
		||||
    sendToRenderer('stt-update', {
 | 
			
		||||
        speaker: 'Me',
 | 
			
		||||
        text: finalText,
 | 
			
		||||
        isPartial: false,
 | 
			
		||||
        isFinal: true,
 | 
			
		||||
        timestamp: Date.now(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    myCompletionBuffer = '';
 | 
			
		||||
    myCompletionTimer = null;
 | 
			
		||||
    myCurrentUtterance = ''; // Reset utterance accumulator on flush
 | 
			
		||||
    sendToRenderer('update-status', 'Listening...');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function flushTheirCompletion() {
 | 
			
		||||
    if (!theirCompletionBuffer.trim()) return;
 | 
			
		||||
 | 
			
		||||
    const finalText = theirCompletionBuffer.trim();
 | 
			
		||||
    saveConversationTurn('Them', finalText);
 | 
			
		||||
    sendToRenderer('stt-update', {
 | 
			
		||||
        speaker: 'Them',
 | 
			
		||||
        text: finalText,
 | 
			
		||||
        isPartial: false,
 | 
			
		||||
        isFinal: true,
 | 
			
		||||
        timestamp: Date.now(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    theirCompletionBuffer = '';
 | 
			
		||||
    theirCompletionTimer = null;
 | 
			
		||||
    theirCurrentUtterance = ''; // Reset utterance accumulator on flush
 | 
			
		||||
    sendToRenderer('update-status', 'Listening...');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function debounceMyCompletion(text) {
 | 
			
		||||
    // 상대방이 말하고 있던 경우, 화자가 변경되었으므로 즉시 상대방의 말풍선을 완성합니다.
 | 
			
		||||
    if (theirCompletionTimer) {
 | 
			
		||||
        clearTimeout(theirCompletionTimer);
 | 
			
		||||
        flushTheirCompletion();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    myCompletionBuffer += (myCompletionBuffer ? ' ' : '') + text;
 | 
			
		||||
 | 
			
		||||
    if (myCompletionTimer) clearTimeout(myCompletionTimer);
 | 
			
		||||
    myCompletionTimer = setTimeout(flushMyCompletion, COMPLETION_DEBOUNCE_MS);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function debounceTheirCompletion(text) {
 | 
			
		||||
    // 내가 말하고 있던 경우, 화자가 변경되었으므로 즉시 내 말풍선을 완성합니다.
 | 
			
		||||
    if (myCompletionTimer) {
 | 
			
		||||
        clearTimeout(myCompletionTimer);
 | 
			
		||||
        flushMyCompletion();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    theirCompletionBuffer += (theirCompletionBuffer ? ' ' : '') + text;
 | 
			
		||||
 | 
			
		||||
    if (theirCompletionTimer) clearTimeout(theirCompletionTimer);
 | 
			
		||||
    theirCompletionTimer = setTimeout(flushTheirCompletion, COMPLETION_DEBOUNCE_MS);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let systemAudioProc = null;
 | 
			
		||||
 | 
			
		||||
let analysisIntervalId = null;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Converts conversation history into text to include in the prompt.
 | 
			
		||||
 * @param {Array<string>} conversationTexts - Array of conversation texts ["me: ~~~", "them: ~~~", ...]
 | 
			
		||||
 * @param {number} maxTurns - Maximum number of recent turns to include
 | 
			
		||||
 * @returns {string} - Formatted conversation string for the prompt
 | 
			
		||||
 */
 | 
			
		||||
function formatConversationForPrompt(conversationTexts, maxTurns = 30) {
 | 
			
		||||
    if (conversationTexts.length === 0) return '';
 | 
			
		||||
    return conversationTexts.slice(-maxTurns).join('\n');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function makeOutlineAndRequests(conversationTexts, maxTurns = 30) {
 | 
			
		||||
    console.log(`🔍 makeOutlineAndRequests called - conversationTexts: ${conversationTexts.length}`);
 | 
			
		||||
 | 
			
		||||
    if (conversationTexts.length === 0) {
 | 
			
		||||
        console.log('⚠️ No conversation texts available for analysis');
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const recentConversation = formatConversationForPrompt(conversationTexts, maxTurns);
 | 
			
		||||
 | 
			
		||||
    // 이전 분석 결과를 프롬프트에 포함
 | 
			
		||||
    let contextualPrompt = '';
 | 
			
		||||
    if (previousAnalysisResult) {
 | 
			
		||||
        contextualPrompt = `
 | 
			
		||||
Previous Analysis Context:
 | 
			
		||||
- Main Topic: ${previousAnalysisResult.topic.header}
 | 
			
		||||
- Key Points: ${previousAnalysisResult.summary.slice(0, 3).join(', ')}
 | 
			
		||||
- Last Actions: ${previousAnalysisResult.actions.slice(0, 2).join(', ')}
 | 
			
		||||
 | 
			
		||||
Please build upon this context while analyzing the new conversation segments.
 | 
			
		||||
`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const basePrompt = getSystemPrompt('pickle_glass_analysis', '', false);
 | 
			
		||||
    const systemPrompt = basePrompt.replace('{{CONVERSATION_HISTORY}}', recentConversation);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        if (currentSessionId) {
 | 
			
		||||
            await sessionRepository.touch(currentSessionId);
 | 
			
		||||
        }
 | 
			
		||||
        const messages = [
 | 
			
		||||
            {
 | 
			
		||||
                role: 'system',
 | 
			
		||||
                content: systemPrompt,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                role: 'user',
 | 
			
		||||
                content: `${contextualPrompt}
 | 
			
		||||
 | 
			
		||||
Analyze the conversation and provide a structured summary. Format your response as follows:
 | 
			
		||||
 | 
			
		||||
**Summary Overview**
 | 
			
		||||
- Main discussion point with context
 | 
			
		||||
 | 
			
		||||
**Key Topic: [Topic Name]**
 | 
			
		||||
- First key insight
 | 
			
		||||
- Second key insight
 | 
			
		||||
- Third key insight
 | 
			
		||||
 | 
			
		||||
**Extended Explanation**
 | 
			
		||||
Provide 2-3 sentences explaining the context and implications.
 | 
			
		||||
 | 
			
		||||
**Suggested Questions**
 | 
			
		||||
1. First follow-up question?
 | 
			
		||||
2. Second follow-up question?
 | 
			
		||||
3. Third follow-up question?
 | 
			
		||||
 | 
			
		||||
Keep all points concise and build upon previous analysis if provided.`,
 | 
			
		||||
            },
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        console.log('🤖 Sending analysis request to OpenAI...');
 | 
			
		||||
 | 
			
		||||
        const API_KEY = await getApiKey();
 | 
			
		||||
        if (!API_KEY) {
 | 
			
		||||
            throw new Error('No API key available');
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const provider = getStoredProvider ? getStoredProvider() : 'openai';
 | 
			
		||||
        const loggedIn = authService.getCurrentUser().isLoggedIn; // true ➜ vKey, false ➜ apiKey
 | 
			
		||||
        const usePortkey = loggedIn && provider === 'openai'; // Only use Portkey for OpenAI with Firebase
 | 
			
		||||
        
 | 
			
		||||
        console.log(`[LiveSummary] provider: ${provider}, usePortkey: ${usePortkey}`);
 | 
			
		||||
 | 
			
		||||
        const completion = await makeChatCompletionWithPortkey({
 | 
			
		||||
            apiKey: API_KEY,
 | 
			
		||||
            provider: provider,
 | 
			
		||||
            messages: messages,
 | 
			
		||||
            temperature: 0.7,
 | 
			
		||||
            maxTokens: 1024,
 | 
			
		||||
            model: provider === 'openai' ? 'gpt-4.1' : 'gemini-2.5-flash',
 | 
			
		||||
            usePortkey: usePortkey,
 | 
			
		||||
            portkeyVirtualKey: usePortkey ? API_KEY : null
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const responseText = completion.content;
 | 
			
		||||
        console.log(`✅ Analysis response received: ${responseText}`);
 | 
			
		||||
        const structuredData = parseResponseText(responseText, previousAnalysisResult);
 | 
			
		||||
 | 
			
		||||
        if (currentSessionId) {
 | 
			
		||||
            listenRepository.saveSummary({
 | 
			
		||||
                sessionId: currentSessionId,
 | 
			
		||||
                tldr: structuredData.summary.join('\n'),
 | 
			
		||||
                bullet_json: JSON.stringify(structuredData.topic.bullets),
 | 
			
		||||
                action_json: JSON.stringify(structuredData.actions),
 | 
			
		||||
                model: 'gpt-4.1'
 | 
			
		||||
            }).catch(err => console.error('[DB] Failed to save summary:', err));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 분석 결과 저장
 | 
			
		||||
        previousAnalysisResult = structuredData;
 | 
			
		||||
        analysisHistory.push({
 | 
			
		||||
            timestamp: Date.now(),
 | 
			
		||||
            data: structuredData,
 | 
			
		||||
            conversationLength: conversationTexts.length,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // 히스토리 크기 제한 (최근 10개만 유지)
 | 
			
		||||
        if (analysisHistory.length > 10) {
 | 
			
		||||
            analysisHistory.shift();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return structuredData;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('❌ Error during analysis generation:', error.message);
 | 
			
		||||
        return previousAnalysisResult; // 에러 시 이전 결과 반환
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function parseResponseText(responseText, previousResult) {
 | 
			
		||||
    const structuredData = {
 | 
			
		||||
        summary: [],
 | 
			
		||||
        topic: { header: '', bullets: [] },
 | 
			
		||||
        actions: [],
 | 
			
		||||
        followUps: ['✉️ Draft a follow-up email', '✅ Generate action items', '📝 Show summary'],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // 이전 결과가 있으면 기본값으로 사용
 | 
			
		||||
    if (previousResult) {
 | 
			
		||||
        structuredData.topic.header = previousResult.topic.header;
 | 
			
		||||
        structuredData.summary = [...previousResult.summary];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        const lines = responseText.split('\n');
 | 
			
		||||
        let currentSection = '';
 | 
			
		||||
        let isCapturingTopic = false;
 | 
			
		||||
        let topicName = '';
 | 
			
		||||
 | 
			
		||||
        for (const line of lines) {
 | 
			
		||||
            const trimmedLine = line.trim();
 | 
			
		||||
 | 
			
		||||
            // 섹션 헤더 감지
 | 
			
		||||
            if (trimmedLine.startsWith('**Summary Overview**')) {
 | 
			
		||||
                currentSection = 'summary-overview';
 | 
			
		||||
                continue;
 | 
			
		||||
            } else if (trimmedLine.startsWith('**Key Topic:')) {
 | 
			
		||||
                currentSection = 'topic';
 | 
			
		||||
                isCapturingTopic = true;
 | 
			
		||||
                topicName = trimmedLine.match(/\*\*Key Topic: (.+?)\*\*/)?.[1] || '';
 | 
			
		||||
                if (topicName) {
 | 
			
		||||
                    structuredData.topic.header = topicName + ':';
 | 
			
		||||
                }
 | 
			
		||||
                continue;
 | 
			
		||||
            } else if (trimmedLine.startsWith('**Extended Explanation**')) {
 | 
			
		||||
                currentSection = 'explanation';
 | 
			
		||||
                continue;
 | 
			
		||||
            } else if (trimmedLine.startsWith('**Suggested Questions**')) {
 | 
			
		||||
                currentSection = 'questions';
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 컨텐츠 파싱
 | 
			
		||||
            if (trimmedLine.startsWith('-') && currentSection === 'summary-overview') {
 | 
			
		||||
                const summaryPoint = trimmedLine.substring(1).trim();
 | 
			
		||||
                if (summaryPoint && !structuredData.summary.includes(summaryPoint)) {
 | 
			
		||||
                    // 기존 summary 업데이트 (최대 5개 유지)
 | 
			
		||||
                    structuredData.summary.unshift(summaryPoint);
 | 
			
		||||
                    if (structuredData.summary.length > 5) {
 | 
			
		||||
                        structuredData.summary.pop();
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else if (trimmedLine.startsWith('-') && currentSection === 'topic') {
 | 
			
		||||
                const bullet = trimmedLine.substring(1).trim();
 | 
			
		||||
                if (bullet && structuredData.topic.bullets.length < 3) {
 | 
			
		||||
                    structuredData.topic.bullets.push(bullet);
 | 
			
		||||
                }
 | 
			
		||||
            } else if (currentSection === 'explanation' && trimmedLine) {
 | 
			
		||||
                // explanation을 topic bullets에 추가 (문장 단위로)
 | 
			
		||||
                const sentences = trimmedLine
 | 
			
		||||
                    .split(/\.\s+/)
 | 
			
		||||
                    .filter(s => s.trim().length > 0)
 | 
			
		||||
                    .map(s => s.trim() + (s.endsWith('.') ? '' : '.'));
 | 
			
		||||
 | 
			
		||||
                sentences.forEach(sentence => {
 | 
			
		||||
                    if (structuredData.topic.bullets.length < 3 && !structuredData.topic.bullets.includes(sentence)) {
 | 
			
		||||
                        structuredData.topic.bullets.push(sentence);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
            } else if (trimmedLine.match(/^\d+\./) && currentSection === 'questions') {
 | 
			
		||||
                const question = trimmedLine.replace(/^\d+\.\s*/, '').trim();
 | 
			
		||||
                if (question && question.includes('?')) {
 | 
			
		||||
                    structuredData.actions.push(`❓ ${question}`);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 기본 액션 추가
 | 
			
		||||
        const defaultActions = ['✨ What should I say next?', '💬 Suggest follow-up questions'];
 | 
			
		||||
        defaultActions.forEach(action => {
 | 
			
		||||
            if (!structuredData.actions.includes(action)) {
 | 
			
		||||
                structuredData.actions.push(action);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // 액션 개수 제한
 | 
			
		||||
        structuredData.actions = structuredData.actions.slice(0, 5);
 | 
			
		||||
 | 
			
		||||
        // 유효성 검증 및 이전 데이터 병합
 | 
			
		||||
        if (structuredData.summary.length === 0 && previousResult) {
 | 
			
		||||
            structuredData.summary = previousResult.summary;
 | 
			
		||||
        }
 | 
			
		||||
        if (structuredData.topic.bullets.length === 0 && previousResult) {
 | 
			
		||||
            structuredData.topic.bullets = previousResult.topic.bullets;
 | 
			
		||||
        }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('❌ Error parsing response text:', error);
 | 
			
		||||
        // 에러 시 이전 결과 반환
 | 
			
		||||
        return (
 | 
			
		||||
            previousResult || {
 | 
			
		||||
                summary: [],
 | 
			
		||||
                topic: { header: 'Analysis in progress', bullets: [] },
 | 
			
		||||
                actions: ['✨ What should I say next?', '💬 Suggest follow-up questions'],
 | 
			
		||||
                followUps: ['✉️ Draft a follow-up email', '✅ Generate action items', '📝 Show summary'],
 | 
			
		||||
            }
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log('📊 Final structured data:', JSON.stringify(structuredData, null, 2));
 | 
			
		||||
    return structuredData;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Triggers analysis when conversation history reaches 5 texts.
 | 
			
		||||
 */
 | 
			
		||||
async function triggerAnalysisIfNeeded() {
 | 
			
		||||
    if (conversationHistory.length >= 5 && conversationHistory.length % 5 === 0) {
 | 
			
		||||
        console.log(`🚀 Triggering analysis (non-blocking) - ${conversationHistory.length} conversation texts accumulated`);
 | 
			
		||||
 | 
			
		||||
        makeOutlineAndRequests(conversationHistory)
 | 
			
		||||
            .then(data => {
 | 
			
		||||
                if (data) {
 | 
			
		||||
                    console.log('📤 Sending structured data to renderer');
 | 
			
		||||
                    sendToRenderer('update-structured-data', data);
 | 
			
		||||
                } else {
 | 
			
		||||
                    console.log('❌ No analysis data returned from non-blocking call');
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
            .catch(error => {
 | 
			
		||||
                console.error('❌ Error in non-blocking analysis:', error);
 | 
			
		||||
            });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Schedules periodic updates of outline and analysis every 10 seconds. - DEPRECATED
 | 
			
		||||
 * Now analysis is triggered every 5 conversation texts.
 | 
			
		||||
 */
 | 
			
		||||
function startAnalysisInterval() {
 | 
			
		||||
    console.log('⏰ Analysis will be triggered every 5 conversation texts (not on timer)');
 | 
			
		||||
 | 
			
		||||
    if (analysisIntervalId) {
 | 
			
		||||
        clearInterval(analysisIntervalId);
 | 
			
		||||
        analysisIntervalId = null;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function stopAnalysisInterval() {
 | 
			
		||||
    if (analysisIntervalId) {
 | 
			
		||||
        clearInterval(analysisIntervalId);
 | 
			
		||||
        analysisIntervalId = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (myInactivityTimer) {
 | 
			
		||||
        clearTimeout(myInactivityTimer);
 | 
			
		||||
        myInactivityTimer = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (theirInactivityTimer) {
 | 
			
		||||
        clearTimeout(theirInactivityTimer);
 | 
			
		||||
        theirInactivityTimer = null;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function sendToRenderer(channel, data) {
 | 
			
		||||
    BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
        if (!win.isDestroyed()) {
 | 
			
		||||
            win.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getCurrentSessionData() {
 | 
			
		||||
    return {
 | 
			
		||||
        sessionId: currentSessionId,
 | 
			
		||||
        conversationHistory: conversationHistory,
 | 
			
		||||
        totalTexts: conversationHistory.length,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Conversation management functions
 | 
			
		||||
async function initializeNewSession() {
 | 
			
		||||
    try {
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        if (!uid) {
 | 
			
		||||
            throw new Error("Cannot initialize session: user not logged in.");
 | 
			
		||||
        }
 | 
			
		||||
        currentSessionId = await sessionRepository.getOrCreateActive(uid, 'listen');
 | 
			
		||||
        console.log(`[DB] New listen session ensured: ${currentSessionId}`);
 | 
			
		||||
 | 
			
		||||
        conversationHistory = [];
 | 
			
		||||
        myCurrentUtterance = '';
 | 
			
		||||
        theirCurrentUtterance = '';
 | 
			
		||||
 | 
			
		||||
        // 🔄 Reset analysis state so the new session starts fresh
 | 
			
		||||
        previousAnalysisResult = null;
 | 
			
		||||
        analysisHistory = [];
 | 
			
		||||
 | 
			
		||||
        // sendToRenderer('update-outline', []);
 | 
			
		||||
        // sendToRenderer('update-analysis-requests', []);
 | 
			
		||||
 | 
			
		||||
        myLastPartialText = '';
 | 
			
		||||
        theirLastPartialText = '';
 | 
			
		||||
        if (myInactivityTimer) {
 | 
			
		||||
            clearTimeout(myInactivityTimer);
 | 
			
		||||
            myInactivityTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
        if (theirInactivityTimer) {
 | 
			
		||||
            clearTimeout(theirInactivityTimer);
 | 
			
		||||
            theirInactivityTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log('New conversation session started:', currentSessionId);
 | 
			
		||||
        return true;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to initialize new session in DB:', error);
 | 
			
		||||
        currentSessionId = null;
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function saveConversationTurn(speaker, transcription) {
 | 
			
		||||
    if (!currentSessionId) {
 | 
			
		||||
        console.error('[DB] Cannot save turn, no active session ID.');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
    if (transcription.trim() === '') return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        await sessionRepository.touch(currentSessionId);
 | 
			
		||||
        await listenRepository.addTranscript({
 | 
			
		||||
            sessionId: currentSessionId,
 | 
			
		||||
            speaker: speaker,
 | 
			
		||||
            text: transcription.trim(),
 | 
			
		||||
        });
 | 
			
		||||
        console.log(`[DB] Saved transcript for session ${currentSessionId}: (${speaker})`);
 | 
			
		||||
 | 
			
		||||
        const conversationText = `${speaker.toLowerCase()}: ${transcription.trim()}`;
 | 
			
		||||
        conversationHistory.push(conversationText);
 | 
			
		||||
        console.log(`💬 Saved conversation text: ${conversationText}`);
 | 
			
		||||
        console.log(`📈 Total conversation history: ${conversationHistory.length} texts`);
 | 
			
		||||
 | 
			
		||||
        triggerAnalysisIfNeeded();
 | 
			
		||||
 | 
			
		||||
        const conversationTurn = {
 | 
			
		||||
            speaker: speaker,
 | 
			
		||||
            timestamp: Date.now(),
 | 
			
		||||
            transcription: transcription.trim(),
 | 
			
		||||
        };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to save transcript to DB:', error);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function initializeLiveSummarySession(language = 'en') {
 | 
			
		||||
    // Use system environment variable if set, otherwise use the provided language
 | 
			
		||||
    const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
 | 
			
		||||
    if (isInitializingSession) {
 | 
			
		||||
        console.log('Session initialization already in progress.');
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const userState = authService.getCurrentUser();
 | 
			
		||||
    const loggedIn = userState.isLoggedIn;
 | 
			
		||||
    const keyType = loggedIn ? 'vKey' : 'apiKey';
 | 
			
		||||
 | 
			
		||||
    isInitializingSession = true;
 | 
			
		||||
    sendToRenderer('session-initializing', true);
 | 
			
		||||
    sendToRenderer('update-status', 'Initializing sessions...');
 | 
			
		||||
 | 
			
		||||
    const API_KEY = await getApiKey();
 | 
			
		||||
    if (!API_KEY) {
 | 
			
		||||
        console.error('FATAL ERROR: API Key is not defined.');
 | 
			
		||||
        sendToRenderer('update-status', 'API Key not configured.');
 | 
			
		||||
        isInitializingSession = false;
 | 
			
		||||
        sendToRenderer('session-initializing', false);
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await initializeNewSession();
 | 
			
		||||
 | 
			
		||||
    const provider = await getAiProvider();
 | 
			
		||||
    const isGemini  = provider === 'gemini';
 | 
			
		||||
    console.log(`[LiveSummaryService] Initializing STT for provider: ${provider}`);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        const handleMyMessage = message => {
 | 
			
		||||
            if (isGemini) {
 | 
			
		||||
                // console.log('[Gemini Raw Message - Me]:', JSON.stringify(message, null, 2));
 | 
			
		||||
                const text = message.serverContent?.inputTranscription?.text || '';
 | 
			
		||||
                if (text && text.trim()) {
 | 
			
		||||
                    const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
 | 
			
		||||
                    if (finalUtteranceText && finalUtteranceText !== '.') {
 | 
			
		||||
                        debounceMyCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const type = message.type;
 | 
			
		||||
                const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
 | 
			
		||||
 | 
			
		||||
                if (type === 'conversation.item.input_audio_transcription.delta') {
 | 
			
		||||
                    if (myCompletionTimer) clearTimeout(myCompletionTimer);
 | 
			
		||||
                    myCompletionTimer = null;
 | 
			
		||||
                    myCurrentUtterance += text;
 | 
			
		||||
                    const continuousText = myCompletionBuffer + (myCompletionBuffer ? ' ' : '') + myCurrentUtterance;
 | 
			
		||||
                    if (text && !text.includes('vq_lbr_audio_')) {
 | 
			
		||||
                        sendToRenderer('stt-update', {
 | 
			
		||||
                            speaker: 'Me',
 | 
			
		||||
                            text: continuousText,
 | 
			
		||||
                            isPartial: true,
 | 
			
		||||
                            isFinal: false,
 | 
			
		||||
                            timestamp: Date.now(),
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (type === 'conversation.item.input_audio_transcription.completed') {
 | 
			
		||||
                    if (text && text.trim()) {
 | 
			
		||||
                        const finalUtteranceText = text.trim();
 | 
			
		||||
                        myCurrentUtterance = '';
 | 
			
		||||
                        debounceMyCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (message.error) {
 | 
			
		||||
                console.error('[Me] STT Session Error:', message.error);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const handleTheirMessage = message => {
 | 
			
		||||
            if (isGemini) {
 | 
			
		||||
                // console.log('[Gemini Raw Message - Them]:', JSON.stringify(message, null, 2));
 | 
			
		||||
                const text = message.serverContent?.inputTranscription?.text || '';
 | 
			
		||||
                if (text && text.trim()) {
 | 
			
		||||
                    const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
 | 
			
		||||
                    if (finalUtteranceText && finalUtteranceText !== '.') {
 | 
			
		||||
                        debounceTheirCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const type = message.type;
 | 
			
		||||
                const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
 | 
			
		||||
                if (type === 'conversation.item.input_audio_transcription.delta') {
 | 
			
		||||
                    if (theirCompletionTimer) clearTimeout(theirCompletionTimer);
 | 
			
		||||
                    theirCompletionTimer = null;
 | 
			
		||||
                    theirCurrentUtterance += text;
 | 
			
		||||
                    const continuousText = theirCompletionBuffer + (theirCompletionBuffer ? ' ' : '') + theirCurrentUtterance;
 | 
			
		||||
                    if (text && !text.includes('vq_lbr_audio_')) {
 | 
			
		||||
                        sendToRenderer('stt-update', {
 | 
			
		||||
                            speaker: 'Them',
 | 
			
		||||
                            text: continuousText,
 | 
			
		||||
                            isPartial: true,
 | 
			
		||||
                            isFinal: false,
 | 
			
		||||
                            timestamp: Date.now(),
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (type === 'conversation.item.input_audio_transcription.completed') {
 | 
			
		||||
                    if (text && text.trim()) {
 | 
			
		||||
                        const finalUtteranceText = text.trim();
 | 
			
		||||
                        theirCurrentUtterance = '';
 | 
			
		||||
                        debounceTheirCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if (message.error) {
 | 
			
		||||
                console.error('[Them] STT Session Error:', message.error);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const mySttConfig = {
 | 
			
		||||
            language: effectiveLanguage,
 | 
			
		||||
            callbacks: {
 | 
			
		||||
                onmessage: handleMyMessage,
 | 
			
		||||
                onerror: error => console.error('My STT session error:', error.message),
 | 
			
		||||
                onclose: event => console.log('My STT session closed:', event.reason),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
        const theirSttConfig = {
 | 
			
		||||
            language: effectiveLanguage,
 | 
			
		||||
            callbacks: {
 | 
			
		||||
                onmessage: handleTheirMessage,
 | 
			
		||||
                onerror: error => console.error('Their STT session error:', error.message),
 | 
			
		||||
                onclose: event => console.log('Their STT session closed:', event.reason),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (isGemini) {
 | 
			
		||||
            [mySttSession, theirSttSession] = await Promise.all([
 | 
			
		||||
                connectToGeminiSession(API_KEY, mySttConfig),
 | 
			
		||||
                connectToGeminiSession(API_KEY, theirSttConfig),
 | 
			
		||||
            ]);
 | 
			
		||||
        } else {
 | 
			
		||||
            [mySttSession, theirSttSession] = await Promise.all([
 | 
			
		||||
                connectToOpenAiSession(API_KEY, mySttConfig, keyType),
 | 
			
		||||
                connectToOpenAiSession(API_KEY, theirSttConfig, keyType),
 | 
			
		||||
            ]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log('✅ Both STT sessions initialized successfully.');
 | 
			
		||||
        triggerAnalysisIfNeeded();
 | 
			
		||||
 | 
			
		||||
        sendToRenderer('session-state-changed', { isActive: true });
 | 
			
		||||
 | 
			
		||||
        isInitializingSession = false;
 | 
			
		||||
        sendToRenderer('session-initializing', false);
 | 
			
		||||
        sendToRenderer('update-status', 'Connected. Ready to listen.');
 | 
			
		||||
        return true;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('❌ Failed to initialize STT sessions:', error);
 | 
			
		||||
        isInitializingSession = false;
 | 
			
		||||
        sendToRenderer('session-initializing', false);
 | 
			
		||||
        sendToRenderer('update-status', 'Initialization failed.');
 | 
			
		||||
        mySttSession = null;
 | 
			
		||||
        theirSttSession = null;
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function killExistingSystemAudioDump() {
 | 
			
		||||
    return new Promise(resolve => {
 | 
			
		||||
        console.log('Checking for existing SystemAudioDump processes...');
 | 
			
		||||
 | 
			
		||||
        const killProc = spawn('pkill', ['-f', 'SystemAudioDump'], {
 | 
			
		||||
            stdio: 'ignore',
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        killProc.on('close', code => {
 | 
			
		||||
            if (code === 0) {
 | 
			
		||||
                console.log('Killed existing SystemAudioDump processes');
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log('No existing SystemAudioDump processes found');
 | 
			
		||||
            }
 | 
			
		||||
            resolve();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        killProc.on('error', err => {
 | 
			
		||||
            console.log('Error checking for existing processes (this is normal):', err.message);
 | 
			
		||||
            resolve();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            killProc.kill();
 | 
			
		||||
            resolve();
 | 
			
		||||
        }, 2000);
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function startMacOSAudioCapture() {
 | 
			
		||||
    if (process.platform !== 'darwin' || !theirSttSession) return false;
 | 
			
		||||
 | 
			
		||||
    await killExistingSystemAudioDump();
 | 
			
		||||
    console.log('Starting macOS audio capture for "Them"...');
 | 
			
		||||
 | 
			
		||||
    const { app } = require('electron');
 | 
			
		||||
    const path = require('path');
 | 
			
		||||
    const systemAudioPath = app.isPackaged
 | 
			
		||||
        ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump')
 | 
			
		||||
        : path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump');
 | 
			
		||||
 | 
			
		||||
    console.log('SystemAudioDump path:', systemAudioPath);
 | 
			
		||||
 | 
			
		||||
    systemAudioProc = spawn(systemAudioPath, [], {
 | 
			
		||||
        stdio: ['ignore', 'pipe', 'pipe'],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!systemAudioProc.pid) {
 | 
			
		||||
        console.error('Failed to start SystemAudioDump');
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log('SystemAudioDump started with PID:', systemAudioProc.pid);
 | 
			
		||||
 | 
			
		||||
    const CHUNK_DURATION = 0.1;
 | 
			
		||||
    const SAMPLE_RATE = 24000;
 | 
			
		||||
    const BYTES_PER_SAMPLE = 2;
 | 
			
		||||
    const CHANNELS = 2;
 | 
			
		||||
    const CHUNK_SIZE = SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS * CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
    let audioBuffer = Buffer.alloc(0);
 | 
			
		||||
 | 
			
		||||
    const provider = await getAiProvider();
 | 
			
		||||
    const isGemini  = provider === 'gemini';
 | 
			
		||||
 | 
			
		||||
    systemAudioProc.stdout.on('data', async data => {
 | 
			
		||||
        audioBuffer = Buffer.concat([audioBuffer, data]);
 | 
			
		||||
 | 
			
		||||
        while (audioBuffer.length >= CHUNK_SIZE) {
 | 
			
		||||
            const chunk = audioBuffer.slice(0, CHUNK_SIZE);
 | 
			
		||||
            audioBuffer = audioBuffer.slice(CHUNK_SIZE);
 | 
			
		||||
 | 
			
		||||
            const monoChunk = CHANNELS === 2 ? convertStereoToMono(chunk) : chunk;
 | 
			
		||||
            const base64Data = monoChunk.toString('base64');
 | 
			
		||||
 | 
			
		||||
            sendToRenderer('system-audio-data', { data: base64Data });
 | 
			
		||||
 | 
			
		||||
            if (theirSttSession) {
 | 
			
		||||
                try {
 | 
			
		||||
                    // await theirSttSession.sendRealtimeInput(base64Data);
 | 
			
		||||
                    const payload = isGemini
 | 
			
		||||
                        ? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
 | 
			
		||||
                        : base64Data;
 | 
			
		||||
                    await theirSttSession.sendRealtimeInput(payload);
 | 
			
		||||
                } catch (err) {
 | 
			
		||||
                    console.error('Error sending system audio:', err.message);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (process.env.DEBUG_AUDIO) {
 | 
			
		||||
                saveDebugAudio(monoChunk, 'system_audio');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    systemAudioProc.stderr.on('data', data => {
 | 
			
		||||
        console.error('SystemAudioDump stderr:', data.toString());
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    systemAudioProc.on('close', code => {
 | 
			
		||||
        console.log('SystemAudioDump process closed with code:', code);
 | 
			
		||||
        systemAudioProc = null;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    systemAudioProc.on('error', err => {
 | 
			
		||||
        console.error('SystemAudioDump process error:', err);
 | 
			
		||||
        systemAudioProc = null;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function convertStereoToMono(stereoBuffer) {
 | 
			
		||||
    const samples = stereoBuffer.length / 4;
 | 
			
		||||
    const monoBuffer = Buffer.alloc(samples * 2);
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i < samples; i++) {
 | 
			
		||||
        const leftSample = stereoBuffer.readInt16LE(i * 4);
 | 
			
		||||
        monoBuffer.writeInt16LE(leftSample, i * 2);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return monoBuffer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function stopMacOSAudioCapture() {
 | 
			
		||||
    if (systemAudioProc) {
 | 
			
		||||
        console.log('Stopping SystemAudioDump...');
 | 
			
		||||
        systemAudioProc.kill('SIGTERM');
 | 
			
		||||
        systemAudioProc = null;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function sendAudioToOpenAI(base64Data, sttSessionRef) {
 | 
			
		||||
    if (!sttSessionRef.current) return;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        process.stdout.write('.');
 | 
			
		||||
        await sttSessionRef.current.sendRealtimeInput({
 | 
			
		||||
            audio: {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error sending audio to OpenAI:', error);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isSessionActive() {
 | 
			
		||||
    return !!mySttSession && !!theirSttSession;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function closeSession() {
 | 
			
		||||
    try {
 | 
			
		||||
        stopMacOSAudioCapture();
 | 
			
		||||
        stopAnalysisInterval();
 | 
			
		||||
 | 
			
		||||
        if (currentSessionId) {
 | 
			
		||||
            await sessionRepository.end(currentSessionId);
 | 
			
		||||
            console.log(`[DB] Session ${currentSessionId} ended.`);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const closePromises = [];
 | 
			
		||||
        if (mySttSession) {
 | 
			
		||||
            closePromises.push(mySttSession.close());
 | 
			
		||||
            mySttSession = null;
 | 
			
		||||
        }
 | 
			
		||||
        if (theirSttSession) {
 | 
			
		||||
            closePromises.push(theirSttSession.close());
 | 
			
		||||
            theirSttSession = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await Promise.all(closePromises);
 | 
			
		||||
        console.log('All sessions closed.');
 | 
			
		||||
 | 
			
		||||
        currentSessionId = null;
 | 
			
		||||
        conversationHistory = [];
 | 
			
		||||
 | 
			
		||||
        sendToRenderer('session-state-changed', { isActive: false });
 | 
			
		||||
        sendToRenderer('session-did-close');
 | 
			
		||||
 | 
			
		||||
        return { success: true };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error closing sessions:', error);
 | 
			
		||||
        return { success: false, error: error.message };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupLiveSummaryIpcHandlers() {
 | 
			
		||||
    ipcMain.handle('is-session-active', async () => {
 | 
			
		||||
        const isActive = isSessionActive();
 | 
			
		||||
        console.log(`Checking session status. Active: ${isActive}`);
 | 
			
		||||
        return isActive;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
 | 
			
		||||
        console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
 | 
			
		||||
        const success = await initializeLiveSummarySession(language);
 | 
			
		||||
        return success;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
 | 
			
		||||
    const provider = await getAiProvider();
 | 
			
		||||
    const isGemini  = provider === 'gemini';
 | 
			
		||||
        if (!mySttSession) return { success: false, error: 'User STT session not active' };
 | 
			
		||||
        try {
 | 
			
		||||
            // await mySttSession.sendRealtimeInput(data);
 | 
			
		||||
                   // provider에 맞는 형식으로 래핑
 | 
			
		||||
       const payload = isGemini
 | 
			
		||||
           ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
 | 
			
		||||
           : data;   // OpenAI는 base64 string 그대로
 | 
			
		||||
 | 
			
		||||
       await mySttSession.sendRealtimeInput(payload);
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error sending user audio:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('start-macos-audio', async () => {
 | 
			
		||||
        if (process.platform !== 'darwin') {
 | 
			
		||||
            return { success: false, error: 'macOS audio capture only available on macOS' };
 | 
			
		||||
        }
 | 
			
		||||
        try {
 | 
			
		||||
            const success = await startMacOSAudioCapture();
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error starting macOS audio capture:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('stop-macos-audio', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            stopMacOSAudioCapture();
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error stopping macOS audio capture:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('close-session', async () => {
 | 
			
		||||
        return await closeSession();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('update-google-search-setting', async (event, enabled) => {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log('Google Search setting updated to:', enabled);
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error updating Google Search setting:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getConversationHistory() {
 | 
			
		||||
    return conversationHistory;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    sendToRenderer,
 | 
			
		||||
    initializeNewSession,
 | 
			
		||||
    saveConversationTurn,
 | 
			
		||||
    killExistingSystemAudioDump,
 | 
			
		||||
    startMacOSAudioCapture,
 | 
			
		||||
    convertStereoToMono,
 | 
			
		||||
    stopMacOSAudioCapture,
 | 
			
		||||
    sendAudioToOpenAI,
 | 
			
		||||
    setupLiveSummaryIpcHandlers,
 | 
			
		||||
    isSessionActive,
 | 
			
		||||
    closeSession,
 | 
			
		||||
    getConversationHistory,
 | 
			
		||||
};
 | 
			
		||||
@ -1,20 +1,29 @@
 | 
			
		||||
// renderer.js
 | 
			
		||||
const { ipcRenderer } = require('electron');
 | 
			
		||||
const { makeStreamingChatCompletionWithPortkey } = require('../../common/services/aiProviderService.js');
 | 
			
		||||
 | 
			
		||||
let mediaStream = null;
 | 
			
		||||
let screenshotInterval = null;
 | 
			
		||||
let audioContext = null;
 | 
			
		||||
let audioProcessor = null;
 | 
			
		||||
let micMediaStream = null;
 | 
			
		||||
let audioBuffer = [];
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Constants & Globals
 | 
			
		||||
// ---------------------------
 | 
			
		||||
const SAMPLE_RATE = 24000;
 | 
			
		||||
const AUDIO_CHUNK_DURATION = 0.1;
 | 
			
		||||
const BUFFER_SIZE = 4096;
 | 
			
		||||
 | 
			
		||||
const isLinux = process.platform === 'linux';
 | 
			
		||||
const isMacOS = process.platform === 'darwin';
 | 
			
		||||
 | 
			
		||||
let mediaStream = null;
 | 
			
		||||
let micMediaStream = null;
 | 
			
		||||
let screenshotInterval = null;
 | 
			
		||||
let audioContext = null;
 | 
			
		||||
let audioProcessor = null;
 | 
			
		||||
let currentImageQuality = 'medium';
 | 
			
		||||
let lastScreenshotBase64 = null;
 | 
			
		||||
 | 
			
		||||
let systemAudioBuffer = [];
 | 
			
		||||
const MAX_SYSTEM_BUFFER_SIZE = 10;
 | 
			
		||||
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Utility helpers (exact from renderer.js)
 | 
			
		||||
// ---------------------------
 | 
			
		||||
function isVoiceActive(audioFloat32Array, threshold = 0.005) {
 | 
			
		||||
    if (!audioFloat32Array || audioFloat32Array.length === 0) {
 | 
			
		||||
        return false;
 | 
			
		||||
@ -31,11 +40,6 @@ function isVoiceActive(audioFloat32Array, threshold = 0.005) {
 | 
			
		||||
    return rms > threshold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let currentImageQuality = 'medium'; // Store current image quality for manual screenshots
 | 
			
		||||
let lastScreenshotBase64 = null; // Store the latest screenshot
 | 
			
		||||
 | 
			
		||||
let realtimeConversationHistory = [];
 | 
			
		||||
 | 
			
		||||
function base64ToFloat32Array(base64) {
 | 
			
		||||
    const binaryString = atob(base64);
 | 
			
		||||
    const bytes = new Uint8Array(binaryString.length);
 | 
			
		||||
@ -54,11 +58,29 @@ function base64ToFloat32Array(base64) {
 | 
			
		||||
    return float32Array;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function queryLoginState() {
 | 
			
		||||
    const userState = await ipcRenderer.invoke('get-current-user');
 | 
			
		||||
    return userState;
 | 
			
		||||
function convertFloat32ToInt16(float32Array) {
 | 
			
		||||
    const int16Array = new Int16Array(float32Array.length);
 | 
			
		||||
    for (let i = 0; i < float32Array.length; i++) {
 | 
			
		||||
        // Improved scaling to prevent clipping
 | 
			
		||||
        const s = Math.max(-1, Math.min(1, float32Array[i]));
 | 
			
		||||
        int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
 | 
			
		||||
    }
 | 
			
		||||
    return int16Array;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function arrayBufferToBase64(buffer) {
 | 
			
		||||
    let binary = '';
 | 
			
		||||
    const bytes = new Uint8Array(buffer);
 | 
			
		||||
    const len = bytes.byteLength;
 | 
			
		||||
    for (let i = 0; i < len; i++) {
 | 
			
		||||
        binary += String.fromCharCode(bytes[i]);
 | 
			
		||||
    }
 | 
			
		||||
    return btoa(binary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Complete SimpleAEC implementation (exact from renderer.js)
 | 
			
		||||
// ---------------------------
 | 
			
		||||
class SimpleAEC {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.adaptiveFilter = new Float32Array(1024);
 | 
			
		||||
@ -179,11 +201,24 @@ class SimpleAEC {
 | 
			
		||||
 | 
			
		||||
let aecProcessor = new SimpleAEC();
 | 
			
		||||
 | 
			
		||||
const isLinux = process.platform === 'linux';
 | 
			
		||||
const isMacOS = process.platform === 'darwin';
 | 
			
		||||
// System audio data handler
 | 
			
		||||
ipcRenderer.on('system-audio-data', (event, { data }) => {
 | 
			
		||||
    systemAudioBuffer.push({
 | 
			
		||||
        data: data,
 | 
			
		||||
        timestamp: Date.now(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
window.pickleGlass = window.pickleGlass || {};
 | 
			
		||||
    // 오래된 데이터 제거
 | 
			
		||||
    if (systemAudioBuffer.length > MAX_SYSTEM_BUFFER_SIZE) {
 | 
			
		||||
        systemAudioBuffer = systemAudioBuffer.slice(-MAX_SYSTEM_BUFFER_SIZE);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log('📥 Received system audio for AEC reference');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Complete token tracker (exact from renderer.js)
 | 
			
		||||
// ---------------------------
 | 
			
		||||
let tokenTracker = {
 | 
			
		||||
    tokens: [],
 | 
			
		||||
    audioStartTime: null,
 | 
			
		||||
@ -265,126 +300,201 @@ setInterval(() => {
 | 
			
		||||
    tokenTracker.trackAudioTokens();
 | 
			
		||||
}, 2000);
 | 
			
		||||
 | 
			
		||||
function pickleGlassElement() {
 | 
			
		||||
    return document.getElementById('pickle-glass');
 | 
			
		||||
}
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Audio processing functions (exact from renderer.js)
 | 
			
		||||
// ---------------------------
 | 
			
		||||
function setupMicProcessing(micStream) {
 | 
			
		||||
    const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
 | 
			
		||||
    const micSource = micAudioContext.createMediaStreamSource(micStream);
 | 
			
		||||
    const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
 | 
			
		||||
 | 
			
		||||
function convertFloat32ToInt16(float32Array) {
 | 
			
		||||
    const int16Array = new Int16Array(float32Array.length);
 | 
			
		||||
    for (let i = 0; i < float32Array.length; i++) {
 | 
			
		||||
        // Improved scaling to prevent clipping
 | 
			
		||||
        const s = Math.max(-1, Math.min(1, float32Array[i]));
 | 
			
		||||
        int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
 | 
			
		||||
    }
 | 
			
		||||
    return int16Array;
 | 
			
		||||
}
 | 
			
		||||
    let audioBuffer = [];
 | 
			
		||||
    const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
function arrayBufferToBase64(buffer) {
 | 
			
		||||
    let binary = '';
 | 
			
		||||
    const bytes = new Uint8Array(buffer);
 | 
			
		||||
    const len = bytes.byteLength;
 | 
			
		||||
    for (let i = 0; i < len; i++) {
 | 
			
		||||
        binary += String.fromCharCode(bytes[i]);
 | 
			
		||||
    }
 | 
			
		||||
    return btoa(binary);
 | 
			
		||||
}
 | 
			
		||||
    micProcessor.onaudioprocess = async e => {
 | 
			
		||||
        const inputData = e.inputBuffer.getChannelData(0);
 | 
			
		||||
        audioBuffer.push(...inputData);
 | 
			
		||||
 | 
			
		||||
async function initializeopenai(profile = 'interview', language = 'en') {
 | 
			
		||||
    // The API key is now handled in the main process from .env file.
 | 
			
		||||
    // We just need to trigger the initialization.
 | 
			
		||||
    try {
 | 
			
		||||
        console.log(`Requesting OpenAI initialization with profile: ${profile}, language: ${language}`);
 | 
			
		||||
        const success = await ipcRenderer.invoke('initialize-openai', profile, language);
 | 
			
		||||
        if (success) {
 | 
			
		||||
            // The status will be updated via 'update-status' event from the main process.
 | 
			
		||||
            console.log('OpenAI initialization successful.');
 | 
			
		||||
        } else {
 | 
			
		||||
            console.error('OpenAI initialization failed.');
 | 
			
		||||
            const appElement = pickleGlassElement();
 | 
			
		||||
            if (appElement && typeof appElement.setStatus === 'function') {
 | 
			
		||||
                appElement.setStatus('Initialization Failed');
 | 
			
		||||
        while (audioBuffer.length >= samplesPerChunk) {
 | 
			
		||||
            let chunk = audioBuffer.splice(0, samplesPerChunk);
 | 
			
		||||
            let processedChunk = new Float32Array(chunk);
 | 
			
		||||
 | 
			
		||||
            // Check for system audio and apply AEC only if voice is active
 | 
			
		||||
            if (aecProcessor && systemAudioBuffer.length > 0) {
 | 
			
		||||
                const latestSystemAudio = systemAudioBuffer[systemAudioBuffer.length - 1];
 | 
			
		||||
                const systemFloat32 = base64ToFloat32Array(latestSystemAudio.data);
 | 
			
		||||
 | 
			
		||||
                // Apply AEC only when system audio has active speech
 | 
			
		||||
                if (isVoiceActive(systemFloat32)) {
 | 
			
		||||
                    processedChunk = aecProcessor.process(new Float32Array(chunk), systemFloat32);
 | 
			
		||||
                    console.log('🔊 Applied AEC because system audio is active');
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const pcmData16 = convertFloat32ToInt16(processedChunk);
 | 
			
		||||
            const base64Data = arrayBufferToBase64(pcmData16.buffer);
 | 
			
		||||
 | 
			
		||||
            await ipcRenderer.invoke('send-audio-content', {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    micSource.connect(micProcessor);
 | 
			
		||||
    micProcessor.connect(micAudioContext.destination);
 | 
			
		||||
 | 
			
		||||
    audioProcessor = micProcessor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupLinuxMicProcessing(micStream) {
 | 
			
		||||
    // Setup microphone audio processing for Linux
 | 
			
		||||
    const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
 | 
			
		||||
    const micSource = micAudioContext.createMediaStreamSource(micStream);
 | 
			
		||||
    const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
 | 
			
		||||
 | 
			
		||||
    let audioBuffer = [];
 | 
			
		||||
    const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
    micProcessor.onaudioprocess = async e => {
 | 
			
		||||
        const inputData = e.inputBuffer.getChannelData(0);
 | 
			
		||||
        audioBuffer.push(...inputData);
 | 
			
		||||
 | 
			
		||||
        // Process audio in chunks
 | 
			
		||||
        while (audioBuffer.length >= samplesPerChunk) {
 | 
			
		||||
            const chunk = audioBuffer.splice(0, samplesPerChunk);
 | 
			
		||||
            const pcmData16 = convertFloat32ToInt16(chunk);
 | 
			
		||||
            const base64Data = arrayBufferToBase64(pcmData16.buffer);
 | 
			
		||||
 | 
			
		||||
            await ipcRenderer.invoke('send-audio-content', {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    micSource.connect(micProcessor);
 | 
			
		||||
    micProcessor.connect(micAudioContext.destination);
 | 
			
		||||
 | 
			
		||||
    // Store processor reference for cleanup
 | 
			
		||||
    audioProcessor = micProcessor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupWindowsLoopbackProcessing() {
 | 
			
		||||
    // Setup audio processing for Windows loopback audio only
 | 
			
		||||
    audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
 | 
			
		||||
    const source = audioContext.createMediaStreamSource(mediaStream);
 | 
			
		||||
    audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
 | 
			
		||||
 | 
			
		||||
    let audioBuffer = [];
 | 
			
		||||
    const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
    audioProcessor.onaudioprocess = async e => {
 | 
			
		||||
        const inputData = e.inputBuffer.getChannelData(0);
 | 
			
		||||
        audioBuffer.push(...inputData);
 | 
			
		||||
 | 
			
		||||
        // Process audio in chunks
 | 
			
		||||
        while (audioBuffer.length >= samplesPerChunk) {
 | 
			
		||||
            const chunk = audioBuffer.splice(0, samplesPerChunk);
 | 
			
		||||
            const pcmData16 = convertFloat32ToInt16(chunk);
 | 
			
		||||
            const base64Data = arrayBufferToBase64(pcmData16.buffer);
 | 
			
		||||
 | 
			
		||||
            await ipcRenderer.invoke('send-audio-content', {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    source.connect(audioProcessor);
 | 
			
		||||
    audioProcessor.connect(audioContext.destination);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Screenshot functions (exact from renderer.js)
 | 
			
		||||
// ---------------------------
 | 
			
		||||
async function captureScreenshot(imageQuality = 'medium', isManual = false) {
 | 
			
		||||
    console.log(`Capturing ${isManual ? 'manual' : 'automated'} screenshot...`);
 | 
			
		||||
 | 
			
		||||
    // Check rate limiting for automated screenshots only
 | 
			
		||||
    if (!isManual && tokenTracker.shouldThrottle()) {
 | 
			
		||||
        console.log('⚠️ Automated screenshot skipped due to rate limiting');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        // Request screenshot from main process
 | 
			
		||||
        const result = await ipcRenderer.invoke('capture-screenshot', {
 | 
			
		||||
            quality: imageQuality,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (result.success && result.base64) {
 | 
			
		||||
            // Store the latest screenshot
 | 
			
		||||
            lastScreenshotBase64 = result.base64;
 | 
			
		||||
 | 
			
		||||
            // Note: sendResult is not defined in the original, this was likely an error
 | 
			
		||||
            // Commenting out this section as it references undefined variable
 | 
			
		||||
            /*
 | 
			
		||||
            if (sendResult.success) {
 | 
			
		||||
                // Track image tokens after successful send
 | 
			
		||||
                const imageTokens = tokenTracker.calculateImageTokens(result.width || 1920, result.height || 1080);
 | 
			
		||||
                tokenTracker.addTokens(imageTokens, 'image');
 | 
			
		||||
                console.log(`📊 Image sent successfully - ${imageTokens} tokens used (${result.width}x${result.height})`);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.error('Failed to send image:', sendResult.error);
 | 
			
		||||
            }
 | 
			
		||||
            */
 | 
			
		||||
        } else {
 | 
			
		||||
            console.error('Failed to capture screenshot:', result.error);
 | 
			
		||||
        }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error during OpenAI initialization IPC call:', error);
 | 
			
		||||
        const appElement = pickleGlassElement();
 | 
			
		||||
        if (appElement && typeof appElement.setStatus === 'function') {
 | 
			
		||||
            appElement.setStatus('Error');
 | 
			
		||||
        }
 | 
			
		||||
        console.error('Error capturing screenshot:', error);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function captureManualScreenshot(imageQuality = null) {
 | 
			
		||||
    console.log('Manual screenshot triggered');
 | 
			
		||||
    const quality = imageQuality || currentImageQuality;
 | 
			
		||||
    await captureScreenshot(quality, true);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ipcRenderer.on('system-audio-data', (event, { data }) => {
 | 
			
		||||
    systemAudioBuffer.push({
 | 
			
		||||
        data: data,
 | 
			
		||||
        timestamp: Date.now(),
 | 
			
		||||
    });
 | 
			
		||||
async function getCurrentScreenshot() {
 | 
			
		||||
    try {
 | 
			
		||||
        // First try to get a fresh screenshot from main process
 | 
			
		||||
        const result = await ipcRenderer.invoke('get-current-screenshot');
 | 
			
		||||
 | 
			
		||||
    // 오래된 데이터 제거
 | 
			
		||||
    if (systemAudioBuffer.length > MAX_SYSTEM_BUFFER_SIZE) {
 | 
			
		||||
        systemAudioBuffer = systemAudioBuffer.slice(-MAX_SYSTEM_BUFFER_SIZE);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log('📥 Received system audio for AEC reference');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Listen for status updates
 | 
			
		||||
ipcRenderer.on('update-status', (event, status) => {
 | 
			
		||||
    console.log('Status update:', status);
 | 
			
		||||
    pickleGlass.e().setStatus(status);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Listen for real-time STT updates
 | 
			
		||||
ipcRenderer.on('stt-update', (event, data) => {
 | 
			
		||||
    console.log('Renderer.js stt-update', data);
 | 
			
		||||
    const { speaker, text, isFinal, isPartial, timestamp } = data;
 | 
			
		||||
 | 
			
		||||
    if (isPartial) {
 | 
			
		||||
        console.log(`🔄 [${speaker} - partial]: ${text}`);
 | 
			
		||||
    } else if (isFinal) {
 | 
			
		||||
        console.log(`✅ [${speaker} - final]: ${text}`);
 | 
			
		||||
 | 
			
		||||
        const speakerText = speaker.toLowerCase();
 | 
			
		||||
        const conversationText = `${speakerText}: ${text.trim()}`;
 | 
			
		||||
 | 
			
		||||
        realtimeConversationHistory.push(conversationText);
 | 
			
		||||
 | 
			
		||||
        if (realtimeConversationHistory.length > 30) {
 | 
			
		||||
            realtimeConversationHistory = realtimeConversationHistory.slice(-30);
 | 
			
		||||
        if (result.success && result.base64) {
 | 
			
		||||
            console.log('📸 Got fresh screenshot from main process');
 | 
			
		||||
            return result.base64;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log(`📝 Updated realtime conversation history: ${realtimeConversationHistory.length} texts`);
 | 
			
		||||
        console.log(`📋 Latest text: ${conversationText}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (pickleGlass.e() && typeof pickleGlass.e().updateRealtimeTranscription === 'function') {
 | 
			
		||||
        pickleGlass.e().updateRealtimeTranscription({
 | 
			
		||||
            speaker,
 | 
			
		||||
            text,
 | 
			
		||||
            isFinal,
 | 
			
		||||
            isPartial,
 | 
			
		||||
            timestamp,
 | 
			
		||||
        // If no screenshot available, capture one now
 | 
			
		||||
        console.log('📸 No screenshot available, capturing new one');
 | 
			
		||||
        const captureResult = await ipcRenderer.invoke('capture-screenshot', {
 | 
			
		||||
            quality: currentImageQuality,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (captureResult.success && captureResult.base64) {
 | 
			
		||||
            lastScreenshotBase64 = captureResult.base64;
 | 
			
		||||
            return captureResult.base64;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Fallback to last stored screenshot
 | 
			
		||||
        if (lastScreenshotBase64) {
 | 
			
		||||
            console.log('📸 Using cached screenshot');
 | 
			
		||||
            return lastScreenshotBase64;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new Error('Failed to get screenshot');
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error getting current screenshot:', error);
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
ipcRenderer.on('update-structured-data', (_, structuredData) => {
 | 
			
		||||
    console.log('📥 Received structured data update:', structuredData);
 | 
			
		||||
    window.pickleGlass.structuredData = structuredData;
 | 
			
		||||
    window.pickleGlass.setStructuredData(structuredData);
 | 
			
		||||
});
 | 
			
		||||
window.pickleGlass.structuredData = {
 | 
			
		||||
    summary: [],
 | 
			
		||||
    topic: { header: '', bullets: [] },
 | 
			
		||||
    actions: [],
 | 
			
		||||
};
 | 
			
		||||
window.pickleGlass.setStructuredData = data => {
 | 
			
		||||
    window.pickleGlass.structuredData = data;
 | 
			
		||||
    pickleGlass.e()?.updateStructuredData?.(data);
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Main capture functions (exact from renderer.js)
 | 
			
		||||
// ---------------------------
 | 
			
		||||
async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'medium') {
 | 
			
		||||
    // Store the image quality for manual screenshots
 | 
			
		||||
    currentImageQuality = imageQuality;
 | 
			
		||||
@ -490,12 +600,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
 | 
			
		||||
            setupWindowsLoopbackProcessing();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // console.log('MediaStream obtained:', {
 | 
			
		||||
        //     hasVideo: mediaStream.getVideoTracks().length > 0,
 | 
			
		||||
        //     hasAudio: mediaStream.getAudioTracks().length > 0,
 | 
			
		||||
        //     videoTrack: mediaStream.getVideoTracks()[0]?.getSettings(),
 | 
			
		||||
        // });
 | 
			
		||||
 | 
			
		||||
        // Start capturing screenshots - check if manual mode
 | 
			
		||||
        if (screenshotIntervalSeconds === 'manual' || screenshotIntervalSeconds === 'Manual') {
 | 
			
		||||
            console.log('Manual mode enabled - screenshots will be captured on demand only');
 | 
			
		||||
@ -511,162 +615,11 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
 | 
			
		||||
        }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
        console.error('Error starting capture:', err);
 | 
			
		||||
        pickleGlass.e().setStatus('error');
 | 
			
		||||
        // Note: pickleGlass.e() is not available in this context, commenting out
 | 
			
		||||
        // pickleGlass.e().setStatus('error');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupMicProcessing(micStream) {
 | 
			
		||||
    const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
 | 
			
		||||
    const micSource = micAudioContext.createMediaStreamSource(micStream);
 | 
			
		||||
    const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
 | 
			
		||||
 | 
			
		||||
    let audioBuffer = [];
 | 
			
		||||
    const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
    micProcessor.onaudioprocess = async e => {
 | 
			
		||||
        const inputData = e.inputBuffer.getChannelData(0);
 | 
			
		||||
        audioBuffer.push(...inputData);
 | 
			
		||||
 | 
			
		||||
        while (audioBuffer.length >= samplesPerChunk) {
 | 
			
		||||
            let chunk = audioBuffer.splice(0, samplesPerChunk);
 | 
			
		||||
            let processedChunk = new Float32Array(chunk);
 | 
			
		||||
 | 
			
		||||
            // Check for system audio and apply AEC only if voice is active
 | 
			
		||||
            if (aecProcessor && systemAudioBuffer.length > 0) {
 | 
			
		||||
                const latestSystemAudio = systemAudioBuffer[systemAudioBuffer.length - 1];
 | 
			
		||||
                const systemFloat32 = base64ToFloat32Array(latestSystemAudio.data);
 | 
			
		||||
 | 
			
		||||
                // Apply AEC only when system audio has active speech
 | 
			
		||||
                if (isVoiceActive(systemFloat32)) {
 | 
			
		||||
                    processedChunk = aecProcessor.process(new Float32Array(chunk), systemFloat32);
 | 
			
		||||
                    console.log('🔊 Applied AEC because system audio is active');
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const pcmData16 = convertFloat32ToInt16(processedChunk);
 | 
			
		||||
            const base64Data = arrayBufferToBase64(pcmData16.buffer);
 | 
			
		||||
 | 
			
		||||
            await ipcRenderer.invoke('send-audio-content', {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    micSource.connect(micProcessor);
 | 
			
		||||
    micProcessor.connect(micAudioContext.destination);
 | 
			
		||||
 | 
			
		||||
    audioProcessor = micProcessor;
 | 
			
		||||
}
 | 
			
		||||
////////// for index & subjects //////////
 | 
			
		||||
 | 
			
		||||
function setupLinuxMicProcessing(micStream) {
 | 
			
		||||
    // Setup microphone audio processing for Linux
 | 
			
		||||
    const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
 | 
			
		||||
    const micSource = micAudioContext.createMediaStreamSource(micStream);
 | 
			
		||||
    const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
 | 
			
		||||
 | 
			
		||||
    let audioBuffer = [];
 | 
			
		||||
    const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
    micProcessor.onaudioprocess = async e => {
 | 
			
		||||
        const inputData = e.inputBuffer.getChannelData(0);
 | 
			
		||||
        audioBuffer.push(...inputData);
 | 
			
		||||
 | 
			
		||||
        // Process audio in chunks
 | 
			
		||||
        while (audioBuffer.length >= samplesPerChunk) {
 | 
			
		||||
            const chunk = audioBuffer.splice(0, samplesPerChunk);
 | 
			
		||||
            const pcmData16 = convertFloat32ToInt16(chunk);
 | 
			
		||||
            const base64Data = arrayBufferToBase64(pcmData16.buffer);
 | 
			
		||||
 | 
			
		||||
            await ipcRenderer.invoke('send-audio-content', {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    micSource.connect(micProcessor);
 | 
			
		||||
    micProcessor.connect(micAudioContext.destination);
 | 
			
		||||
 | 
			
		||||
    // Store processor reference for cleanup
 | 
			
		||||
    audioProcessor = micProcessor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupWindowsLoopbackProcessing() {
 | 
			
		||||
    // Setup audio processing for Windows loopback audio only
 | 
			
		||||
    audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
 | 
			
		||||
    const source = audioContext.createMediaStreamSource(mediaStream);
 | 
			
		||||
    audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
 | 
			
		||||
 | 
			
		||||
    let audioBuffer = [];
 | 
			
		||||
    const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
    audioProcessor.onaudioprocess = async e => {
 | 
			
		||||
        const inputData = e.inputBuffer.getChannelData(0);
 | 
			
		||||
        audioBuffer.push(...inputData);
 | 
			
		||||
 | 
			
		||||
        // Process audio in chunks
 | 
			
		||||
        while (audioBuffer.length >= samplesPerChunk) {
 | 
			
		||||
            const chunk = audioBuffer.splice(0, samplesPerChunk);
 | 
			
		||||
            const pcmData16 = convertFloat32ToInt16(chunk);
 | 
			
		||||
            const base64Data = arrayBufferToBase64(pcmData16.buffer);
 | 
			
		||||
 | 
			
		||||
            await ipcRenderer.invoke('send-audio-content', {
 | 
			
		||||
                data: base64Data,
 | 
			
		||||
                mimeType: 'audio/pcm;rate=24000',
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    source.connect(audioProcessor);
 | 
			
		||||
    audioProcessor.connect(audioContext.destination);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function captureScreenshot(imageQuality = 'medium', isManual = false) {
 | 
			
		||||
    console.log(`Capturing ${isManual ? 'manual' : 'automated'} screenshot...`);
 | 
			
		||||
 | 
			
		||||
    // Check rate limiting for automated screenshots only
 | 
			
		||||
    if (!isManual && tokenTracker.shouldThrottle()) {
 | 
			
		||||
        console.log('⚠️ Automated screenshot skipped due to rate limiting');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        // Request screenshot from main process
 | 
			
		||||
        const result = await ipcRenderer.invoke('capture-screenshot', {
 | 
			
		||||
            quality: imageQuality,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (result.success && result.base64) {
 | 
			
		||||
            // Store the latest screenshot
 | 
			
		||||
            lastScreenshotBase64 = result.base64;
 | 
			
		||||
 | 
			
		||||
            if (sendResult.success) {
 | 
			
		||||
                // Track image tokens after successful send
 | 
			
		||||
                const imageTokens = tokenTracker.calculateImageTokens(result.width || 1920, result.height || 1080);
 | 
			
		||||
                tokenTracker.addTokens(imageTokens, 'image');
 | 
			
		||||
                console.log(`📊 Image sent successfully - ${imageTokens} tokens used (${result.width}x${result.height})`);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.error('Failed to send image:', sendResult.error);
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            console.error('Failed to capture screenshot:', result.error);
 | 
			
		||||
        }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error capturing screenshot:', error);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function captureManualScreenshot(imageQuality = null) {
 | 
			
		||||
    console.log('Manual screenshot triggered');
 | 
			
		||||
    const quality = imageQuality || currentImageQuality;
 | 
			
		||||
    await captureScreenshot(quality, true);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Expose functions to global scope for external access
 | 
			
		||||
window.captureManualScreenshot = captureManualScreenshot;
 | 
			
		||||
 | 
			
		||||
function stopCapture() {
 | 
			
		||||
    if (screenshotInterval) {
 | 
			
		||||
        clearInterval(screenshotInterval);
 | 
			
		||||
@ -706,76 +659,25 @@ function stopCapture() {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getCurrentScreenshot() {
 | 
			
		||||
    try {
 | 
			
		||||
        // First try to get a fresh screenshot from main process
 | 
			
		||||
        const result = await ipcRenderer.invoke('get-current-screenshot');
 | 
			
		||||
 | 
			
		||||
        if (result.success && result.base64) {
 | 
			
		||||
            console.log('📸 Got fresh screenshot from main process');
 | 
			
		||||
            return result.base64;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If no screenshot available, capture one now
 | 
			
		||||
        console.log('📸 No screenshot available, capturing new one');
 | 
			
		||||
        const captureResult = await ipcRenderer.invoke('capture-screenshot', {
 | 
			
		||||
            quality: currentImageQuality,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (captureResult.success && captureResult.base64) {
 | 
			
		||||
            lastScreenshotBase64 = captureResult.base64;
 | 
			
		||||
            return captureResult.base64;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Fallback to last stored screenshot
 | 
			
		||||
        if (lastScreenshotBase64) {
 | 
			
		||||
            console.log('📸 Using cached screenshot');
 | 
			
		||||
            return lastScreenshotBase64;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new Error('Failed to get screenshot');
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error getting current screenshot:', error);
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatRealtimeConversationHistory() {
 | 
			
		||||
    if (realtimeConversationHistory.length === 0) return 'No conversation history available.';
 | 
			
		||||
 | 
			
		||||
    return realtimeConversationHistory.slice(-30).join('\n');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.pickleGlass = {
 | 
			
		||||
    initializeopenai,
 | 
			
		||||
// ---------------------------
 | 
			
		||||
// Exports & global registration
 | 
			
		||||
// ---------------------------
 | 
			
		||||
module.exports = {
 | 
			
		||||
    startCapture,
 | 
			
		||||
    stopCapture,
 | 
			
		||||
    isLinux: isLinux,
 | 
			
		||||
    isMacOS: isMacOS,
 | 
			
		||||
    e: pickleGlassElement,
 | 
			
		||||
    captureManualScreenshot,
 | 
			
		||||
    getCurrentScreenshot,
 | 
			
		||||
    isLinux,
 | 
			
		||||
    isMacOS,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -------------------------------------------------------
 | 
			
		||||
// 🔔 React to session state changes from the main process
 | 
			
		||||
// When the session ends (isActive === false), ensure we stop
 | 
			
		||||
// all local capture pipelines (mic, screen, etc.).
 | 
			
		||||
// -------------------------------------------------------
 | 
			
		||||
ipcRenderer.on('session-state-changed', (_event, { isActive }) => {
 | 
			
		||||
    if (!isActive) {
 | 
			
		||||
        console.log('[Renderer] Session ended – stopping local capture');
 | 
			
		||||
        stopCapture();
 | 
			
		||||
    } else {
 | 
			
		||||
        console.log('[Renderer] New session started – clearing in-memory history and summaries');
 | 
			
		||||
 | 
			
		||||
        // Reset live conversation & analysis caches
 | 
			
		||||
        realtimeConversationHistory = [];
 | 
			
		||||
 | 
			
		||||
        const blankData = {
 | 
			
		||||
            summary: [],
 | 
			
		||||
            topic: { header: '', bullets: [] },
 | 
			
		||||
            actions: [],
 | 
			
		||||
            followUps: [],
 | 
			
		||||
        };
 | 
			
		||||
        window.pickleGlass.setStructuredData(blankData);
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
// Expose functions to global scope for external access (exact from renderer.js)
 | 
			
		||||
if (typeof window !== 'undefined') {
 | 
			
		||||
    window.captureManualScreenshot = captureManualScreenshot;
 | 
			
		||||
    window.listenCapture = module.exports;
 | 
			
		||||
    window.pickleGlass = window.pickleGlass || {};
 | 
			
		||||
    window.pickleGlass.startCapture = startCapture;
 | 
			
		||||
    window.pickleGlass.stopCapture = stopCapture;
 | 
			
		||||
    window.pickleGlass.captureManualScreenshot = captureManualScreenshot;
 | 
			
		||||
    window.pickleGlass.getCurrentScreenshot = getCurrentScreenshot;
 | 
			
		||||
} 
 | 
			
		||||
							
								
								
									
										139
									
								
								src/features/listen/renderer/renderer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/features/listen/renderer/renderer.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,139 @@
 | 
			
		||||
// renderer.js
 | 
			
		||||
const { ipcRenderer } = require('electron');
 | 
			
		||||
const { makeStreamingChatCompletionWithPortkey } = require('../../../common/services/aiProviderService.js');
 | 
			
		||||
const listenCapture = require('./listenCapture.js');
 | 
			
		||||
 | 
			
		||||
let realtimeConversationHistory = [];
 | 
			
		||||
 | 
			
		||||
async function queryLoginState() {
 | 
			
		||||
    const userState = await ipcRenderer.invoke('get-current-user');
 | 
			
		||||
    return userState;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function pickleGlassElement() {
 | 
			
		||||
    return document.getElementById('pickle-glass');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function initializeopenai(profile = 'interview', language = 'en') {
 | 
			
		||||
    // The API key is now handled in the main process from .env file.
 | 
			
		||||
    // We just need to trigger the initialization.
 | 
			
		||||
    try {
 | 
			
		||||
        console.log(`Requesting OpenAI initialization with profile: ${profile}, language: ${language}`);
 | 
			
		||||
        const success = await ipcRenderer.invoke('initialize-openai', profile, language);
 | 
			
		||||
        if (success) {
 | 
			
		||||
            // The status will be updated via 'update-status' event from the main process.
 | 
			
		||||
            console.log('OpenAI initialization successful.');
 | 
			
		||||
        } else {
 | 
			
		||||
            console.error('OpenAI initialization failed.');
 | 
			
		||||
            const appElement = pickleGlassElement();
 | 
			
		||||
            if (appElement && typeof appElement.setStatus === 'function') {
 | 
			
		||||
                appElement.setStatus('Initialization Failed');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Error during OpenAI initialization IPC call:', error);
 | 
			
		||||
        const appElement = pickleGlassElement();
 | 
			
		||||
        if (appElement && typeof appElement.setStatus === 'function') {
 | 
			
		||||
            appElement.setStatus('Error');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Listen for status updates
 | 
			
		||||
ipcRenderer.on('update-status', (event, status) => {
 | 
			
		||||
    console.log('Status update:', status);
 | 
			
		||||
    pickleGlass.e().setStatus(status);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Listen for real-time STT updates
 | 
			
		||||
ipcRenderer.on('stt-update', (event, data) => {
 | 
			
		||||
    console.log('Renderer.js stt-update', data);
 | 
			
		||||
    const { speaker, text, isFinal, isPartial, timestamp } = data;
 | 
			
		||||
 | 
			
		||||
    if (isPartial) {
 | 
			
		||||
        console.log(`🔄 [${speaker} - partial]: ${text}`);
 | 
			
		||||
    } else if (isFinal) {
 | 
			
		||||
        console.log(`✅ [${speaker} - final]: ${text}`);
 | 
			
		||||
 | 
			
		||||
        const speakerText = speaker.toLowerCase();
 | 
			
		||||
        const conversationText = `${speakerText}: ${text.trim()}`;
 | 
			
		||||
 | 
			
		||||
        realtimeConversationHistory.push(conversationText);
 | 
			
		||||
 | 
			
		||||
        if (realtimeConversationHistory.length > 30) {
 | 
			
		||||
            realtimeConversationHistory = realtimeConversationHistory.slice(-30);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log(`📝 Updated realtime conversation history: ${realtimeConversationHistory.length} texts`);
 | 
			
		||||
        console.log(`📋 Latest text: ${conversationText}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (pickleGlass.e() && typeof pickleGlass.e().updateRealtimeTranscription === 'function') {
 | 
			
		||||
        pickleGlass.e().updateRealtimeTranscription({
 | 
			
		||||
            speaker,
 | 
			
		||||
            text,
 | 
			
		||||
            isFinal,
 | 
			
		||||
            isPartial,
 | 
			
		||||
            timestamp,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
ipcRenderer.on('update-structured-data', (_, structuredData) => {
 | 
			
		||||
    console.log('📥 Received structured data update:', structuredData);
 | 
			
		||||
    window.pickleGlass.structuredData = structuredData;
 | 
			
		||||
    window.pickleGlass.setStructuredData(structuredData);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
window.pickleGlass.structuredData = {
 | 
			
		||||
    summary: [],
 | 
			
		||||
    topic: { header: '', bullets: [] },
 | 
			
		||||
    actions: [],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
window.pickleGlass.setStructuredData = data => {
 | 
			
		||||
    window.pickleGlass.structuredData = data;
 | 
			
		||||
    pickleGlass.e()?.updateStructuredData?.(data);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function formatRealtimeConversationHistory() {
 | 
			
		||||
    if (realtimeConversationHistory.length === 0) return 'No conversation history available.';
 | 
			
		||||
 | 
			
		||||
    return realtimeConversationHistory.slice(-30).join('\n');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.pickleGlass = {
 | 
			
		||||
    initializeopenai,
 | 
			
		||||
    startCapture: listenCapture.startCapture,
 | 
			
		||||
    stopCapture: listenCapture.stopCapture,
 | 
			
		||||
    isLinux: listenCapture.isLinux,
 | 
			
		||||
    isMacOS: listenCapture.isMacOS,
 | 
			
		||||
    captureManualScreenshot: listenCapture.captureManualScreenshot,
 | 
			
		||||
    getCurrentScreenshot: listenCapture.getCurrentScreenshot,
 | 
			
		||||
    e: pickleGlassElement,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// -------------------------------------------------------
 | 
			
		||||
// 🔔 React to session state changes from the main process
 | 
			
		||||
// When the session ends (isActive === false), ensure we stop
 | 
			
		||||
// all local capture pipelines (mic, screen, etc.).
 | 
			
		||||
// -------------------------------------------------------
 | 
			
		||||
ipcRenderer.on('session-state-changed', (_event, { isActive }) => {
 | 
			
		||||
    if (!isActive) {
 | 
			
		||||
        console.log('[Renderer] Session ended – stopping local capture');
 | 
			
		||||
        listenCapture.stopCapture();
 | 
			
		||||
    } else {
 | 
			
		||||
        console.log('[Renderer] New session started – clearing in-memory history and summaries');
 | 
			
		||||
 | 
			
		||||
        // Reset live conversation & analysis caches
 | 
			
		||||
        realtimeConversationHistory = [];
 | 
			
		||||
 | 
			
		||||
        const blankData = {
 | 
			
		||||
            summary: [],
 | 
			
		||||
            topic: { header: '', bullets: [] },
 | 
			
		||||
            actions: [],
 | 
			
		||||
            followUps: [],
 | 
			
		||||
        };
 | 
			
		||||
        window.pickleGlass.setStructuredData(blankData);
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										5
									
								
								src/features/listen/stt/repositories/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/features/listen/stt/repositories/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
const sttRepository = require('./sqlite.repository');
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    ...sttRepository,
 | 
			
		||||
}; 
 | 
			
		||||
							
								
								
									
										37
									
								
								src/features/listen/stt/repositories/sqlite.repository.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/features/listen/stt/repositories/sqlite.repository.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
const sqliteClient = require('../../../../common/services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function addTranscript({ sessionId, speaker, text }) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
        const transcriptId = require('crypto').randomUUID();
 | 
			
		||||
        const now = Math.floor(Date.now() / 1000);
 | 
			
		||||
        const query = `INSERT INTO transcripts (id, session_id, start_at, speaker, text, created_at) VALUES (?, ?, ?, ?, ?, ?)`;
 | 
			
		||||
        db.run(query, [transcriptId, sessionId, now, speaker, text, now], function(err) {
 | 
			
		||||
            if (err) {
 | 
			
		||||
                console.error('Error adding transcript:', err);
 | 
			
		||||
                reject(err);
 | 
			
		||||
            } else {
 | 
			
		||||
                resolve({ id: transcriptId });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getAllTranscriptsBySessionId(sessionId) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
        const query = "SELECT * FROM transcripts WHERE session_id = ? ORDER BY start_at ASC";
 | 
			
		||||
        db.all(query, [sessionId], (err, rows) => {
 | 
			
		||||
            if (err) {
 | 
			
		||||
                reject(err);
 | 
			
		||||
            } else {
 | 
			
		||||
                resolve(rows);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    addTranscript,
 | 
			
		||||
    getAllTranscriptsBySessionId,
 | 
			
		||||
}; 
 | 
			
		||||
							
								
								
									
										480
									
								
								src/features/listen/stt/sttService.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										480
									
								
								src/features/listen/stt/sttService.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,480 @@
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { spawn } = require('child_process');
 | 
			
		||||
const { connectToGeminiSession } = require('../../../common/services/googleGeminiClient.js');
 | 
			
		||||
const { connectToOpenAiSession } = require('../../../common/services/openAiClient.js');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider } = require('../../../electron/windowManager');
 | 
			
		||||
 | 
			
		||||
const COMPLETION_DEBOUNCE_MS = 2000;
 | 
			
		||||
 | 
			
		||||
class SttService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.mySttSession = null;
 | 
			
		||||
        this.theirSttSession = null;
 | 
			
		||||
        this.myCurrentUtterance = '';
 | 
			
		||||
        this.theirCurrentUtterance = '';
 | 
			
		||||
        
 | 
			
		||||
        this.myLastPartialText = '';
 | 
			
		||||
        this.theirLastPartialText = '';
 | 
			
		||||
        this.myInactivityTimer = null;
 | 
			
		||||
        this.theirInactivityTimer = null;
 | 
			
		||||
        
 | 
			
		||||
        // Turn-completion debouncing
 | 
			
		||||
        this.myCompletionBuffer = '';
 | 
			
		||||
        this.theirCompletionBuffer = '';
 | 
			
		||||
        this.myCompletionTimer = null;
 | 
			
		||||
        this.theirCompletionTimer = null;
 | 
			
		||||
        
 | 
			
		||||
        // System audio capture
 | 
			
		||||
        this.systemAudioProc = null;
 | 
			
		||||
        
 | 
			
		||||
        // Callbacks
 | 
			
		||||
        this.onTranscriptionComplete = null;
 | 
			
		||||
        this.onStatusUpdate = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setCallbacks({ onTranscriptionComplete, onStatusUpdate }) {
 | 
			
		||||
        this.onTranscriptionComplete = onTranscriptionComplete;
 | 
			
		||||
        this.onStatusUpdate = onStatusUpdate;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getApiKey() {
 | 
			
		||||
        const storedKey = await getStoredApiKey();
 | 
			
		||||
        if (storedKey) {
 | 
			
		||||
            console.log('[SttService] Using stored API key');
 | 
			
		||||
            return storedKey;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const envKey = process.env.OPENAI_API_KEY;
 | 
			
		||||
        if (envKey) {
 | 
			
		||||
            console.log('[SttService] Using environment API key');
 | 
			
		||||
            return envKey;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.error('[SttService] No API key found in storage or environment');
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAiProvider() {
 | 
			
		||||
        try {
 | 
			
		||||
            const { ipcRenderer } = require('electron');
 | 
			
		||||
            const provider = await ipcRenderer.invoke('get-ai-provider');
 | 
			
		||||
            return provider || 'openai';
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            return getStoredProvider ? getStoredProvider() : 'openai';
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    flushMyCompletion() {
 | 
			
		||||
        if (!this.myCompletionBuffer.trim()) return;
 | 
			
		||||
 | 
			
		||||
        const finalText = this.myCompletionBuffer.trim();
 | 
			
		||||
        
 | 
			
		||||
        // Notify completion callback
 | 
			
		||||
        if (this.onTranscriptionComplete) {
 | 
			
		||||
            this.onTranscriptionComplete('Me', finalText);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Send to renderer as final
 | 
			
		||||
        this.sendToRenderer('stt-update', {
 | 
			
		||||
            speaker: 'Me',
 | 
			
		||||
            text: finalText,
 | 
			
		||||
            isPartial: false,
 | 
			
		||||
            isFinal: true,
 | 
			
		||||
            timestamp: Date.now(),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.myCompletionBuffer = '';
 | 
			
		||||
        this.myCompletionTimer = null;
 | 
			
		||||
        this.myCurrentUtterance = '';
 | 
			
		||||
        
 | 
			
		||||
        if (this.onStatusUpdate) {
 | 
			
		||||
            this.onStatusUpdate('Listening...');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    flushTheirCompletion() {
 | 
			
		||||
        if (!this.theirCompletionBuffer.trim()) return;
 | 
			
		||||
 | 
			
		||||
        const finalText = this.theirCompletionBuffer.trim();
 | 
			
		||||
        
 | 
			
		||||
        // Notify completion callback
 | 
			
		||||
        if (this.onTranscriptionComplete) {
 | 
			
		||||
            this.onTranscriptionComplete('Them', finalText);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Send to renderer as final
 | 
			
		||||
        this.sendToRenderer('stt-update', {
 | 
			
		||||
            speaker: 'Them',
 | 
			
		||||
            text: finalText,
 | 
			
		||||
            isPartial: false,
 | 
			
		||||
            isFinal: true,
 | 
			
		||||
            timestamp: Date.now(),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.theirCompletionBuffer = '';
 | 
			
		||||
        this.theirCompletionTimer = null;
 | 
			
		||||
        this.theirCurrentUtterance = '';
 | 
			
		||||
        
 | 
			
		||||
        if (this.onStatusUpdate) {
 | 
			
		||||
            this.onStatusUpdate('Listening...');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    debounceMyCompletion(text) {
 | 
			
		||||
        // 상대방이 말하고 있던 경우, 화자가 변경되었으므로 즉시 상대방의 말풍선을 완성합니다.
 | 
			
		||||
        if (this.theirCompletionTimer) {
 | 
			
		||||
            clearTimeout(this.theirCompletionTimer);
 | 
			
		||||
            this.flushTheirCompletion();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.myCompletionBuffer += (this.myCompletionBuffer ? ' ' : '') + text;
 | 
			
		||||
 | 
			
		||||
        if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
 | 
			
		||||
        this.myCompletionTimer = setTimeout(() => this.flushMyCompletion(), COMPLETION_DEBOUNCE_MS);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    debounceTheirCompletion(text) {
 | 
			
		||||
        // 내가 말하고 있던 경우, 화자가 변경되었으므로 즉시 내 말풍선을 완성합니다.
 | 
			
		||||
        if (this.myCompletionTimer) {
 | 
			
		||||
            clearTimeout(this.myCompletionTimer);
 | 
			
		||||
            this.flushMyCompletion();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.theirCompletionBuffer += (this.theirCompletionBuffer ? ' ' : '') + text;
 | 
			
		||||
 | 
			
		||||
        if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
 | 
			
		||||
        this.theirCompletionTimer = setTimeout(() => this.flushTheirCompletion(), COMPLETION_DEBOUNCE_MS);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initializeSttSessions(language = 'en') {
 | 
			
		||||
        const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
 | 
			
		||||
        
 | 
			
		||||
        const API_KEY = await this.getApiKey();
 | 
			
		||||
        if (!API_KEY) {
 | 
			
		||||
            throw new Error('No API key available');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const provider = await this.getAiProvider();
 | 
			
		||||
        const isGemini = provider === 'gemini';
 | 
			
		||||
        console.log(`[SttService] Initializing STT for provider: ${provider}`);
 | 
			
		||||
 | 
			
		||||
        const handleMyMessage = message => {
 | 
			
		||||
            if (isGemini) {
 | 
			
		||||
                const text = message.serverContent?.inputTranscription?.text || '';
 | 
			
		||||
                if (text && text.trim()) {
 | 
			
		||||
                    const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
 | 
			
		||||
                    if (finalUtteranceText && finalUtteranceText !== '.') {
 | 
			
		||||
                        this.debounceMyCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const type = message.type;
 | 
			
		||||
                const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
 | 
			
		||||
 | 
			
		||||
                if (type === 'conversation.item.input_audio_transcription.delta') {
 | 
			
		||||
                    if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
 | 
			
		||||
                    this.myCompletionTimer = null;
 | 
			
		||||
                    this.myCurrentUtterance += text;
 | 
			
		||||
                    const continuousText = this.myCompletionBuffer + (this.myCompletionBuffer ? ' ' : '') + this.myCurrentUtterance;
 | 
			
		||||
                    if (text && !text.includes('vq_lbr_audio_')) {
 | 
			
		||||
                        this.sendToRenderer('stt-update', {
 | 
			
		||||
                            speaker: 'Me',
 | 
			
		||||
                            text: continuousText,
 | 
			
		||||
                            isPartial: true,
 | 
			
		||||
                            isFinal: false,
 | 
			
		||||
                            timestamp: Date.now(),
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (type === 'conversation.item.input_audio_transcription.completed') {
 | 
			
		||||
                    if (text && text.trim()) {
 | 
			
		||||
                        const finalUtteranceText = text.trim();
 | 
			
		||||
                        this.myCurrentUtterance = '';
 | 
			
		||||
                        this.debounceMyCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (message.error) {
 | 
			
		||||
                console.error('[Me] STT Session Error:', message.error);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const handleTheirMessage = message => {
 | 
			
		||||
            if (isGemini) {
 | 
			
		||||
                const text = message.serverContent?.inputTranscription?.text || '';
 | 
			
		||||
                if (text && text.trim()) {
 | 
			
		||||
                    const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
 | 
			
		||||
                    if (finalUtteranceText && finalUtteranceText !== '.') {
 | 
			
		||||
                        this.debounceTheirCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                const type = message.type;
 | 
			
		||||
                const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
 | 
			
		||||
                if (type === 'conversation.item.input_audio_transcription.delta') {
 | 
			
		||||
                    if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
 | 
			
		||||
                    this.theirCompletionTimer = null;
 | 
			
		||||
                    this.theirCurrentUtterance += text;
 | 
			
		||||
                    const continuousText = this.theirCompletionBuffer + (this.theirCompletionBuffer ? ' ' : '') + this.theirCurrentUtterance;
 | 
			
		||||
                    if (text && !text.includes('vq_lbr_audio_')) {
 | 
			
		||||
                        this.sendToRenderer('stt-update', {
 | 
			
		||||
                            speaker: 'Them',
 | 
			
		||||
                            text: continuousText,
 | 
			
		||||
                            isPartial: true,
 | 
			
		||||
                            isFinal: false,
 | 
			
		||||
                            timestamp: Date.now(),
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (type === 'conversation.item.input_audio_transcription.completed') {
 | 
			
		||||
                    if (text && text.trim()) {
 | 
			
		||||
                        const finalUtteranceText = text.trim();
 | 
			
		||||
                        this.theirCurrentUtterance = '';
 | 
			
		||||
                        this.debounceTheirCompletion(finalUtteranceText);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if (message.error) {
 | 
			
		||||
                console.error('[Them] STT Session Error:', message.error);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const mySttConfig = {
 | 
			
		||||
            language: effectiveLanguage,
 | 
			
		||||
            callbacks: {
 | 
			
		||||
                onmessage: handleMyMessage,
 | 
			
		||||
                onerror: error => console.error('My STT session error:', error.message),
 | 
			
		||||
                onclose: event => console.log('My STT session closed:', event.reason),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        const theirSttConfig = {
 | 
			
		||||
            language: effectiveLanguage,
 | 
			
		||||
            callbacks: {
 | 
			
		||||
                onmessage: handleTheirMessage,
 | 
			
		||||
                onerror: error => console.error('Their STT session error:', error.message),
 | 
			
		||||
                onclose: event => console.log('Their STT session closed:', event.reason),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Determine key type based on auth status
 | 
			
		||||
        const authService = require('../../../common/services/authService');
 | 
			
		||||
        const userState = authService.getCurrentUser();
 | 
			
		||||
        const loggedIn = userState.isLoggedIn;
 | 
			
		||||
        const keyType = loggedIn ? 'vKey' : 'apiKey';
 | 
			
		||||
 | 
			
		||||
        if (isGemini) {
 | 
			
		||||
            [this.mySttSession, this.theirSttSession] = await Promise.all([
 | 
			
		||||
                connectToGeminiSession(API_KEY, mySttConfig),
 | 
			
		||||
                connectToGeminiSession(API_KEY, theirSttConfig),
 | 
			
		||||
            ]);
 | 
			
		||||
        } else {
 | 
			
		||||
            [this.mySttSession, this.theirSttSession] = await Promise.all([
 | 
			
		||||
                connectToOpenAiSession(API_KEY, mySttConfig, keyType),
 | 
			
		||||
                connectToOpenAiSession(API_KEY, theirSttConfig, keyType),
 | 
			
		||||
            ]);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log('✅ Both STT sessions initialized successfully.');
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async sendAudioContent(data, mimeType) {
 | 
			
		||||
        const provider = await this.getAiProvider();
 | 
			
		||||
        const isGemini = provider === 'gemini';
 | 
			
		||||
        
 | 
			
		||||
        if (!this.mySttSession) {
 | 
			
		||||
            throw new Error('User STT session not active');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const payload = isGemini
 | 
			
		||||
            ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
 | 
			
		||||
            : data;
 | 
			
		||||
 | 
			
		||||
        await this.mySttSession.sendRealtimeInput(payload);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    killExistingSystemAudioDump() {
 | 
			
		||||
        return new Promise(resolve => {
 | 
			
		||||
            console.log('Checking for existing SystemAudioDump processes...');
 | 
			
		||||
 | 
			
		||||
            const killProc = spawn('pkill', ['-f', 'SystemAudioDump'], {
 | 
			
		||||
                stdio: 'ignore',
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            killProc.on('close', code => {
 | 
			
		||||
                if (code === 0) {
 | 
			
		||||
                    console.log('Killed existing SystemAudioDump processes');
 | 
			
		||||
                } else {
 | 
			
		||||
                    console.log('No existing SystemAudioDump processes found');
 | 
			
		||||
                }
 | 
			
		||||
                resolve();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            killProc.on('error', err => {
 | 
			
		||||
                console.log('Error checking for existing processes (this is normal):', err.message);
 | 
			
		||||
                resolve();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                killProc.kill();
 | 
			
		||||
                resolve();
 | 
			
		||||
            }, 2000);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async startMacOSAudioCapture() {
 | 
			
		||||
        if (process.platform !== 'darwin' || !this.theirSttSession) return false;
 | 
			
		||||
 | 
			
		||||
        await this.killExistingSystemAudioDump();
 | 
			
		||||
        console.log('Starting macOS audio capture for "Them"...');
 | 
			
		||||
 | 
			
		||||
        const { app } = require('electron');
 | 
			
		||||
        const path = require('path');
 | 
			
		||||
        const systemAudioPath = app.isPackaged
 | 
			
		||||
            ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump')
 | 
			
		||||
            : path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump');
 | 
			
		||||
 | 
			
		||||
        console.log('SystemAudioDump path:', systemAudioPath);
 | 
			
		||||
 | 
			
		||||
        this.systemAudioProc = spawn(systemAudioPath, [], {
 | 
			
		||||
            stdio: ['ignore', 'pipe', 'pipe'],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (!this.systemAudioProc.pid) {
 | 
			
		||||
            console.error('Failed to start SystemAudioDump');
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log('SystemAudioDump started with PID:', this.systemAudioProc.pid);
 | 
			
		||||
 | 
			
		||||
        const CHUNK_DURATION = 0.1;
 | 
			
		||||
        const SAMPLE_RATE = 24000;
 | 
			
		||||
        const BYTES_PER_SAMPLE = 2;
 | 
			
		||||
        const CHANNELS = 2;
 | 
			
		||||
        const CHUNK_SIZE = SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS * CHUNK_DURATION;
 | 
			
		||||
 | 
			
		||||
        let audioBuffer = Buffer.alloc(0);
 | 
			
		||||
 | 
			
		||||
        const provider = await this.getAiProvider();
 | 
			
		||||
        const isGemini = provider === 'gemini';
 | 
			
		||||
 | 
			
		||||
        this.systemAudioProc.stdout.on('data', async data => {
 | 
			
		||||
            audioBuffer = Buffer.concat([audioBuffer, data]);
 | 
			
		||||
 | 
			
		||||
            while (audioBuffer.length >= CHUNK_SIZE) {
 | 
			
		||||
                const chunk = audioBuffer.slice(0, CHUNK_SIZE);
 | 
			
		||||
                audioBuffer = audioBuffer.slice(CHUNK_SIZE);
 | 
			
		||||
 | 
			
		||||
                const monoChunk = CHANNELS === 2 ? this.convertStereoToMono(chunk) : chunk;
 | 
			
		||||
                const base64Data = monoChunk.toString('base64');
 | 
			
		||||
 | 
			
		||||
                this.sendToRenderer('system-audio-data', { data: base64Data });
 | 
			
		||||
 | 
			
		||||
                if (this.theirSttSession) {
 | 
			
		||||
                    try {
 | 
			
		||||
                        const payload = isGemini
 | 
			
		||||
                            ? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
 | 
			
		||||
                            : base64Data;
 | 
			
		||||
                        await this.theirSttSession.sendRealtimeInput(payload);
 | 
			
		||||
                    } catch (err) {
 | 
			
		||||
                        console.error('Error sending system audio:', err.message);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.systemAudioProc.stderr.on('data', data => {
 | 
			
		||||
            console.error('SystemAudioDump stderr:', data.toString());
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.systemAudioProc.on('close', code => {
 | 
			
		||||
            console.log('SystemAudioDump process closed with code:', code);
 | 
			
		||||
            this.systemAudioProc = null;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.systemAudioProc.on('error', err => {
 | 
			
		||||
            console.error('SystemAudioDump process error:', err);
 | 
			
		||||
            this.systemAudioProc = null;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    convertStereoToMono(stereoBuffer) {
 | 
			
		||||
        const samples = stereoBuffer.length / 4;
 | 
			
		||||
        const monoBuffer = Buffer.alloc(samples * 2);
 | 
			
		||||
 | 
			
		||||
        for (let i = 0; i < samples; i++) {
 | 
			
		||||
            const leftSample = stereoBuffer.readInt16LE(i * 4);
 | 
			
		||||
            monoBuffer.writeInt16LE(leftSample, i * 2);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return monoBuffer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    stopMacOSAudioCapture() {
 | 
			
		||||
        if (this.systemAudioProc) {
 | 
			
		||||
            console.log('Stopping SystemAudioDump...');
 | 
			
		||||
            this.systemAudioProc.kill('SIGTERM');
 | 
			
		||||
            this.systemAudioProc = null;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isSessionActive() {
 | 
			
		||||
        return !!this.mySttSession && !!this.theirSttSession;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async closeSessions() {
 | 
			
		||||
        this.stopMacOSAudioCapture();
 | 
			
		||||
 | 
			
		||||
        // Clear timers
 | 
			
		||||
        if (this.myInactivityTimer) {
 | 
			
		||||
            clearTimeout(this.myInactivityTimer);
 | 
			
		||||
            this.myInactivityTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.theirInactivityTimer) {
 | 
			
		||||
            clearTimeout(this.theirInactivityTimer);
 | 
			
		||||
            this.theirInactivityTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.myCompletionTimer) {
 | 
			
		||||
            clearTimeout(this.myCompletionTimer);
 | 
			
		||||
            this.myCompletionTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.theirCompletionTimer) {
 | 
			
		||||
            clearTimeout(this.theirCompletionTimer);
 | 
			
		||||
            this.theirCompletionTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const closePromises = [];
 | 
			
		||||
        if (this.mySttSession) {
 | 
			
		||||
            closePromises.push(this.mySttSession.close());
 | 
			
		||||
            this.mySttSession = null;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.theirSttSession) {
 | 
			
		||||
            closePromises.push(this.theirSttSession.close());
 | 
			
		||||
            this.theirSttSession = null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await Promise.all(closePromises);
 | 
			
		||||
        console.log('All STT sessions closed.');
 | 
			
		||||
 | 
			
		||||
        // Reset state
 | 
			
		||||
        this.myCurrentUtterance = '';
 | 
			
		||||
        this.theirCurrentUtterance = '';
 | 
			
		||||
        this.myLastPartialText = '';
 | 
			
		||||
        this.theirLastPartialText = '';
 | 
			
		||||
        this.myCompletionBuffer = '';
 | 
			
		||||
        this.theirCompletionBuffer = '';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = SttService; 
 | 
			
		||||
							
								
								
									
										5
									
								
								src/features/listen/summary/repositories/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/features/listen/summary/repositories/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
const summaryRepository = require('./sqlite.repository');
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    ...summaryRepository,
 | 
			
		||||
}; 
 | 
			
		||||
@ -0,0 +1,47 @@
 | 
			
		||||
const sqliteClient = require('../../../../common/services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
        const now = Math.floor(Date.now() / 1000);
 | 
			
		||||
        const query = `
 | 
			
		||||
            INSERT INTO summaries (session_id, generated_at, model, text, tldr, bullet_json, action_json, updated_at) 
 | 
			
		||||
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
            ON CONFLICT(session_id) DO UPDATE SET
 | 
			
		||||
                generated_at=excluded.generated_at,
 | 
			
		||||
                model=excluded.model,
 | 
			
		||||
                text=excluded.text,
 | 
			
		||||
                tldr=excluded.tldr,
 | 
			
		||||
                bullet_json=excluded.bullet_json,
 | 
			
		||||
                action_json=excluded.action_json,
 | 
			
		||||
                updated_at=excluded.updated_at
 | 
			
		||||
        `;
 | 
			
		||||
        db.run(query, [sessionId, now, model, text, tldr, bullet_json, action_json, now], function(err) {
 | 
			
		||||
            if (err) {
 | 
			
		||||
                console.error('Error saving summary:', err);
 | 
			
		||||
                reject(err);
 | 
			
		||||
            } else {
 | 
			
		||||
                resolve({ changes: this.changes });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getSummaryBySessionId(sessionId) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
        const query = "SELECT * FROM summaries WHERE session_id = ?";
 | 
			
		||||
        db.get(query, [sessionId], (err, row) => {
 | 
			
		||||
            if (err) {
 | 
			
		||||
                reject(err);
 | 
			
		||||
            } else {
 | 
			
		||||
                resolve(row || null);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    saveSummary,
 | 
			
		||||
    getSummaryBySessionId,
 | 
			
		||||
}; 
 | 
			
		||||
							
								
								
									
										357
									
								
								src/features/listen/summary/summaryService.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								src/features/listen/summary/summaryService.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,357 @@
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { getSystemPrompt } = require('../../../common/prompts/promptBuilder.js');
 | 
			
		||||
const { makeChatCompletionWithPortkey } = require('../../../common/services/aiProviderService.js');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../../common/repositories/session');
 | 
			
		||||
const summaryRepository = require('./repositories');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider } = require('../../../electron/windowManager');
 | 
			
		||||
 | 
			
		||||
class SummaryService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.previousAnalysisResult = null;
 | 
			
		||||
        this.analysisHistory = [];
 | 
			
		||||
        this.conversationHistory = [];
 | 
			
		||||
        this.currentSessionId = null;
 | 
			
		||||
        
 | 
			
		||||
        // Callbacks
 | 
			
		||||
        this.onAnalysisComplete = null;
 | 
			
		||||
        this.onStatusUpdate = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setCallbacks({ onAnalysisComplete, onStatusUpdate }) {
 | 
			
		||||
        this.onAnalysisComplete = onAnalysisComplete;
 | 
			
		||||
        this.onStatusUpdate = onStatusUpdate;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSessionId(sessionId) {
 | 
			
		||||
        this.currentSessionId = sessionId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getApiKey() {
 | 
			
		||||
        const storedKey = await getStoredApiKey();
 | 
			
		||||
        if (storedKey) {
 | 
			
		||||
            console.log('[SummaryService] Using stored API key');
 | 
			
		||||
            return storedKey;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const envKey = process.env.OPENAI_API_KEY;
 | 
			
		||||
        if (envKey) {
 | 
			
		||||
            console.log('[SummaryService] Using environment API key');
 | 
			
		||||
            return envKey;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.error('[SummaryService] No API key found in storage or environment');
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addConversationTurn(speaker, text) {
 | 
			
		||||
        const conversationText = `${speaker.toLowerCase()}: ${text.trim()}`;
 | 
			
		||||
        this.conversationHistory.push(conversationText);
 | 
			
		||||
        console.log(`💬 Added conversation text: ${conversationText}`);
 | 
			
		||||
        console.log(`📈 Total conversation history: ${this.conversationHistory.length} texts`);
 | 
			
		||||
 | 
			
		||||
        // Trigger analysis if needed
 | 
			
		||||
        this.triggerAnalysisIfNeeded();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getConversationHistory() {
 | 
			
		||||
        return this.conversationHistory;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    resetConversationHistory() {
 | 
			
		||||
        this.conversationHistory = [];
 | 
			
		||||
        this.previousAnalysisResult = null;
 | 
			
		||||
        this.analysisHistory = [];
 | 
			
		||||
        console.log('🔄 Conversation history and analysis state reset');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Converts conversation history into text to include in the prompt.
 | 
			
		||||
     * @param {Array<string>} conversationTexts - Array of conversation texts ["me: ~~~", "them: ~~~", ...]
 | 
			
		||||
     * @param {number} maxTurns - Maximum number of recent turns to include
 | 
			
		||||
     * @returns {string} - Formatted conversation string for the prompt
 | 
			
		||||
     */
 | 
			
		||||
    formatConversationForPrompt(conversationTexts, maxTurns = 30) {
 | 
			
		||||
        if (conversationTexts.length === 0) return '';
 | 
			
		||||
        return conversationTexts.slice(-maxTurns).join('\n');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async makeOutlineAndRequests(conversationTexts, maxTurns = 30) {
 | 
			
		||||
        console.log(`🔍 makeOutlineAndRequests called - conversationTexts: ${conversationTexts.length}`);
 | 
			
		||||
 | 
			
		||||
        if (conversationTexts.length === 0) {
 | 
			
		||||
            console.log('⚠️ No conversation texts available for analysis');
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const recentConversation = this.formatConversationForPrompt(conversationTexts, maxTurns);
 | 
			
		||||
 | 
			
		||||
        // 이전 분석 결과를 프롬프트에 포함
 | 
			
		||||
        let contextualPrompt = '';
 | 
			
		||||
        if (this.previousAnalysisResult) {
 | 
			
		||||
            contextualPrompt = `
 | 
			
		||||
Previous Analysis Context:
 | 
			
		||||
- Main Topic: ${this.previousAnalysisResult.topic.header}
 | 
			
		||||
- Key Points: ${this.previousAnalysisResult.summary.slice(0, 3).join(', ')}
 | 
			
		||||
- Last Actions: ${this.previousAnalysisResult.actions.slice(0, 2).join(', ')}
 | 
			
		||||
 | 
			
		||||
Please build upon this context while analyzing the new conversation segments.
 | 
			
		||||
`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const basePrompt = getSystemPrompt('pickle_glass_analysis', '', false);
 | 
			
		||||
        const systemPrompt = basePrompt.replace('{{CONVERSATION_HISTORY}}', recentConversation);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            if (this.currentSessionId) {
 | 
			
		||||
                await sessionRepository.touch(this.currentSessionId);
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            const messages = [
 | 
			
		||||
                {
 | 
			
		||||
                    role: 'system',
 | 
			
		||||
                    content: systemPrompt,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    role: 'user',
 | 
			
		||||
                    content: `${contextualPrompt}
 | 
			
		||||
 | 
			
		||||
Analyze the conversation and provide a structured summary. Format your response as follows:
 | 
			
		||||
 | 
			
		||||
**Summary Overview**
 | 
			
		||||
- Main discussion point with context
 | 
			
		||||
 | 
			
		||||
**Key Topic: [Topic Name]**
 | 
			
		||||
- First key insight
 | 
			
		||||
- Second key insight
 | 
			
		||||
- Third key insight
 | 
			
		||||
 | 
			
		||||
**Extended Explanation**
 | 
			
		||||
Provide 2-3 sentences explaining the context and implications.
 | 
			
		||||
 | 
			
		||||
**Suggested Questions**
 | 
			
		||||
1. First follow-up question?
 | 
			
		||||
2. Second follow-up question?
 | 
			
		||||
3. Third follow-up question?
 | 
			
		||||
 | 
			
		||||
Keep all points concise and build upon previous analysis if provided.`,
 | 
			
		||||
                },
 | 
			
		||||
            ];
 | 
			
		||||
 | 
			
		||||
            console.log('🤖 Sending analysis request to AI...');
 | 
			
		||||
 | 
			
		||||
            const API_KEY = await this.getApiKey();
 | 
			
		||||
            if (!API_KEY) {
 | 
			
		||||
                throw new Error('No API key available');
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            const provider = getStoredProvider ? await getStoredProvider() : 'openai';
 | 
			
		||||
            const loggedIn = authService.getCurrentUser().isLoggedIn;
 | 
			
		||||
            const usePortkey = loggedIn && provider === 'openai';
 | 
			
		||||
            
 | 
			
		||||
            console.log(`[SummaryService] provider: ${provider}, usePortkey: ${usePortkey}`);
 | 
			
		||||
 | 
			
		||||
            const completion = await makeChatCompletionWithPortkey({
 | 
			
		||||
                apiKey: API_KEY,
 | 
			
		||||
                provider: provider,
 | 
			
		||||
                messages: messages,
 | 
			
		||||
                temperature: 0.7,
 | 
			
		||||
                maxTokens: 1024,
 | 
			
		||||
                model: provider === 'openai' ? 'gpt-4.1' : 'gemini-2.5-flash',
 | 
			
		||||
                usePortkey: usePortkey,
 | 
			
		||||
                portkeyVirtualKey: usePortkey ? API_KEY : null
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const responseText = completion.content;
 | 
			
		||||
            console.log(`✅ Analysis response received: ${responseText}`);
 | 
			
		||||
            const structuredData = this.parseResponseText(responseText, this.previousAnalysisResult);
 | 
			
		||||
 | 
			
		||||
            if (this.currentSessionId) {
 | 
			
		||||
                summaryRepository.saveSummary({
 | 
			
		||||
                    sessionId: this.currentSessionId,
 | 
			
		||||
                    text: responseText,
 | 
			
		||||
                    tldr: structuredData.summary.join('\n'),
 | 
			
		||||
                    bullet_json: JSON.stringify(structuredData.topic.bullets),
 | 
			
		||||
                    action_json: JSON.stringify(structuredData.actions),
 | 
			
		||||
                    model: 'gpt-4.1'
 | 
			
		||||
                }).catch(err => console.error('[DB] Failed to save summary:', err));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 분석 결과 저장
 | 
			
		||||
            this.previousAnalysisResult = structuredData;
 | 
			
		||||
            this.analysisHistory.push({
 | 
			
		||||
                timestamp: Date.now(),
 | 
			
		||||
                data: structuredData,
 | 
			
		||||
                conversationLength: conversationTexts.length,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // 히스토리 크기 제한 (최근 10개만 유지)
 | 
			
		||||
            if (this.analysisHistory.length > 10) {
 | 
			
		||||
                this.analysisHistory.shift();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return structuredData;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('❌ Error during analysis generation:', error.message);
 | 
			
		||||
            return this.previousAnalysisResult; // 에러 시 이전 결과 반환
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    parseResponseText(responseText, previousResult) {
 | 
			
		||||
        const structuredData = {
 | 
			
		||||
            summary: [],
 | 
			
		||||
            topic: { header: '', bullets: [] },
 | 
			
		||||
            actions: [],
 | 
			
		||||
            followUps: ['✉️ Draft a follow-up email', '✅ Generate action items', '📝 Show summary'],
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // 이전 결과가 있으면 기본값으로 사용
 | 
			
		||||
        if (previousResult) {
 | 
			
		||||
            structuredData.topic.header = previousResult.topic.header;
 | 
			
		||||
            structuredData.summary = [...previousResult.summary];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const lines = responseText.split('\n');
 | 
			
		||||
            let currentSection = '';
 | 
			
		||||
            let isCapturingTopic = false;
 | 
			
		||||
            let topicName = '';
 | 
			
		||||
 | 
			
		||||
            for (const line of lines) {
 | 
			
		||||
                const trimmedLine = line.trim();
 | 
			
		||||
 | 
			
		||||
                // 섹션 헤더 감지
 | 
			
		||||
                if (trimmedLine.startsWith('**Summary Overview**')) {
 | 
			
		||||
                    currentSection = 'summary-overview';
 | 
			
		||||
                    continue;
 | 
			
		||||
                } else if (trimmedLine.startsWith('**Key Topic:')) {
 | 
			
		||||
                    currentSection = 'topic';
 | 
			
		||||
                    isCapturingTopic = true;
 | 
			
		||||
                    topicName = trimmedLine.match(/\*\*Key Topic: (.+?)\*\*/)?.[1] || '';
 | 
			
		||||
                    if (topicName) {
 | 
			
		||||
                        structuredData.topic.header = topicName + ':';
 | 
			
		||||
                    }
 | 
			
		||||
                    continue;
 | 
			
		||||
                } else if (trimmedLine.startsWith('**Extended Explanation**')) {
 | 
			
		||||
                    currentSection = 'explanation';
 | 
			
		||||
                    continue;
 | 
			
		||||
                } else if (trimmedLine.startsWith('**Suggested Questions**')) {
 | 
			
		||||
                    currentSection = 'questions';
 | 
			
		||||
                    continue;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // 컨텐츠 파싱
 | 
			
		||||
                if (trimmedLine.startsWith('-') && currentSection === 'summary-overview') {
 | 
			
		||||
                    const summaryPoint = trimmedLine.substring(1).trim();
 | 
			
		||||
                    if (summaryPoint && !structuredData.summary.includes(summaryPoint)) {
 | 
			
		||||
                        // 기존 summary 업데이트 (최대 5개 유지)
 | 
			
		||||
                        structuredData.summary.unshift(summaryPoint);
 | 
			
		||||
                        if (structuredData.summary.length > 5) {
 | 
			
		||||
                            structuredData.summary.pop();
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (trimmedLine.startsWith('-') && currentSection === 'topic') {
 | 
			
		||||
                    const bullet = trimmedLine.substring(1).trim();
 | 
			
		||||
                    if (bullet && structuredData.topic.bullets.length < 3) {
 | 
			
		||||
                        structuredData.topic.bullets.push(bullet);
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (currentSection === 'explanation' && trimmedLine) {
 | 
			
		||||
                    // explanation을 topic bullets에 추가 (문장 단위로)
 | 
			
		||||
                    const sentences = trimmedLine
 | 
			
		||||
                        .split(/\.\s+/)
 | 
			
		||||
                        .filter(s => s.trim().length > 0)
 | 
			
		||||
                        .map(s => s.trim() + (s.endsWith('.') ? '' : '.'));
 | 
			
		||||
 | 
			
		||||
                    sentences.forEach(sentence => {
 | 
			
		||||
                        if (structuredData.topic.bullets.length < 3 && !structuredData.topic.bullets.includes(sentence)) {
 | 
			
		||||
                            structuredData.topic.bullets.push(sentence);
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
                } else if (trimmedLine.match(/^\d+\./) && currentSection === 'questions') {
 | 
			
		||||
                    const question = trimmedLine.replace(/^\d+\.\s*/, '').trim();
 | 
			
		||||
                    if (question && question.includes('?')) {
 | 
			
		||||
                        structuredData.actions.push(`❓ ${question}`);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 기본 액션 추가
 | 
			
		||||
            const defaultActions = ['✨ What should I say next?', '💬 Suggest follow-up questions'];
 | 
			
		||||
            defaultActions.forEach(action => {
 | 
			
		||||
                if (!structuredData.actions.includes(action)) {
 | 
			
		||||
                    structuredData.actions.push(action);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // 액션 개수 제한
 | 
			
		||||
            structuredData.actions = structuredData.actions.slice(0, 5);
 | 
			
		||||
 | 
			
		||||
            // 유효성 검증 및 이전 데이터 병합
 | 
			
		||||
            if (structuredData.summary.length === 0 && previousResult) {
 | 
			
		||||
                structuredData.summary = previousResult.summary;
 | 
			
		||||
            }
 | 
			
		||||
            if (structuredData.topic.bullets.length === 0 && previousResult) {
 | 
			
		||||
                structuredData.topic.bullets = previousResult.topic.bullets;
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('❌ Error parsing response text:', error);
 | 
			
		||||
            // 에러 시 이전 결과 반환
 | 
			
		||||
            return (
 | 
			
		||||
                previousResult || {
 | 
			
		||||
                    summary: [],
 | 
			
		||||
                    topic: { header: 'Analysis in progress', bullets: [] },
 | 
			
		||||
                    actions: ['✨ What should I say next?', '💬 Suggest follow-up questions'],
 | 
			
		||||
                    followUps: ['✉️ Draft a follow-up email', '✅ Generate action items', '📝 Show summary'],
 | 
			
		||||
                }
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log('📊 Final structured data:', JSON.stringify(structuredData, null, 2));
 | 
			
		||||
        return structuredData;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Triggers analysis when conversation history reaches 5 texts.
 | 
			
		||||
     */
 | 
			
		||||
    async triggerAnalysisIfNeeded() {
 | 
			
		||||
        if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
 | 
			
		||||
            console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`);
 | 
			
		||||
 | 
			
		||||
            this.makeOutlineAndRequests(this.conversationHistory)
 | 
			
		||||
                .then(data => {
 | 
			
		||||
                    if (data) {
 | 
			
		||||
                        console.log('📤 Sending structured data to renderer');
 | 
			
		||||
                        this.sendToRenderer('update-structured-data', data);
 | 
			
		||||
                        
 | 
			
		||||
                        // Notify callback
 | 
			
		||||
                        if (this.onAnalysisComplete) {
 | 
			
		||||
                            this.onAnalysisComplete(data);
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        console.log('❌ No analysis data returned from non-blocking call');
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
                .catch(error => {
 | 
			
		||||
                    console.error('❌ Error in non-blocking analysis:', error);
 | 
			
		||||
                });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getCurrentAnalysisData() {
 | 
			
		||||
        return {
 | 
			
		||||
            previousResult: this.previousAnalysisResult,
 | 
			
		||||
            history: this.analysisHistory,
 | 
			
		||||
            conversationLength: this.conversationHistory.length,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = SummaryService; 
 | 
			
		||||
							
								
								
									
										19
									
								
								src/index.js
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								src/index.js
									
									
									
									
									
								
							@ -13,7 +13,7 @@ if (require('electron-squirrel-startup')) {
 | 
			
		||||
 | 
			
		||||
const { app, BrowserWindow, shell, ipcMain, dialog } = require('electron');
 | 
			
		||||
const { createWindows } = require('./electron/windowManager.js');
 | 
			
		||||
const { setupLiveSummaryIpcHandlers, stopMacOSAudioCapture } = require('./features/listen/liveSummaryService.js');
 | 
			
		||||
const ListenService = require('./features/listen/listenService');
 | 
			
		||||
const { initializeFirebase } = require('./common/services/firebaseClient');
 | 
			
		||||
const databaseInitializer = require('./common/services/databaseInitializer');
 | 
			
		||||
const authService = require('./common/services/authService');
 | 
			
		||||
@ -29,7 +29,9 @@ const sessionRepository = require('./common/repositories/session');
 | 
			
		||||
const eventBridge = new EventEmitter();
 | 
			
		||||
let WEB_PORT = 3000;
 | 
			
		||||
 | 
			
		||||
const openaiSessionRef = { current: null };
 | 
			
		||||
const listenService = new ListenService();
 | 
			
		||||
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
 | 
			
		||||
global.listenService = listenService;
 | 
			
		||||
let deeplink = null; // Initialize as null
 | 
			
		||||
let pendingDeepLinkUrl = null; // Store any deep link that arrives before initialization
 | 
			
		||||
 | 
			
		||||
@ -106,7 +108,7 @@ app.whenReady().then(async () => {
 | 
			
		||||
            sessionRepository.endAllActiveSessions();
 | 
			
		||||
 | 
			
		||||
            authService.initialize();
 | 
			
		||||
            setupLiveSummaryIpcHandlers(openaiSessionRef);
 | 
			
		||||
            listenService.setupIpcHandlers();
 | 
			
		||||
            askService.initialize();
 | 
			
		||||
            setupGeneralIpcHandlers();
 | 
			
		||||
        })
 | 
			
		||||
@ -123,7 +125,7 @@ app.whenReady().then(async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on('window-all-closed', () => {
 | 
			
		||||
    stopMacOSAudioCapture();
 | 
			
		||||
    listenService.stopMacOSAudioCapture();
 | 
			
		||||
    if (process.platform !== 'darwin') {
 | 
			
		||||
        app.quit();
 | 
			
		||||
    }
 | 
			
		||||
@ -131,7 +133,7 @@ app.on('window-all-closed', () => {
 | 
			
		||||
 | 
			
		||||
app.on('before-quit', async () => {
 | 
			
		||||
    console.log('[Shutdown] App is about to quit.');
 | 
			
		||||
    stopMacOSAudioCapture();
 | 
			
		||||
    listenService.stopMacOSAudioCapture();
 | 
			
		||||
    await sessionRepository.endAllActiveSessions();
 | 
			
		||||
    databaseInitializer.close();
 | 
			
		||||
});
 | 
			
		||||
@ -210,7 +212,8 @@ function setupGeneralIpcHandlers() {
 | 
			
		||||
 | 
			
		||||
function setupWebDataHandlers() {
 | 
			
		||||
    const sessionRepository = require('./common/repositories/session');
 | 
			
		||||
    const listenRepository = require('./features/listen/repositories');
 | 
			
		||||
    const sttRepository = require('./features/listen/stt/repositories');
 | 
			
		||||
    const summaryRepository = require('./features/listen/summary/repositories');
 | 
			
		||||
    const askRepository = require('./features/ask/repositories');
 | 
			
		||||
    const userRepository = require('./common/repositories/user');
 | 
			
		||||
    const presetRepository = require('./common/repositories/preset');
 | 
			
		||||
@ -230,9 +233,9 @@ function setupWebDataHandlers() {
 | 
			
		||||
                        result = null;
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                    const transcripts = await listenRepository.getAllTranscriptsBySessionId(payload);
 | 
			
		||||
                    const transcripts = await sttRepository.getAllTranscriptsBySessionId(payload);
 | 
			
		||||
                    const ai_messages = await askRepository.getAllAiMessagesBySessionId(payload);
 | 
			
		||||
                    const summary = await listenRepository.getSummaryBySessionId(payload);
 | 
			
		||||
                    const summary = await summaryRepository.getSummaryBySessionId(payload);
 | 
			
		||||
                    result = { session, transcripts, ai_messages, summary };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'delete-session':
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user