binlog

MUSE

손동작을 입력으로 받아 사운드를 제어하는 실시간 인터랙션 시스템. 입력, 인식, 매핑, 출력 구조를 설계해 사용자의 움직임을 음악으로 연결되는 흐름으로 구현했습니다.

Role풀스택 개발Year2026
React 18TypeScriptTauriRustMediaPipeWeb Audio APIZustandVite
MUSE
01

Problem

기존 음악 생성 방식은 악기를 다루는 기술을 전제로 하거나, 터치 기반 인터페이스에 의존해 실제 연주 감각을 전달하기 어렵습니다. 특히 음악을 처음 접하는 사용자에게는 진입 장벽이 높고, 직관적인 입력만으로 음악을 만들 수 있는 방식이 부족한 문제가 있습니다. MUSE는 별도의 장비 없이 웹캠만으로 사용자의 움직임을 음악으로 연결할 수 있는 직관적인 인터랙션 시스템을 만드는 것을 목표로 했습니다.

02

Limitation

기존 제스처 기반 음악 시스템은 입력 인식과 오디오 출력이 분리되어 있어 지연(latency)이 발생하고, 실제 연주처럼 자연스럽게 연결되지 않는 문제가 있습니다. 또한 제스처 인식의 정확도가 낮아 입력 안정성이 떨어지고, 단순 트리거 기반 구조로 인해 음악 표현의 다양성이 제한되는 한계가 있습니다. 웹 환경에서는 특히 오디오 처리가 메인 스레드에 의존할 경우 입력 처리와 충돌하면서 지연이 증가하는 구조적 문제가 발생합니다.

03

Solution

MUSE는 사용자의 손동작을 입력으로 받아 사운드를 생성하는 구조를 입력 → 인식 → 매핑 → 출력 단계로 분리해 설계했습니다. MediaPipe 기반 손 추적을 통해 손의 위치와 손가락 상태를 실시간으로 추출하고, 이를 제스처 데이터로 변환해 오디오 파라미터에 매핑했습니다. 화면을 상단(멜로디)과 하단(드럼) 영역으로 분리해, 하나의 입력 장치로도 서로 다른 음악 요소를 동시에 제어할 수 있도록 구성했습니다. 오디오 처리는 Web Audio API의 AudioWorklet을 사용해 메인 스레드와 분리함으로써, 입력 처리와 독립적으로 동작하는 저지연 사운드 시스템을 구현했습니다.

04

System Architecture

WebCam Feed (30fps)
    |
    v
[MediaPipe Hand Tracking]
 21 landmarks per hand
    |
    v
[Gesture Classifier]
 ├── Finger count (0–5)
 ├── Hand position (x, y zone)
 └── Hold duration (0.8s threshold)
    |
    v
[Sound Zone Mapper]
 Screen Split: Upper zone (35%) / Lower zone (65%)
 ├── Upper zone: Synthesizer (pentatonic scale)
 │    └── finger_count → note pitch
 └── Lower: Drum Kit (6 pads)
      └── zone_position → pad trigger
    |
    v
[Web Audio Engine]
 AudioWorklet (off main thread)
 ├── Oscillator + ADSR envelope
 ├── Drum synthesis (no samples)
 └── Loop Station (record/playback)
    |
    v
[MIDI/OSC Output] → External DAW
05

Key Implementation

Tauri(Rust)로 웹 앱을 데스크탑 앱으로 패키징해 시스템 MIDI 접근을 가능하게 했습니다. AudioWorklet으로 드럼 합성과 루프스테이션 녹음을 메인 스레드 밖에서 처리합니다. 5손가락 0.8초 유지 제스처로 신시사이저/드럼/이펙터 패널을 전환하는 제스처 FSM을 구현했습니다. 외부 샘플 파일 없이 Web Audio API만으로 킥, 스네어, 하이햇 6종 드럼 합성을 구현했습니다.

06

Key Code

01computeHandOpenness — Landmark Heuristictypescript
1private computeHandOpenness(hand: Hand | null): number {
2 if (!hand || hand.landmarks.length < 21) return 0
3 const wrist = hand.landmarks[0]
4 const fingers = [
5 { tip: 8, mcp: 5 },
6 { tip: 12, mcp: 9 },
7 { tip: 16, mcp: 13 },
8 { tip: 20, mcp: 17 },
9 ]
10 let extended = 0
11
12 for (const { tip, mcp } of fingers) {
13 const distTip = Math.hypot(
14 hand.landmarks[tip].x - wrist.x,
15 hand.landmarks[tip].y - wrist.y
16 )
17 const distMcp = Math.hypot(
18 hand.landmarks[mcp].x - wrist.x,
19 hand.landmarks[mcp].y - wrist.y
20 )
21 if (distTip > distMcp * 1.1) extended++
22 }
23
24 // Thumb spread check via wrist-to-index MCP distance ratio
25 const thumbSpread = Math.hypot(
26 hand.landmarks[4].x - hand.landmarks[5].x,
27 hand.landmarks[4].y - hand.landmarks[5].y
28 )
29 const handSize = Math.hypot(
30 wrist.x - hand.landmarks[9].x,
31 wrist.y - hand.landmarks[9].y
32 )
33 if (handSize > 0.01 && thumbSpread > handSize * 0.5) extended++
34
35 // EMA smoothing applied by caller: 0.7 * prev + 0.3 * raw
36 return extended / 5
37}

MediaPipe 21개 랜드마크에서 손가락 4개의 tip-MCP 거리 비교로 신장 여부를 판단하고, 엄지 펼침을 별도 계산해 0~1 범위의 손 개방도를 반환합니다. 호출부에서 EMA(α=0.3)로 노이즈를 제거합니다.

02DrumEngine — Procedural Synthesistypescript
1hit(type: DrumType, velocity = 0.8): void {
2 const t = this.ctx!.currentTime
3 const vel = Math.max(0.1, Math.min(1, velocity))
4 switch (type) {
5 case "kick": this.playKick(t, vel); break
6 case "snare": this.playSnare(t, vel); break
7 case "hihatClosed": this.playHihat(t, vel, false); break
8 case "hihatOpen": this.playHihat(t, vel, true); break
9 case "tom1": this.playTom(t, vel, 210); break
10 case "tom2": this.playTom(t, vel, 150); break
11 }
12}
13
14private playKick(t: number, vel: number): void {
15 const osc = this.ctx!.createOscillator()
16 const gain = this.ctx!.createGain()
17 // 160Hz → 30Hz pitch drop over 450ms (sine wave body)
18 osc.frequency.setValueAtTime(160, t)
19 osc.frequency.exponentialRampToValueAtTime(30, t + 0.45)
20 gain.gain.setValueAtTime(vel * 1.2, t)
21 gain.gain.exponentialRampToValueAtTime(0.001, t + 0.5)
22 osc.connect(gain)
23 gain.connect(this.masterGain!)
24 osc.start(t); osc.stop(t + 0.5)
25}
26
27private playSnare(t: number, vel: number): void {
28 // White noise through 2800Hz bandpass filter
29 const noise = this.ctx!.createBufferSource()
30 noise.buffer = this.makeNoiseBuffer()
31 const filter = this.ctx!.createBiquadFilter()
32 filter.type = "bandpass"
33 filter.frequency.value = 2800
34 const gain = this.ctx!.createGain()
35 gain.gain.setValueAtTime(vel * 0.8, t)
36 gain.gain.exponentialRampToValueAtTime(0.001, t + 0.18)
37 noise.connect(filter); filter.connect(gain); gain.connect(this.masterGain!)
38 noise.start(t); noise.stop(t + 0.18)
39}

외부 샘플 파일 없이 Web Audio API만으로 드럼 사운드를 합성합니다. 킥은 사인파 피치 드롭(160→30Hz), 스네어는 화이트 노이즈 + 밴드패스 필터로 구현하며 velocity가 gain과 decay를 제어합니다.

07

Result & Learnings

입력과 오디오 처리를 분리한 구조를 적용해, 30ms 이하의 레이턴시로 실시간 연주가 가능한 환경을 구현했습니다. 제스처 인식에서 발생하는 노이즈를 줄이기 위해 홀드 시간과 평균화 기반 필터를 적용해 입력 안정성을 개선했습니다. 이 과정을 통해 인터랙션 시스템에서는 단순한 인식 정확도보다 입력 안정성과 반응 일관성이 사용자 경험에 더 큰 영향을 준다는 것을 확인했습니다. 또한 하나의 입력을 여러 출력으로 매핑하는 구조를 통해 단순 제스처를 음악적 표현으로 확장할 수 있는 가능성을 확인했습니다.

Links