Nightingale 코드 분석 — Rust + ML로 만든 오픈소스 노래방 앱의 구조
Tauri 2 + Python ML 서버 + UVR/Demucs 음성 분리 + WhisperX 가사 동기화 + 실시간 피치 감지
GitHub 리포지토리(rzru/nightingale)를 직접 분석한 결과다.
아키텍처 — Bevy가 아니라 Tauri 2
처음 "Rust + Bevy 게임 엔진"으로 알려졌지만, 실제 코드는 Tauri 2 데스크톱 앱(Rust 백엔드 + React 프론트엔드)이다.
Cargo workspace 구조:
app-core/— 순수 Rust 라이브러리. 비즈니스 로직, ML 오케스트레이션, 캐싱, 벤더 부트스트래핑client/src-tauri/— Tauri 2 셸. app-core를#[tauri::command]로 노출, cpal로 마이크 입력, pitch-detection으로 피치 감지xtask/— 빌드 툴링
"게임 엔진" 요소는 프론트엔드의 GPU 셰이더 배경(Plasma, Aurora, Nebula 등)뿐이다. WebGL/WebGPU로 렌더링한다.
음성 분리 — 가장 핵심적인 부분
두 가지 분리 백엔드를 지원한다. config.separator()로 선택하며 기본값은 "karaoke".
1. UVR Karaoke 모델 (기본)
app-core/analyzer/stems.py에 구현되어 있다:
KARAOKE_MODEL = "mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"
def separate_stems_uvr(audio_path, work_dir, models_dir):
from audio_separator.separator import Separator
separator = Separator(
model_file_dir=models_dir,
output_dir=work_dir,
)
separator.load_model(KARAOKE_MODEL)
output_files = separator.separate(audio_path)
Mel-Band RoFormer 아키텍처, aufr33+viperx가 훈련한 모델이다. SDR 10.1956. 이 모델의 특징은 리드 보컬만 분리하고 백킹 보컬은 반주에 남긴다 — 노래방에 딱 맞는 동작이다.
audio-separator 패키지가 ONNX Runtime으로 실행한다. NVIDIA GPU에서는 CUDA, Apple Silicon에서는 CoreML을 쓴다.
2. Demucs (대안)
Facebook의 htdemucs 모델을 직접 사용한다:
from demucs.apply import apply_model
from demucs.pretrained import get_model
model = get_model("htdemucs")
sources = apply_model(model, wav_scaled[None], device=device, shifts=1, overlap=0.25)[0]
vocals = sources[vocals_idx]
instrumental = wav.to(device) - vocals
Demucs는 4개 스템(보컬, 드럼, 베이스, 기타)으로 분리하지만, Nightingale은 보컬만 추출하고 원본 - 보컬 = 반주로 계산한다.
Rust에서 Python ML을 관리하는 방법
이게 아키텍처에서 가장 영리한 부분이다.
Python 스크립트 10개가 컴파일 시점에 Rust 바이너리에 임베딩된다:
// app-core/src/vendor_scripts.rs
const STEMS_PY: &str = include_str!("../analyzer/stems.py");
const PIPELINE_PY: &str = include_str!("../analyzer/pipeline.py");
첫 실행 시 ~/.nightingale/vendor/analyzer/에 추출된다. Rust 앱이 영속적 Python 서버 프로세스를 스폰하고, stdin/stdout JSON 프로토콜로 통신한다.
Rust → stdin: {"command": "analyze", "audio_path": "...", "separator": "karaoke"}
Python → stdout: [nightingale:PROGRESS:15] Separating vocals...
Python → stdout: [nightingale:DONE]
서버 프로세스가 영속적이라 WhisperX 모델이 곡 사이에 메모리에 남아있다. CUDA OOM이 발생하면 서버를 kill하고 GPU를 정리한 후 다시 스폰한다.
전체 처리 파이프라인
pipeline.py::run_pipeline()이 오케스트레이션한다:
- Key Detection — FFT 기반 피치 클래스 프로파일링 (Krumhansl-Schmuckler 알고리즘)
- Stem Separation — UVR Karaoke 또는 Demucs로 보컬/반주 분리
- Lyrics — LRCLIB에 기존 가사가 있으면 사용, 없으면 WhisperX로 전사
- 캐시 저장 — blake3 해시 기반. 같은 파일은 재분석 안 함
가사 동기화
LRCLIB 우선 검색:
1. lrclib.net/api/search?track_name=...&artist_name=... 검색
2. 앨범 매칭 + 재생 시간 근접도로 순위 매김
3. 가사가 있으면 WhisperX의 align() 만 실행 (전사 건너뜀)
WhisperX 전사 (가사 없을 때):
1. RMS 에너지로 보컬 구간 감지
2. 80Hz 하이패스 필터 + RMS 정규화
3. 30초 윈도우 4개로 다중 언어 감지 (투표)
4. model.transcribe(audio, batch_size=8, chunk_size=30)
5. whisperx.align()으로 단어 단위 타임스탬프 정렬
6. 정렬에서 빠진 단어 복구 + 타임스탬프 보간
7. 시간 간격과 구두점 기준으로 화면 표시 세그먼트 구성 (줄당 최대 10단어)
환각 필터링도 있다 — "please subscribe", "구독과 좋아요" 같은 Whisper 특유의 환각 문구를 블록리스트로 제거한다.
OOM 폴백 전략: GPU에서 시도 → OOM이면 Whisper 모델 해제 후 재시도 → 여전히 OOM이면 CPU 폴백.
실시간 피치 감지
피치 감지는 순수 Rust로 구현된다. Python 불필요.
client/src-tauri/src/microphones.rs:
cpal크레이트로 크로스플랫폼 마이크 입력pitch-detection크레이트의 McLeod Pitch Detection 알고리즘윈도우 사이즈 2048 샘플, 80-1000Hz 범위
~40Hz(25ms 주기)로
mic-pitch이벤트를 Tauri 이벤트 시스템으로 프론트엔드에 전송프론트엔드(React)가 감지된 피치와 가사의 기대 피치를 비교해 점수 계산
자급형 바이너리 — 첫 실행 시 부트스트랩
6단계로 자동 설정된다:
1. FFmpeg 바이너리 다운로드 (플랫폼별)
2. uv (Astral의 Python 패키지 매니저) 다운로드
3. uv python install 3.10으로 Python 설치
4. uv venv로 가상환경 생성
5. uv pip install로 ML 패키지(torch, whisperx, audio-separator, demucs 등) 설치
6. 임베딩된 Python 스크립트를 vendor/analyzer/에 추출
GPU 감지:
macOS ARM → MPS (Apple GPU)
Linux/Windows + NVIDIA → CUDA (compute capability로 cu126/cu128 선택)
그 외 → CPU 폴백
.ready 마커 파일로 설정 완료 여부를 판단한다.
일본어·한국어 가사의 한계
WhisperX의 단어 정렬이 line_text.split()으로 단어를 분리하기 때문에, 띄어쓰기가 없는 일본어에서는 작동하지 않는다. fugashi 같은 형태소 분석기가 필요한 부분. 한국어는 띄어쓰기가 있어서 상대적으로 낫지만, 가사 싱크 정확도는 언어에 따라 차이가 크다.
동작 흐름
Python 스크립트를 include_str!()로 Rust 바이너리에 컴파일 타임 임베딩
영속적 Python 서버 프로세스를 stdin/stdout JSON 프로토콜로 제어
UVR Mel-Band RoFormer로 리드 보컬만 분리 (백킹 보컬은 반주에 잔류)
LRCLIB API로 기존 가사 검색 → 없으면 WhisperX 전사 + 강제 정렬
McLeod Pitch Detection을 순수 Rust로 ~40Hz 주기 실행, Tauri 이벤트로 프론트엔드 전송
첫 실행 시 uv로 Python 3.10 + PyTorch + ML 모델을 자동 부트스트랩