This commit is contained in:
sanio 2025-07-08 21:35:24 +09:00
commit 4b8b097ce2
15 changed files with 239 additions and 169 deletions

View File

@ -31,4 +31,15 @@ jobs:
- name: 🖥️ Build Electron app - name: 🖥️ Build Electron app
# Run Electron build script from root directory # Run Electron build script from root directory
run: npm run build run: npm run build
- name: 🚨 Send failure notification to Slack
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: general
SLACK_TITLE: "🚨 Build Failed"
SLACK_MESSAGE: "😭 Build failed for `${{ github.repository }}` repo on main branch."
SLACK_COLOR: 'danger'
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "aec"]
path = aec
url = https://github.com/samtiz/aec.git

View File

@ -121,7 +121,6 @@ We have a list of [help wanted](https://github.com/pickle-com/glass/issues?q=is%
| 🚧 WIP | Local LLM Support | Supporting Local LLM to power AI answers | | 🚧 WIP | Local LLM Support | Supporting Local LLM to power AI answers |
| 🚧 WIP | AEC Improvement | Transcription is not working occasionally | | 🚧 WIP | AEC Improvement | Transcription is not working occasionally |
| 🚧 WIP | Firebase Data Storage Issue | Session & ask should be saved in firebase for signup users | | 🚧 WIP | Firebase Data Storage Issue | Session & ask should be saved in firebase for signup users |
| 🚧 WIP | Login Issue | Currently breaking when switching between local and sign-in mode |
| 🚧 WIP | Liquid Glass | Liquid Glass UI for MacOS 26 | | 🚧 WIP | Liquid Glass | Liquid Glass UI for MacOS 26 |
### Changelog ### Changelog

1
aec Submodule

@ -0,0 +1 @@
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "pickle-glass", "name": "pickle-glass",
"version": "0.2.1", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pickle-glass", "name": "pickle-glass",
"version": "0.2.1", "version": "0.2.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {

View File

@ -1,7 +1,9 @@
{ {
"name": "pickle-glass", "name": "pickle-glass",
"productName": "Glass", "productName": "Glass",
"version": "0.2.1",
"version": "0.2.2",
"description": "Cl*ely for Free", "description": "Cl*ely for Free",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@ -10,7 +12,7 @@
"package": "npm run build:renderer && electron-forge package", "package": "npm run build:renderer && electron-forge package",
"make": "npm run build:renderer && electron-forge make", "make": "npm run build:renderer && electron-forge make",
"build": "npm run build:all && electron-builder --config electron-builder.yml --publish never", "build": "npm run build:all && electron-builder --config electron-builder.yml --publish never",
"build:win": "npm run build:all && electron-builder --win --x64 --publish never", "build:win": "npm run build:all && electron-builder --win --x64 --publish never",
"publish": "npm run build:all && electron-builder --config electron-builder.yml --publish always", "publish": "npm run build:all && electron-builder --config electron-builder.yml --publish always",
"lint": "eslint --ext .ts,.tsx,.js .", "lint": "eslint --ext .ts,.tsx,.js .",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
@ -39,7 +41,6 @@
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.0", "dotenv": "^17.0.0",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
@ -74,4 +75,4 @@
"optionalDependencies": { "optionalDependencies": {
"electron-liquid-glass": "^1.0.1" "electron-liquid-glass": "^1.0.1"
} }
} }

View File

@ -42,21 +42,27 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz",
"integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.0.3", "@emnapi/wasi-threads": "1.0.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz",
"integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -65,9 +71,11 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz",
"integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -2667,9 +2675,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.180", "version": "1.5.180",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz",
"integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {

20
src/assets/aec.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -17,7 +17,14 @@ async function createSTT({ apiKey, language = 'en-US', callbacks = {}, ...config
const session = await liveClient.live.connect({ const session = await liveClient.live.connect({
model: 'gemini-live-2.5-flash-preview', model: 'gemini-live-2.5-flash-preview',
callbacks, callbacks: {
...callbacks,
onMessage: (msg) => {
if (!msg || typeof msg !== 'object') return;
msg.provider = 'gemini';
callbacks.onmessage?.(msg);
}
},
config: { config: {
inputAudioTranscription: {}, inputAudioTranscription: {},
speechConfig: { languageCode: lang }, speechConfig: { languageCode: lang },

View File

@ -72,6 +72,7 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
close: () => { close: () => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'session.close' })); ws.send(JSON.stringify({ type: 'session.close' }));
ws.onmessage = ws.onerror = () => {}; // 핸들러 제거
ws.close(1000, 'Client initiated close.'); ws.close(1000, 'Client initiated close.');
} }
} }
@ -79,10 +80,17 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
const message = JSON.parse(event.data); // ── 종료·하트비트 패킷 필터링 ──────────────────────────────
if (callbacks && callbacks.onmessage) { if (!event.data || event.data === 'null' || event.data === '[DONE]') return;
callbacks.onmessage(message);
} let msg;
try { msg = JSON.parse(event.data); }
catch { return; } // JSON 파싱 실패 무시
if (!msg || typeof msg !== 'object') return;
msg.provider = 'openai'; // ← 항상 명시
callbacks.onmessage?.(msg);
}; };
ws.onerror = (error) => { ws.onerror = (error) => {

View File

@ -1,4 +1,4 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow, app } = require('electron');
const SttService = require('./stt/sttService'); const SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService'); const SummaryService = require('./summary/summaryService');
const authService = require('../../common/services/authService'); const authService = require('../../common/services/authService');
@ -117,8 +117,27 @@ class ListenService {
throw new Error('Failed to initialize database session'); throw new Error('Failed to initialize database session');
} }
// Initialize STT sessions /* ---------- STT Initialization Retry Logic ---------- */
await this.sttService.initializeSttSessions(language); const MAX_RETRY = 10;
const RETRY_DELAY_MS = 300; // 0.3 seconds
let sttReady = false;
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
await this.sttService.initializeSttSessions(language);
sttReady = true;
break; // Exit on success
} catch (err) {
console.warn(
`[ListenService] STT init attempt ${attempt} failed: ${err.message}`
);
if (attempt < MAX_RETRY) {
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
}
}
}
if (!sttReady) throw new Error('STT init failed after retries');
/* ------------------------------------------- */
console.log('✅ Listen service initialized successfully.'); console.log('✅ Listen service initialized successfully.');
@ -213,9 +232,9 @@ class ListenService {
try { try {
await this.sendAudioContent(data, mimeType); await this.sendAudioContent(data, mimeType);
return { success: true }; return { success: true };
} catch (error) { } catch (e) {
console.error('Error sending user audio:', error); console.error('Error sending user audio:', e);
return { success: false, error: error.message }; return { success: false, error: e.message };
} }
}); });
@ -237,9 +256,13 @@ class ListenService {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
return { success: false, error: 'macOS audio capture only available on macOS' }; return { success: false, error: 'macOS audio capture only available on macOS' };
} }
if (this.sttService.isMacOSAudioRunning?.()) {
return { success: false, error: 'already_running' };
}
try { try {
const success = await this.startMacOSAudioCapture(); const success = await this.startMacOSAudioCapture();
return { success }; return { success, error: null };
} catch (error) { } catch (error) {
console.error('Error starting macOS audio capture:', error); console.error('Error starting macOS audio capture:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
@ -274,4 +297,4 @@ class ListenService {
} }
} }
module.exports = ListenService; module.exports = ListenService;

View File

@ -1,5 +1,30 @@
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
const createAecModule = require('../../../assets/aec.js');
let aecModPromise = null; // 한 번만 로드
let aecMod = null;
let aecPtr = 0; // Rust Aec* 1개만 재사용
/** WASM 모듈 가져오고 1회 초기화 */
async function getAec () {
if (aecModPromise) return aecModPromise; // 캐시
aecModPromise = createAecModule().then((M) => {
aecMod = M;
// C 심볼 → JS 래퍼 바인딩 (딱 1번)
M.newPtr = M.cwrap('AecNew', 'number',
['number','number','number','number']);
M.cancel = M.cwrap('AecCancelEcho', null,
['number','number','number','number','number']);
M.destroy = M.cwrap('AecDestroy', null, ['number']);
return M;
});
return aecModPromise;
}
// 바로 로드-실패 로그를 보기 위해
getAec().catch(console.error);
// --------------------------- // ---------------------------
// Constants & Globals // Constants & Globals
// --------------------------- // ---------------------------
@ -80,128 +105,49 @@ function arrayBufferToBase64(buffer) {
return btoa(binary); return btoa(binary);
} }
// --------------------------- /* ───────────────────────── JS ↔︎ WASM 헬퍼 ───────────────────────── */
// Complete SimpleAEC implementation (exact from renderer.js) function int16PtrFromFloat32(mod, f32) {
// --------------------------- const len = f32.length;
class SimpleAEC { const bytes = len * 2;
constructor() { const ptr = mod._malloc(bytes);
this.adaptiveFilter = new Float32Array(1024); // HEAP16이 없으면 HEAPU8.buffer로 직접 래핑
this.mu = 0.2; const heapBuf = (mod.HEAP16 ? mod.HEAP16.buffer : mod.HEAPU8.buffer);
this.echoDelay = 100; const i16 = new Int16Array(heapBuf, ptr, len);
this.sampleRate = 24000; for (let i = 0; i < len; ++i) {
this.delaySamples = Math.floor((this.echoDelay / 1000) * this.sampleRate); const s = Math.max(-1, Math.min(1, f32[i]));
i16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
this.echoGain = 0.5; }
this.noiseFloor = 0.01; return { ptr, view: i16 };
}
// 🔧 Adaptive-gain parameters (User-tuned, very aggressive)
this.targetErr = 0.002; function float32FromInt16View(i16) {
this.adaptRate = 0.1; const out = new Float32Array(i16.length);
for (let i = 0; i < i16.length; ++i) out[i] = i16[i] / 32768;
console.log('🎯 AEC initialized (hyper-aggressive)'); return out;
} }
process(micData, systemData) { /* 필요하다면 종료 시 */
if (!systemData || systemData.length === 0) { function disposeAec () {
return micData; getAec().then(mod => { if (aecPtr) mod.destroy(aecPtr); });
} }
for (let i = 0; i < systemData.length; i++) { function runAecSync (micF32, sysF32) {
if (systemData[i] > 0.98) systemData[i] = 0.98; if (!aecMod || !aecPtr || !aecMod.HEAPU8) return micF32; // 아직 모듈 안 뜸 → 패스
else if (systemData[i] < -0.98) systemData[i] = -0.98;
const len = micF32.length;
systemData[i] = Math.tanh(systemData[i] * 4); const mic = int16PtrFromFloat32(aecMod, micF32);
} const echo = int16PtrFromFloat32(aecMod, sysF32);
const out = aecMod._malloc(len * 2);
let sum2 = 0;
for (let i = 0; i < systemData.length; i++) sum2 += systemData[i] * systemData[i]; aecMod.cancel(aecPtr, mic.ptr, echo.ptr, out, len);
const rms = Math.sqrt(sum2 / systemData.length);
const targetRms = 0.08; // 🔧 기준 RMS (기존 0.1) const heapBuf = (aecMod.HEAP16 ? aecMod.HEAP16.buffer : aecMod.HEAPU8.buffer);
const scale = targetRms / (rms + 1e-6); // 1e-6: 0-division 방지 const outF32 = float32FromInt16View(new Int16Array(heapBuf, out, len));
const output = new Float32Array(micData.length); aecMod._free(mic.ptr); aecMod._free(echo.ptr); aecMod._free(out);
return outF32;
const optimalDelay = this.findOptimalDelay(micData, systemData);
for (let i = 0; i < micData.length; i++) {
let echoEstimate = 0;
for (let d = -500; d <= 500; d += 100) {
const delayIndex = i - optimalDelay - d;
if (delayIndex >= 0 && delayIndex < systemData.length) {
const weight = Math.exp(-Math.abs(d) / 1000);
echoEstimate += systemData[delayIndex] * scale * this.echoGain * weight;
}
}
output[i] = micData[i] - echoEstimate * 0.9;
if (Math.abs(output[i]) < this.noiseFloor) {
output[i] *= 0.5;
}
if (this.isSimilarToSystem(output[i], systemData, i, optimalDelay)) {
output[i] *= 0.25;
}
output[i] = Math.max(-1, Math.min(1, output[i]));
}
let errSum = 0;
for (let i = 0; i < output.length; i++) errSum += output[i] * output[i];
const errRms = Math.sqrt(errSum / output.length);
const err = errRms - this.targetErr;
this.echoGain += this.adaptRate * err; // 비례 제어
this.echoGain = Math.max(0, Math.min(1, this.echoGain));
return output;
}
findOptimalDelay(micData, systemData) {
let maxCorr = 0;
let optimalDelay = this.delaySamples;
for (let delay = 0; delay < 5000 && delay < systemData.length; delay += 200) {
let corr = 0;
let count = 0;
for (let i = 0; i < Math.min(500, micData.length); i++) {
if (i + delay < systemData.length) {
corr += micData[i] * systemData[i + delay];
count++;
}
}
if (count > 0) {
corr = Math.abs(corr / count);
if (corr > maxCorr) {
maxCorr = corr;
optimalDelay = delay;
}
}
}
return optimalDelay;
}
isSimilarToSystem(sample, systemData, index, delay) {
const windowSize = 50;
let similarity = 0;
for (let i = -windowSize; i <= windowSize; i++) {
const sysIndex = index - delay + i;
if (sysIndex >= 0 && sysIndex < systemData.length) {
similarity += Math.abs(sample - systemData[sysIndex]);
}
}
return similarity / (2 * windowSize + 1) < 0.15;
}
} }
let aecProcessor = new SimpleAEC();
// System audio data handler // System audio data handler
ipcRenderer.on('system-audio-data', (event, { data }) => { ipcRenderer.on('system-audio-data', (event, { data }) => {
@ -214,8 +160,6 @@ ipcRenderer.on('system-audio-data', (event, { data }) => {
if (systemAudioBuffer.length > MAX_SYSTEM_BUFFER_SIZE) { if (systemAudioBuffer.length > MAX_SYSTEM_BUFFER_SIZE) {
systemAudioBuffer = systemAudioBuffer.slice(-MAX_SYSTEM_BUFFER_SIZE); systemAudioBuffer = systemAudioBuffer.slice(-MAX_SYSTEM_BUFFER_SIZE);
} }
console.log('📥 Received system audio for AEC reference');
}); });
// --------------------------- // ---------------------------
@ -305,39 +249,47 @@ setInterval(() => {
// --------------------------- // ---------------------------
// Audio processing functions (exact from renderer.js) // Audio processing functions (exact from renderer.js)
// --------------------------- // ---------------------------
function setupMicProcessing(micStream) { async function setupMicProcessing(micStream) {
/* ── WASM 먼저 로드 ───────────────────────── */
const mod = await getAec();
if (!aecPtr) aecPtr = mod.newPtr(160, 1600, 24000, 1);
const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE }); const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
await micAudioContext.resume();
const micSource = micAudioContext.createMediaStreamSource(micStream); const micSource = micAudioContext.createMediaStreamSource(micStream);
const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1); const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
let audioBuffer = []; let audioBuffer = [];
const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION; const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
micProcessor.onaudioprocess = async e => { micProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0); const inputData = e.inputBuffer.getChannelData(0);
audioBuffer.push(...inputData); audioBuffer.push(...inputData);
console.log('🎤 micProcessor.onaudioprocess');
// samplesPerChunk(=2400) 만큼 모이면 전송
while (audioBuffer.length >= samplesPerChunk) { while (audioBuffer.length >= samplesPerChunk) {
let chunk = audioBuffer.splice(0, samplesPerChunk); let chunk = audioBuffer.splice(0, samplesPerChunk);
let processedChunk = new Float32Array(chunk); let processedChunk = new Float32Array(chunk); // 기본값
// Check for system audio and apply AEC only if voice is active // ───────────────── WASM AEC ─────────────────
if (aecProcessor && systemAudioBuffer.length > 0) { if (systemAudioBuffer.length > 0) {
const latestSystemAudio = systemAudioBuffer[systemAudioBuffer.length - 1]; const latest = systemAudioBuffer[systemAudioBuffer.length - 1];
const systemFloat32 = base64ToFloat32Array(latestSystemAudio.data); const sysF32 = base64ToFloat32Array(latest.data);
// Apply AEC only when system audio has active speech // **음성 구간일 때만 런**
if (isVoiceActive(systemFloat32)) { processedChunk = runAecSync(new Float32Array(chunk), sysF32);
processedChunk = aecProcessor.process(new Float32Array(chunk), systemFloat32); console.log('🔊 Applied WASM-AEC (speex)');
console.log('🔊 Applied AEC because system audio is active'); } else {
} console.log('🔊 No system audio for AEC reference');
} }
const pcmData16 = convertFloat32ToInt16(processedChunk); const pcm16 = convertFloat32ToInt16(processedChunk);
const base64Data = arrayBufferToBase64(pcmData16.buffer); const b64 = arrayBufferToBase64(pcm16.buffer);
await ipcRenderer.invoke('send-audio-content', { ipcRenderer.invoke('send-audio-content', {
data: base64Data, data: b64,
mimeType: 'audio/pcm;rate=24000', mimeType: 'audio/pcm;rate=24000',
}); });
} }
@ -520,7 +472,19 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
// Start macOS audio capture // Start macOS audio capture
const audioResult = await ipcRenderer.invoke('start-macos-audio'); const audioResult = await ipcRenderer.invoke('start-macos-audio');
if (!audioResult.success) { if (!audioResult.success) {
throw new Error('Failed to start macOS audio capture: ' + audioResult.error); console.warn('[listenCapture] macOS audio start failed:', audioResult.error);
// 이미 실행 중 → stop 후 재시도
if (audioResult.error === 'already_running') {
await ipcRenderer.invoke('stop-macos-audio');
await new Promise(r => setTimeout(r, 500));
const retry = await ipcRenderer.invoke('start-macos-audio');
if (!retry.success) {
throw new Error('Retry failed: ' + retry.error);
}
} else {
throw new Error('Failed to start macOS audio capture: ' + audioResult.error);
}
} }
// Initialize screen capture in main process // Initialize screen capture in main process
@ -543,7 +507,7 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
}); });
console.log('macOS microphone capture started'); console.log('macOS microphone capture started');
const { context, processor } = setupMicProcessing(micMediaStream); const { context, processor } = await setupMicProcessing(micMediaStream);
audioContext = context; audioContext = context;
audioProcessor = processor; audioProcessor = processor;
} catch (micErr) { } catch (micErr) {
@ -616,7 +580,7 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
video: false, video: false,
}); });
console.log('Windows microphone capture started'); console.log('Windows microphone capture started');
const { context, processor } = setupMicProcessing(micMediaStream); const { context, processor } = await setupMicProcessing(micMediaStream);
audioContext = context; audioContext = context;
audioProcessor = processor; audioProcessor = processor;
} catch (micErr) { } catch (micErr) {
@ -719,6 +683,9 @@ function stopCapture() {
// Exports & global registration // Exports & global registration
// --------------------------- // ---------------------------
module.exports = { module.exports = {
getAec, // 새로 만든 초기화 함수
runAecSync, // sync 버전
disposeAec, // 필요시 Rust 객체 파괴
startCapture, startCapture,
stopCapture, stopCapture,
captureManualScreenshot, captureManualScreenshot,

View File

@ -74,7 +74,7 @@ class SttService {
} }
flushMyCompletion() { flushMyCompletion() {
if (!this.myCompletionBuffer.trim()) return; if (!this.modelInfo || !this.myCompletionBuffer.trim()) return;
const finalText = this.myCompletionBuffer.trim(); const finalText = this.myCompletionBuffer.trim();
@ -102,7 +102,7 @@ class SttService {
} }
flushTheirCompletion() { flushTheirCompletion() {
if (!this.theirCompletionBuffer.trim()) return; if (!this.modelInfo || !this.theirCompletionBuffer.trim()) return;
const finalText = this.theirCompletionBuffer.trim(); const finalText = this.theirCompletionBuffer.trim();
@ -176,6 +176,11 @@ class SttService {
// console.log(`[SttService] Initializing STT for provider: ${modelInfo.provider}`); // console.log(`[SttService] Initializing STT for provider: ${modelInfo.provider}`);
const handleMyMessage = message => { const handleMyMessage = message => {
if (!this.modelInfo) {
console.log('[SttService] Ignoring message - session already closed');
return;
}
if (this.modelInfo.provider === 'gemini') { if (this.modelInfo.provider === 'gemini') {
const text = message.serverContent?.inputTranscription?.text || ''; const text = message.serverContent?.inputTranscription?.text || '';
if (text && text.trim()) { if (text && text.trim()) {
@ -217,6 +222,13 @@ class SttService {
}; };
const handleTheirMessage = message => { const handleTheirMessage = message => {
if (!message || typeof message !== 'object') return;
if (!this.modelInfo) {
console.log('[SttService] Ignoring message - session already closed');
return;
}
if (this.modelInfo.provider === 'gemini') { if (this.modelInfo.provider === 'gemini') {
const text = message.serverContent?.inputTranscription?.text || ''; const text = message.serverContent?.inputTranscription?.text || '';
if (text && text.trim()) { if (text && text.trim()) {
@ -320,14 +332,20 @@ class SttService {
} }
async sendSystemAudioContent(data, mimeType) { async sendSystemAudioContent(data, mimeType) {
const provider = await this.getAiProvider();
const isGemini = provider === 'gemini';
if (!this.theirSttSession) { if (!this.theirSttSession) {
throw new Error('Their STT session not active'); throw new Error('Their STT session not active');
} }
const payload = isGemini let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
const payload = modelInfo.provider === 'gemini'
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
: data; : data;

View File

@ -115,7 +115,7 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId); await sessionRepository.touch(this.currentSessionId);
} }
const modelInfo = await getCurrentModelInfo('llm'); const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }

View File

@ -190,9 +190,11 @@ app.whenReady().then(async () => {
sessionRepository.endAllActiveSessions(); sessionRepository.endAllActiveSessions();
authService.initialize(); authService.initialize();
//////// after_modelStateService //////// //////// after_modelStateService ////////
modelStateService.initialize(); modelStateService.initialize();
//////// after_modelStateService //////// //////// after_modelStateService ////////
listenService.setupIpcHandlers(); listenService.setupIpcHandlers();
askService.initialize(); askService.initialize();
settingsService.initialize(); settingsService.initialize();