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

Problem
기존 음악 생성 방식은 악기를 다루는 기술을 전제로 하거나, 터치 기반 인터페이스에 의존해 실제 연주 감각을 전달하기 어렵습니다. 특히 음악을 처음 접하는 사용자에게는 진입 장벽이 높고, 직관적인 입력만으로 음악을 만들 수 있는 방식이 부족한 문제가 있습니다. MUSE는 별도의 장비 없이 웹캠만으로 사용자의 움직임을 음악으로 연결할 수 있는 직관적인 인터랙션 시스템을 만드는 것을 목표로 했습니다.
Limitation
기존 제스처 기반 음악 시스템은 입력 인식과 오디오 출력이 분리되어 있어 지연(latency)이 발생하고, 실제 연주처럼 자연스럽게 연결되지 않는 문제가 있습니다. 또한 제스처 인식의 정확도가 낮아 입력 안정성이 떨어지고, 단순 트리거 기반 구조로 인해 음악 표현의 다양성이 제한되는 한계가 있습니다. 웹 환경에서는 특히 오디오 처리가 메인 스레드에 의존할 경우 입력 처리와 충돌하면서 지연이 증가하는 구조적 문제가 발생합니다.
Solution
MUSE는 사용자의 손동작을 입력으로 받아 사운드를 생성하는 구조를 입력 → 인식 → 매핑 → 출력 단계로 분리해 설계했습니다. MediaPipe 기반 손 추적을 통해 손의 위치와 손가락 상태를 실시간으로 추출하고, 이를 제스처 데이터로 변환해 오디오 파라미터에 매핑했습니다. 화면을 상단(멜로디)과 하단(드럼) 영역으로 분리해, 하나의 입력 장치로도 서로 다른 음악 요소를 동시에 제어할 수 있도록 구성했습니다. 오디오 처리는 Web Audio API의 AudioWorklet을 사용해 메인 스레드와 분리함으로써, 입력 처리와 독립적으로 동작하는 저지연 사운드 시스템을 구현했습니다.
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 DAWKey Implementation
Tauri(Rust)로 웹 앱을 데스크탑 앱으로 패키징해 시스템 MIDI 접근을 가능하게 했습니다. AudioWorklet으로 드럼 합성과 루프스테이션 녹음을 메인 스레드 밖에서 처리합니다. 5손가락 0.8초 유지 제스처로 신시사이저/드럼/이펙터 패널을 전환하는 제스처 FSM을 구현했습니다. 외부 샘플 파일 없이 Web Audio API만으로 킥, 스네어, 하이햇 6종 드럼 합성을 구현했습니다.
Key Code
| 1 | private 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)로 노이즈를 제거합니다.
| 1 | hit(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 | |
| 14 | private 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 | |
| 27 | private 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를 제어합니다.
Result & Learnings
Links