centralize mainheader listen session logic

This commit is contained in:
sanio 2025-07-11 05:46:11 +09:00
parent e86c2db464
commit bcefa75154
4 changed files with 155 additions and 67 deletions

View File

@ -2,7 +2,9 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class MainHeader extends LitElement { export class MainHeader extends LitElement {
static properties = { static properties = {
isSessionActive: { type: Boolean, state: true }, // isSessionActive: { type: Boolean, state: true },
isTogglingSession: { type: Boolean, state: true },
actionText: { type: String, state: true },
shortcuts: { type: Object, state: true }, shortcuts: { type: Object, state: true },
}; };
@ -95,6 +97,11 @@ export class MainHeader extends LitElement {
position: relative; position: relative;
} }
.listen-button:disabled {
cursor: default;
opacity: 0.8;
}
.listen-button.active::before { .listen-button.active::before {
background: rgba(215, 0, 0, 0.5); background: rgba(215, 0, 0, 0.5);
} }
@ -103,6 +110,24 @@ export class MainHeader extends LitElement {
background: rgba(255, 20, 20, 0.6); background: rgba(255, 20, 20, 0.6);
} }
.listen-button.done {
background-color: rgba(255, 255, 255, 0.6);
transition: background-color 0.15s ease;
}
.listen-button.done .action-text-content {
color: black;
}
.listen-button.done .listen-icon svg rect,
.listen-button.done .listen-icon svg path {
fill: black;
}
.listen-button.done:hover {
background-color: #f0f0f0;
}
.listen-button:hover::before { .listen-button:hover::before {
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.18);
} }
@ -132,6 +157,38 @@ export class MainHeader extends LitElement {
pointer-events: none; pointer-events: none;
} }
.listen-button.done::after {
display: none;
}
.loading-dots {
display: flex;
align-items: center;
gap: 5px;
}
.loading-dots span {
width: 6px;
height: 6px;
background-color: white;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out both;
}
.loading-dots span:nth-of-type(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-of-type(2) {
animation-delay: -0.16s;
}
@keyframes pulse {
0%, 80%, 100% {
opacity: 0.2;
}
40% {
opacity: 1.0;
}
}
.header-actions { .header-actions {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
height: 26px; height: 26px;
@ -242,7 +299,9 @@ export class MainHeader extends LitElement {
this.isAnimating = false; this.isAnimating = false;
this.hasSlidIn = false; this.hasSlidIn = false;
this.settingsHideTimer = null; this.settingsHideTimer = null;
this.isSessionActive = false; // this.isSessionActive = false;
this.isTogglingSession = false;
this.actionText = 'Listen';
this.animationEndTimer = null; this.animationEndTimer = null;
this.handleAnimationEnd = this.handleAnimationEnd.bind(this); this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
} }
@ -305,10 +364,19 @@ export class MainHeader extends LitElement {
if (window.require) { if (window.require) {
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
this._sessionStateListener = (event, { isActive }) => {
this.isSessionActive = isActive; this._sessionStateTextListener = (event, text) => {
this.actionText = text;
this.isTogglingSession = false;
}; };
ipcRenderer.on('session-state-changed', this._sessionStateListener); ipcRenderer.on('session-state-text', this._sessionStateTextListener);
// this._sessionStateListener = (event, { isActive }) => {
// this.isSessionActive = isActive;
// this.isTogglingSession = false;
// };
// ipcRenderer.on('session-state-changed', this._sessionStateListener);
this._shortcutListener = (event, keybinds) => { this._shortcutListener = (event, keybinds) => {
console.log('[MainHeader] Received updated shortcuts:', keybinds); console.log('[MainHeader] Received updated shortcuts:', keybinds);
this.shortcuts = keybinds; this.shortcuts = keybinds;
@ -328,9 +396,12 @@ export class MainHeader extends LitElement {
if (window.require) { if (window.require) {
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
if (this._sessionStateListener) { if (this._sessionStateTextListener) {
ipcRenderer.removeListener('session-state-changed', this._sessionStateListener); ipcRenderer.removeListener('session-state-text', this._sessionStateTextListener);
} }
// if (this._sessionStateListener) {
// ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
// }
if (this._shortcutListener) { if (this._shortcutListener) {
ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener); ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener);
} }
@ -341,6 +412,7 @@ export class MainHeader extends LitElement {
if (window.require) { if (window.require) {
window.require('electron').ipcRenderer.invoke(channel, ...args); window.require('electron').ipcRenderer.invoke(channel, ...args);
} }
// return Promise.resolve();
} }
showSettingsWindow(element) { showSettingsWindow(element) {
@ -369,6 +441,23 @@ export class MainHeader extends LitElement {
} }
} }
async _handleListenClick() {
if (this.isTogglingSession) {
return;
}
this.isTogglingSession = true;
try {
const channel = 'toggle-feature';
const args = ['listen'];
await this.invoke(channel, ...args);
} catch (error) {
console.error('IPC invoke for session toggle failed:', error);
this.isTogglingSession = false;
}
}
renderShortcut(accelerator) { renderShortcut(accelerator) {
if (!accelerator) return html``; if (!accelerator) return html``;
@ -394,22 +483,35 @@ export class MainHeader extends LitElement {
} }
render() { render() {
const buttonClasses = {
active: this.actionText === 'Stop',
done: this.actionText === 'Done',
};
const showStopIcon = this.actionText === 'Stop' || this.actionText === 'Done';
return html` return html`
<div class="header"> <div class="header">
<button <button
class="listen-button ${this.isSessionActive ? 'active' : ''}" class="listen-button ${Object.keys(buttonClasses).filter(k => buttonClasses[k]).join(' ')}"
@click=${() => this.invoke(this.isSessionActive ? 'close-session' : 'toggle-feature', 'listen')} @click=${this._handleListenClick}
?disabled=${this.isTogglingSession}
> >
${this.isTogglingSession
? html`
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
`
: html`
<div class="action-text"> <div class="action-text">
<div class="action-text-content">${this.isSessionActive ? 'Stop' : 'Listen'}</div> <div class="action-text-content">${this.actionText}</div>
</div> </div>
<div class="listen-icon"> <div class="listen-icon">
${this.isSessionActive ${showStopIcon
? html` ? html`
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="9" height="9" rx="1" fill="white"/> <rect width="9" height="9" rx="1" fill="white"/>
</svg> </svg>
` `
: html` : html`
<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -419,6 +521,7 @@ export class MainHeader extends LitElement {
</svg> </svg>
`} `}
</div> </div>
`}
</button> </button>
<div class="header-actions ask-action" @click=${() => this.invoke('toggle-feature', 'ask')}> <div class="header-actions ask-action" @click=${() => this.invoke('toggle-feature', 'ask')}>

View File

@ -472,7 +472,7 @@ function createWindows() {
createFeatureWindows(windowPool.get('header')); createFeatureWindows(windowPool.get('header'));
} }
const header = windowPool.get('header');
if (featureName === 'listen') { if (featureName === 'listen') {
console.log(`[WindowManager] Toggling feature: ${featureName}`); console.log(`[WindowManager] Toggling feature: ${featureName}`);
const listenWindow = windowPool.get(featureName); const listenWindow = windowPool.get(featureName);
@ -480,18 +480,22 @@ function createWindows() {
if (listenService && listenService.isSessionActive()) { if (listenService && listenService.isSessionActive()) {
console.log('[WindowManager] Listen session is active, closing it via toggle.'); console.log('[WindowManager] Listen session is active, closing it via toggle.');
await listenService.closeSession(); await listenService.closeSession();
return; listenWindow.webContents.send('session-state-changed', { isActive: false });
} header.webContents.send('session-state-text', 'Done');
// return;
} else {
if (listenWindow.isVisible()) { if (listenWindow.isVisible()) {
listenWindow.webContents.send('window-hide-animation'); listenWindow.webContents.send('window-hide-animation');
listenWindow.webContents.send('session-state-changed', { isActive: false });
header.webContents.send('session-state-text', 'Listen');
} else { } else {
listenWindow.show(); listenWindow.show();
updateLayout(); updateLayout();
// listenWindow.webContents.send('start-listening-session');
listenWindow.webContents.send('window-show-animation'); listenWindow.webContents.send('window-show-animation');
await listenService.initializeSession(); await listenService.initializeSession();
// listenWindow.webContents.send('start-listening-session'); listenWindow.webContents.send('session-state-changed', { isActive: true });
header.webContents.send('session-state-text', 'Stop');
}
} }
} }

View File

@ -144,9 +144,7 @@ class ListenService {
console.log('✅ Listen service initialized successfully.'); console.log('✅ Listen service initialized successfully.');
this.sendToRenderer('session-state-changed', { isActive: true });
this.sendToRenderer('update-status', 'Connected. Ready to listen.'); this.sendToRenderer('update-status', 'Connected. Ready to listen.');
// this.sendToRenderer('change-listen-capture-state', { status: "start" });
return true; return true;
} catch (error) { } catch (error) {
@ -181,6 +179,7 @@ class ListenService {
async closeSession() { async closeSession() {
try { try {
this.sendToRenderer('change-listen-capture-state', { status: "stop" });
// Close STT sessions // Close STT sessions
await this.sttService.closeSessions(); await this.sttService.closeSessions();
@ -194,9 +193,7 @@ class ListenService {
this.currentSessionId = null; this.currentSessionId = null;
this.summaryService.resetConversationHistory(); this.summaryService.resetConversationHistory();
this.sendToRenderer('session-state-changed', { isActive: false });
this.sendToRenderer('session-did-close'); this.sendToRenderer('session-did-close');
this.sendToRenderer('change-listen-capture-state', { status: "stop" });
console.log('Listen service session closed.'); console.log('Listen service session closed.');
return { success: true }; return { success: true };
@ -285,9 +282,9 @@ class ListenService {
} }
}); });
ipcMain.handle('close-session', async () => { // ipcMain.handle('close-session', async () => {
return await this.closeSession(); // return await this.closeSession();
}); // });
ipcMain.handle('update-google-search-setting', async (event, enabled) => { ipcMain.handle('update-google-search-setting', async (event, enabled) => {
try { try {

View File

@ -7,15 +7,9 @@ let aecPtr = 0; // Rust Aec* 1개만 재사용
/** WASM 모듈 가져오고 1회 초기화 */ /** WASM 모듈 가져오고 1회 초기화 */
async function getAec () { async function getAec () {
if (aecModPromise) { if (aecModPromise) return aecModPromise; // 캐시
console.log('[AEC] getAec: 캐시=있음(재사용)');
return aecModPromise; // 캐시
}
console.log('[AEC] getAec: 캐시=없음 → 모듈 로드 시작');
aecModPromise = createAecModule().then((M) => { aecModPromise = createAecModule().then((M) => {
console.log('[AEC] WASM 모듈 로드 완료');
aecMod = M; aecMod = M;
// C 심볼 → JS 래퍼 바인딩 (딱 1번) // C 심볼 → JS 래퍼 바인딩 (딱 1번)
M.newPtr = M.cwrap('AecNew', 'number', M.newPtr = M.cwrap('AecNew', 'number',
@ -24,13 +18,8 @@ async function getAec () {
['number','number','number','number','number']); ['number','number','number','number','number']);
M.destroy = M.cwrap('AecDestroy', null, ['number']); M.destroy = M.cwrap('AecDestroy', null, ['number']);
return M; return M;
})
.catch(err => {
console.error('[AEC] WASM 모듈 로드 실패:', err);
throw err; // 상위에서도 잡을 수 있게
}); });
return aecModPromise; return aecModPromise;
} }
@ -143,10 +132,6 @@ function disposeAec () {
} }
function runAecSync (micF32, sysF32) { function runAecSync (micF32, sysF32) {
const modStat = aecMod?.HEAPU8 ? '있음' : '없음'; // aecMod가 초기화되었고 HEAP 접근 가능?
const ptrStat = aecPtr ? '있음' : '없음'; // newPtr 호출 여부
const heapStat = aecMod?.HEAPU8 ? '있음' : '없음'; // HEAPU8 생성 여부
console.log(`[AEC] mod:${modStat} ptr:${ptrStat} heap:${heapStat}`);
if (!aecMod || !aecPtr || !aecMod.HEAPU8) return micF32; // 아직 모듈 안 뜸 → 패스 if (!aecMod || !aecPtr || !aecMod.HEAPU8) return micF32; // 아직 모듈 안 뜸 → 패스
const len = micF32.length; const len = micF32.length;
@ -160,7 +145,6 @@ function runAecSync (micF32, sysF32) {
const outF32 = float32FromInt16View(new Int16Array(heapBuf, out, len)); const outF32 = float32FromInt16View(new Int16Array(heapBuf, out, len));
aecMod._free(mic.ptr); aecMod._free(echo.ptr); aecMod._free(out); aecMod._free(mic.ptr); aecMod._free(echo.ptr); aecMod._free(out);
console.log(`[AEC] 적용 완료`);
return outF32; return outF32;
} }
@ -282,7 +266,7 @@ async function setupMicProcessing(micStream) {
micProcessor.onaudioprocess = (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'); console.log('🎤 micProcessor.onaudioprocess');
// samplesPerChunk(=2400) 만큼 모이면 전송 // samplesPerChunk(=2400) 만큼 모이면 전송
while (audioBuffer.length >= samplesPerChunk) { while (audioBuffer.length >= samplesPerChunk) {
@ -296,7 +280,7 @@ async function setupMicProcessing(micStream) {
// **음성 구간일 때만 런** // **음성 구간일 때만 런**
processedChunk = runAecSync(new Float32Array(chunk), sysF32); processedChunk = runAecSync(new Float32Array(chunk), sysF32);
// console.log('🔊 Applied WASM-AEC (speex)'); console.log('🔊 Applied WASM-AEC (speex)');
} else { } else {
console.log('🔊 No system audio for AEC reference'); console.log('🔊 No system audio for AEC reference');
} }