Phase 9: About 탭 + 녹음 오디오 WAV 저장
- Settings: 정보(About) 탭 추가 (버전, 기술 스택, 음성 엔진 정보)
- VoiceModeService: 전사 완료 시 PCM→WAV 파일 저장 ({userData}/recordings/)
- WAV 헤더 생성 (16kHz, 16bit, mono)
- HistoryService와 연동 가능 (audioLocalPath)
- docs/phases/phase-9.md 설계 문서 작성
This commit is contained in:
parent
67a6ee242a
commit
f281809d05
3 changed files with 118 additions and 0 deletions
33
docs/phases/phase-9.md
Normal file
33
docs/phases/phase-9.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Phase 9: 품질 보강 + 사용자 경험 개선
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
Phase 8에서 핵심 파이프라인(핫키→녹음→전사→삽입)이 동작하게 되었으므로,
|
||||||
|
사용자 경험을 개선하는 보조 기능들을 추가한다.
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### 9.1 Settings About 탭
|
||||||
|
- 앱 버전, Electron 버전 표시
|
||||||
|
- 기술 스택 정보 (Whisper, Ollama, SoX)
|
||||||
|
- 프로젝트 링크
|
||||||
|
|
||||||
|
### 9.2 마이크 테스트 UI
|
||||||
|
- Settings Audio 탭에 "테스트" 버튼 추가
|
||||||
|
- 버튼 누르면 3초간 마이크 캡처 → 레벨 바 표시
|
||||||
|
- AudioCaptureService의 audio-level 이벤트 활용
|
||||||
|
|
||||||
|
### 9.3 녹음 오디오 파일 저장
|
||||||
|
- VoiceModeService에서 세션 완료 시 오디오 버퍼를 WAV 파일로 저장
|
||||||
|
- 저장 경로: `{userData}/recordings/{sessionId}.wav`
|
||||||
|
- HistoryService create 시 audioLocalPath 기록
|
||||||
|
- History UI에서 재생 버튼 (향후)
|
||||||
|
|
||||||
|
### 9.4 RecordingTip thinking 상태 개선
|
||||||
|
- 전사 중 "처리 중..." 텍스트 표시
|
||||||
|
- 프로그레스 바 점근 수렴 애니메이션
|
||||||
|
|
||||||
|
## 완료 조건
|
||||||
|
- Settings About 탭에 버전 표시
|
||||||
|
- 마이크 테스트 버튼 동작
|
||||||
|
- 전사 완료 시 WAV 파일 저장
|
||||||
|
- typecheck + test 통과
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { app } from 'electron'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getAudioCaptureService } from './AudioCaptureService'
|
import { getAudioCaptureService } from './AudioCaptureService'
|
||||||
import { getLocalSTTService } from './LocalSTTService'
|
import { getLocalSTTService } from './LocalSTTService'
|
||||||
|
|
@ -97,6 +100,9 @@ class VoiceModeService extends EventEmitter {
|
||||||
private _audioBuffer: Buffer[] = []
|
private _audioBuffer: Buffer[] = []
|
||||||
private _audioBufferBytes = 0
|
private _audioBufferBytes = 0
|
||||||
|
|
||||||
|
// 녹음 오디오 저장용
|
||||||
|
private _lastAudioBuffer: Buffer | null = null
|
||||||
|
|
||||||
// 에러 가드
|
// 에러 가드
|
||||||
private _errorEmitted = false
|
private _errorEmitted = false
|
||||||
|
|
||||||
|
|
@ -404,6 +410,7 @@ class VoiceModeService extends EventEmitter {
|
||||||
this._setRecognitionState(RecognitionState.RECOGNIZING)
|
this._setRecognitionState(RecognitionState.RECOGNIZING)
|
||||||
|
|
||||||
const merged = Buffer.concat(this._audioBuffer)
|
const merged = Buffer.concat(this._audioBuffer)
|
||||||
|
this._lastAudioBuffer = merged // WAV 저장용 복사본
|
||||||
this._audioBuffer = []
|
this._audioBuffer = []
|
||||||
this._audioBufferBytes = 0
|
this._audioBufferBytes = 0
|
||||||
|
|
||||||
|
|
@ -497,6 +504,48 @@ class VoiceModeService extends EventEmitter {
|
||||||
showResultPopup(finalText, 10000)
|
showResultPopup(finalText, 10000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 녹음 오디오 WAV 파일 저장 (비동기, 실패해도 무시)
|
||||||
|
this._saveAudioFile(session.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PCM 버퍼를 WAV 파일로 저장 */
|
||||||
|
private async _saveAudioFile(sessionId: string): Promise<void> {
|
||||||
|
if (!this._lastAudioBuffer || this._lastAudioBuffer.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recordingsDir = join(app.getPath('userData'), 'recordings')
|
||||||
|
await mkdir(recordingsDir, { recursive: true })
|
||||||
|
|
||||||
|
const wavPath = join(recordingsDir, `${sessionId}.wav`)
|
||||||
|
const pcmData = this._lastAudioBuffer
|
||||||
|
|
||||||
|
// WAV 헤더 생성 (16kHz, 16bit, mono)
|
||||||
|
const header = Buffer.alloc(44)
|
||||||
|
const dataSize = pcmData.length
|
||||||
|
const fileSize = dataSize + 36
|
||||||
|
|
||||||
|
header.write('RIFF', 0)
|
||||||
|
header.writeUInt32LE(fileSize, 4)
|
||||||
|
header.write('WAVE', 8)
|
||||||
|
header.write('fmt ', 12)
|
||||||
|
header.writeUInt32LE(16, 16) // fmt chunk size
|
||||||
|
header.writeUInt16LE(1, 20) // PCM format
|
||||||
|
header.writeUInt16LE(1, 22) // mono
|
||||||
|
header.writeUInt32LE(16000, 24) // sample rate
|
||||||
|
header.writeUInt32LE(32000, 28) // byte rate (16000 * 2)
|
||||||
|
header.writeUInt16LE(2, 32) // block align
|
||||||
|
header.writeUInt16LE(16, 34) // bits per sample
|
||||||
|
header.write('data', 36)
|
||||||
|
header.writeUInt32LE(dataSize, 40)
|
||||||
|
|
||||||
|
await writeFile(wavPath, Buffer.concat([header, pcmData]))
|
||||||
|
logger.info(`Audio saved: ${wavPath} (${Math.round(dataSize / 1024)}KB)`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Audio save failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
} finally {
|
||||||
|
this._lastAudioBuffer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void {
|
private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void {
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
<Tab label="오디오" />
|
<Tab label="오디오" />
|
||||||
<Tab label="STT" />
|
<Tab label="STT" />
|
||||||
<Tab label="LLM" />
|
<Tab label="LLM" />
|
||||||
|
<Tab label="정보" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* ── 일반 탭 ─────────────────────────────── */}
|
{/* ── 일반 탭 ─────────────────────────────── */}
|
||||||
|
|
@ -518,6 +519,41 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* ── 정보 탭 ──────────────────────────────── */}
|
||||||
|
<TabPanel value={activeTab} index={4}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||||
|
D3RO-VOICE
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>버전</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">v1.0.0</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>기술 스택</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Electron + React 19 + MUI 7 + TypeScript
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>음성 엔진</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
STT: faster-whisper (로컬) / LLM: Ollama (로컬)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '11px' }}>
|
||||||
|
Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트.
|
||||||
|
클라우드 의존성 없이 완전 로컬로 동작합니다.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue