feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
|
|
@ -1,120 +0,0 @@
|
|||
"""
|
||||
scripts/build-sidecar.py
|
||||
faster-whisper sidecar를 PyInstaller로 빌드한다.
|
||||
|
||||
사용법:
|
||||
pip install pyinstaller
|
||||
python scripts/build-sidecar.py
|
||||
|
||||
출력:
|
||||
sidecar-dist/sidecar.exe (단일 디렉토리 모드)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
SIDECAR_DIR = PROJECT_ROOT / "sidecar"
|
||||
MAIN_PY = SIDECAR_DIR / "main.py"
|
||||
OUTPUT_DIR = PROJECT_ROOT / "sidecar-dist"
|
||||
|
||||
|
||||
def check_prerequisites():
|
||||
"""필수 도구가 설치되어 있는지 확인한다."""
|
||||
# PyInstaller
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
print("PyInstaller가 설치되어 있지 않습니다.")
|
||||
print("실행: pip install pyinstaller")
|
||||
sys.exit(1)
|
||||
|
||||
# faster-whisper
|
||||
try:
|
||||
import faster_whisper # noqa: F401
|
||||
except ImportError:
|
||||
print("faster-whisper가 설치되어 있지 않습니다.")
|
||||
print("실행: pip install -r sidecar/requirements.txt")
|
||||
sys.exit(1)
|
||||
|
||||
if not MAIN_PY.exists():
|
||||
print(f"sidecar 소스를 찾을 수 없습니다: {MAIN_PY}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def build():
|
||||
"""PyInstaller로 sidecar를 빌드한다."""
|
||||
print("=" * 60)
|
||||
print("D3RO-VOICE Sidecar 빌드 시작")
|
||||
print("=" * 60)
|
||||
|
||||
# 기존 빌드 정리
|
||||
if OUTPUT_DIR.exists():
|
||||
shutil.rmtree(OUTPUT_DIR)
|
||||
|
||||
# PyInstaller 실행 — onedir 모드 (onefile보다 시작이 빠름)
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "sidecar",
|
||||
"--distpath", str(OUTPUT_DIR),
|
||||
"--workpath", str(PROJECT_ROOT / "build" / "sidecar-build"),
|
||||
"--specpath", str(PROJECT_ROOT / "build"),
|
||||
# onedir 모드 (단일 디렉토리)
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
# hidden imports (PyInstaller가 자동 감지 못하는 것)
|
||||
"--hidden-import", "faster_whisper",
|
||||
"--hidden-import", "ctranslate2",
|
||||
"--hidden-import", "huggingface_hub",
|
||||
"--hidden-import", "tokenizers",
|
||||
"--hidden-import", "uvicorn.logging",
|
||||
"--hidden-import", "uvicorn.protocols.http",
|
||||
"--hidden-import", "uvicorn.protocols.http.auto",
|
||||
"--hidden-import", "uvicorn.protocols.http.h11_impl",
|
||||
"--hidden-import", "uvicorn.protocols.websockets",
|
||||
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
||||
"--hidden-import", "uvicorn.lifespan",
|
||||
"--hidden-import", "uvicorn.lifespan.on",
|
||||
"--hidden-import", "uvicorn.lifespan.off",
|
||||
# 콘솔 없음 (Windows)
|
||||
"--noconsole",
|
||||
str(MAIN_PY),
|
||||
]
|
||||
|
||||
print(f"\n실행 명령:\n {' '.join(cmd)}\n")
|
||||
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT))
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"\nPyInstaller 빌드 실패 (exit code: {result.returncode})")
|
||||
sys.exit(1)
|
||||
|
||||
# 빌드 결과 확인
|
||||
exe_path = OUTPUT_DIR / "sidecar" / "sidecar.exe"
|
||||
if exe_path.exists():
|
||||
size_mb = exe_path.stat().st_size / (1024 * 1024)
|
||||
print(f"\n빌드 성공!")
|
||||
print(f" 경로: {exe_path}")
|
||||
print(f" 크기: {size_mb:.1f} MB")
|
||||
|
||||
# 전체 디렉토리 크기
|
||||
total_size = sum(f.stat().st_size for f in (OUTPUT_DIR / "sidecar").rglob("*") if f.is_file())
|
||||
total_mb = total_size / (1024 * 1024)
|
||||
print(f" 전체 디렉토리: {total_mb:.1f} MB")
|
||||
else:
|
||||
print(f"\n빌드 출력을 찾을 수 없습니다: {exe_path}")
|
||||
# onedir 모드에서는 디렉토리 내부에 exe가 있음
|
||||
for exe in OUTPUT_DIR.rglob("*.exe"):
|
||||
print(f" 발견: {exe}")
|
||||
|
||||
print("\n빌드 완료!")
|
||||
print(f"electron-builder에서 이 경로를 extraResources로 지정하세요:")
|
||||
print(f" from: sidecar-dist/sidecar/")
|
||||
print(f" to: sidecar/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_prerequisites()
|
||||
build()
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
# scripts/download-sox.ps1
|
||||
# SoX Windows 바이너리를 resources/sox/에 다운로드한다.
|
||||
# 실행: powershell -ExecutionPolicy Bypass -File scripts/download-sox.ps1
|
||||
|
||||
$SOX_VERSION = "14.4.1"
|
||||
# SourceForge 직접 다운로드 URL (리다이렉트 따라감)
|
||||
$SOX_URL = "https://downloads.sourceforge.net/project/sox/sox/$SOX_VERSION/sox-${SOX_VERSION}-win32.zip"
|
||||
$DEST_DIR = Join-Path $PSScriptRoot "..\resources\sox"
|
||||
$TEMP_ZIP = Join-Path $env:TEMP "sox-${SOX_VERSION}-win32.zip"
|
||||
$TEMP_DIR = Join-Path $env:TEMP "sox-extract"
|
||||
|
||||
Write-Host "=== SoX $SOX_VERSION Windows 바이너리 다운로드 ===" -ForegroundColor Cyan
|
||||
|
||||
# 이미 존재하면 스킵
|
||||
if (Test-Path (Join-Path $DEST_DIR "sox.exe")) {
|
||||
Write-Host "SoX가 이미 존재합니다: $DEST_DIR\sox.exe" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 기존 임시 파일 정리
|
||||
if (Test-Path $TEMP_ZIP) { Remove-Item -Force $TEMP_ZIP }
|
||||
|
||||
# 다운로드 (SourceForge 리다이렉트를 따라감)
|
||||
Write-Host "다운로드 중: $SOX_URL"
|
||||
Write-Host "(SourceForge 리다이렉트를 따라가므로 시간이 걸릴 수 있습니다)"
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
$webClient.DownloadFile($SOX_URL, $TEMP_ZIP)
|
||||
} catch {
|
||||
Write-Host "자동 다운로드 실패: $_" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow
|
||||
Write-Host "1. 브라우저에서 다운로드:" -ForegroundColor White
|
||||
Write-Host " https://sourceforge.net/projects/sox/files/sox/14.4.1/sox-14.4.1-win32.zip/download"
|
||||
Write-Host ""
|
||||
Write-Host "2. 다운로드한 zip에서 아래 파일들을 복사:" -ForegroundColor White
|
||||
Write-Host " sox.exe, rec.exe, libmad-0.dll, libmp3lame-0.dll, libsox-3.dll"
|
||||
Write-Host ""
|
||||
Write-Host "3. 복사 위치:" -ForegroundColor White
|
||||
Write-Host " $DEST_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# zip 파일 검증
|
||||
$fileSize = (Get-Item $TEMP_ZIP).Length
|
||||
if ($fileSize -lt 100000) {
|
||||
Write-Host "다운로드된 파일이 너무 작습니다 (${fileSize} bytes). HTML 페이지가 다운로드된 것 같습니다." -ForegroundColor Red
|
||||
Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue
|
||||
Write-Host ""
|
||||
Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow
|
||||
Write-Host "1. 브라우저에서 다운로드:" -ForegroundColor White
|
||||
Write-Host " https://sourceforge.net/projects/sox/files/sox/14.4.1/sox-14.4.1-win32.zip/download"
|
||||
Write-Host ""
|
||||
Write-Host "2. 다운로드한 zip 압축 해제 후 아래 파일들을 복사:" -ForegroundColor White
|
||||
Write-Host " sox.exe, rec.exe, libmad-0.dll, libmp3lame-0.dll, libsox-3.dll"
|
||||
Write-Host ""
|
||||
Write-Host "3. 복사 위치:" -ForegroundColor White
|
||||
Write-Host " $DEST_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "다운로드 완료 (${fileSize} bytes)"
|
||||
|
||||
# 압축 해제
|
||||
Write-Host "압축 해제 중..."
|
||||
if (Test-Path $TEMP_DIR) { Remove-Item -Recurse -Force $TEMP_DIR }
|
||||
|
||||
try {
|
||||
Expand-Archive -Path $TEMP_ZIP -DestinationPath $TEMP_DIR -Force
|
||||
} catch {
|
||||
Write-Host "압축 해제 실패: $_" -ForegroundColor Red
|
||||
Write-Host "수동으로 $TEMP_ZIP 을 압축 해제한 후 sox.exe 등을 $DEST_DIR 에 복사하세요." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 필요한 파일만 복사
|
||||
$SOX_EXTRACTED = Get-ChildItem $TEMP_DIR -Directory | Select-Object -First 1
|
||||
if (-not $SOX_EXTRACTED) {
|
||||
# 디렉토리 없이 바로 파일이 있는 경우
|
||||
$SOX_EXTRACTED = Get-Item $TEMP_DIR
|
||||
}
|
||||
|
||||
$filesToCopy = @("sox.exe", "rec.exe", "libmad-0.dll", "libmp3lame-0.dll", "libsox-3.dll")
|
||||
New-Item -ItemType Directory -Force -Path $DEST_DIR | Out-Null
|
||||
|
||||
$copied = 0
|
||||
foreach ($file in $filesToCopy) {
|
||||
$src = Get-ChildItem -Path $TEMP_DIR -Recurse -Filter $file | Select-Object -First 1
|
||||
if ($src) {
|
||||
Copy-Item $src.FullName -Destination $DEST_DIR
|
||||
Write-Host " 복사: $file" -ForegroundColor Green
|
||||
$copied++
|
||||
} else {
|
||||
Write-Host " 누락: $file (선택적)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# 정리
|
||||
Remove-Item -Recurse -Force $TEMP_DIR -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue
|
||||
|
||||
if ($copied -ge 1) {
|
||||
Write-Host ""
|
||||
Write-Host "SoX 설치 완료: $DEST_DIR ($copied 파일)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "파일 복사 실패. 수동으로 설치해주세요." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
/**
|
||||
* D3RO-VOICE 효과음 WAV 파일 생성 스크립트
|
||||
*
|
||||
* 생성 파일:
|
||||
* resources/sounds/recording-start.wav — 상승 톤 (440→880Hz, 150ms)
|
||||
* resources/sounds/recording-stop.wav — 하강 톤 (880→440Hz, 150ms)
|
||||
* resources/sounds/error.wav — 저음 비프 2회 (220Hz, 200ms×2)
|
||||
*
|
||||
* WAV 포맷: 16kHz, mono, 16bit PCM
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SAMPLE_RATE = 16000;
|
||||
const BIT_DEPTH = 16;
|
||||
const NUM_CHANNELS = 1;
|
||||
const BYTES_PER_SAMPLE = BIT_DEPTH / 8;
|
||||
|
||||
/**
|
||||
* 사인파 샘플 생성 (주파수 선형 스윕 지원)
|
||||
* @param {number} durationMs - 길이 (ms)
|
||||
* @param {number} freqStart - 시작 주파수 (Hz)
|
||||
* @param {number} freqEnd - 끝 주파수 (Hz)
|
||||
* @param {number} volume - 볼륨 (0.0~1.0)
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function generateTone(durationMs, freqStart, freqEnd, volume = 0.6) {
|
||||
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||
const samples = new Int16Array(numSamples);
|
||||
const maxVal = 32767 * volume;
|
||||
|
||||
// 페이드 인/아웃 길이 (클릭 방지)
|
||||
const fadeSamples = Math.min(Math.floor(numSamples * 0.05), 80);
|
||||
|
||||
let phase = 0;
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const t = i / numSamples;
|
||||
const freq = freqStart + (freqEnd - freqStart) * t;
|
||||
|
||||
// 페이드 인/아웃 엔벨로프
|
||||
let envelope = 1.0;
|
||||
if (i < fadeSamples) {
|
||||
envelope = i / fadeSamples;
|
||||
} else if (i > numSamples - fadeSamples) {
|
||||
envelope = (numSamples - i) / fadeSamples;
|
||||
}
|
||||
|
||||
samples[i] = Math.round(Math.sin(phase) * maxVal * envelope);
|
||||
phase += (2 * Math.PI * freq) / SAMPLE_RATE;
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무음 생성
|
||||
* @param {number} durationMs
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function generateSilence(durationMs) {
|
||||
const numSamples = Math.floor((SAMPLE_RATE * durationMs) / 1000);
|
||||
return new Int16Array(numSamples);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 샘플 배열을 이어붙임
|
||||
* @param {Int16Array[]} arrays
|
||||
* @returns {Int16Array}
|
||||
*/
|
||||
function concatenate(arrays) {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Int16Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* PCM 데이터를 WAV 파일 버퍼로 변환
|
||||
* @param {Int16Array} samples
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function createWavBuffer(samples) {
|
||||
const dataSize = samples.length * BYTES_PER_SAMPLE;
|
||||
const headerSize = 44;
|
||||
const buffer = Buffer.alloc(headerSize + dataSize);
|
||||
|
||||
// RIFF header
|
||||
buffer.write('RIFF', 0);
|
||||
buffer.writeUInt32LE(headerSize - 8 + dataSize, 4);
|
||||
buffer.write('WAVE', 8);
|
||||
|
||||
// fmt chunk
|
||||
buffer.write('fmt ', 12);
|
||||
buffer.writeUInt32LE(16, 16); // chunk size
|
||||
buffer.writeUInt16LE(1, 20); // PCM format
|
||||
buffer.writeUInt16LE(NUM_CHANNELS, 22);
|
||||
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
||||
buffer.writeUInt32LE(SAMPLE_RATE * NUM_CHANNELS * BYTES_PER_SAMPLE, 28); // byte rate
|
||||
buffer.writeUInt16LE(NUM_CHANNELS * BYTES_PER_SAMPLE, 32); // block align
|
||||
buffer.writeUInt16LE(BIT_DEPTH, 34);
|
||||
|
||||
// data chunk
|
||||
buffer.write('data', 36);
|
||||
buffer.writeUInt32LE(dataSize, 40);
|
||||
|
||||
// PCM data (Int16 little-endian)
|
||||
const pcmBuffer = Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength);
|
||||
pcmBuffer.copy(buffer, headerSize);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// --- 효과음 생성 ---
|
||||
|
||||
const outputDir = path.resolve(__dirname, '..', 'resources', 'sounds');
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// 1. recording-start.wav: 상승 톤 440→880Hz, 150ms
|
||||
const startSamples = generateTone(150, 440, 880, 0.5);
|
||||
const startWav = createWavBuffer(startSamples);
|
||||
const startPath = path.join(outputDir, 'recording-start.wav');
|
||||
fs.writeFileSync(startPath, startWav);
|
||||
console.log(`Created: ${startPath} (${startWav.length} bytes)`);
|
||||
|
||||
// 2. recording-stop.wav: 하강 톤 880→440Hz, 150ms
|
||||
const stopSamples = generateTone(150, 880, 440, 0.5);
|
||||
const stopWav = createWavBuffer(stopSamples);
|
||||
const stopPath = path.join(outputDir, 'recording-stop.wav');
|
||||
fs.writeFileSync(stopPath, stopWav);
|
||||
console.log(`Created: ${stopPath} (${stopWav.length} bytes)`);
|
||||
|
||||
// 3. error.wav: 220Hz 비프 200ms × 2회, 중간 100ms 무음
|
||||
const beep1 = generateTone(200, 220, 220, 0.5);
|
||||
const gap = generateSilence(100);
|
||||
const beep2 = generateTone(200, 220, 220, 0.5);
|
||||
const errorSamples = concatenate([beep1, gap, beep2]);
|
||||
const errorWav = createWavBuffer(errorSamples);
|
||||
const errorPath = path.join(outputDir, 'error.wav');
|
||||
fs.writeFileSync(errorPath, errorWav);
|
||||
console.log(`Created: ${errorPath} (${errorWav.length} bytes)`);
|
||||
|
||||
console.log('\nDone! All 3 sound files generated.');
|
||||
Loading…
Add table
Add a link
Reference in a new issue