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:
Yun Chan 2026-04-05 11:20:10 +09:00
parent 67a6ee242a
commit f281809d05
3 changed files with 118 additions and 0 deletions

View file

@ -5,6 +5,9 @@
import { EventEmitter } from 'events'
import { randomUUID } from 'crypto'
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { getAudioCaptureService } from './AudioCaptureService'
import { getLocalSTTService } from './LocalSTTService'
@ -97,6 +100,9 @@ class VoiceModeService extends EventEmitter {
private _audioBuffer: Buffer[] = []
private _audioBufferBytes = 0
// 녹음 오디오 저장용
private _lastAudioBuffer: Buffer | null = null
// 에러 가드
private _errorEmitted = false
@ -404,6 +410,7 @@ class VoiceModeService extends EventEmitter {
this._setRecognitionState(RecognitionState.RECOGNIZING)
const merged = Buffer.concat(this._audioBuffer)
this._lastAudioBuffer = merged // WAV 저장용 복사본
this._audioBuffer = []
this._audioBufferBytes = 0
@ -497,6 +504,48 @@ class VoiceModeService extends EventEmitter {
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 {

View file

@ -283,6 +283,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
<Tab label="오디오" />
<Tab label="STT" />
<Tab label="LLM" />
<Tab label="정보" />
</Tabs>
{/* ── 일반 탭 ─────────────────────────────── */}
@ -518,6 +519,41 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
</Typography>
</Box>
</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>
</Dialog>