저렴한 아두이노 호흡 컨트롤러 - USB-MIDI 연동
부품 및 소모품
| 부품 |
수량 |
비고 |
| 아두이노 마이크로 |
1 |
클론으로 비용 절감 가능하나 정품 권장. ATmega32U4 기반 마이크로만 네이티브 USB 지원으로 작동 |
| 브레드보드 하프 사이즈 |
1 |
|
| MPS20N0040D-D 압력 센서 |
1 |
저렴하고 구하기 쉬움 |
| LM358N 오피앰프 |
1 |
센서 신호 증폭용으로 널리 쓰이는 LM358 사용 |
| 저항 1MΩ |
2 |
|
| 후크업 와이어 키트 (22 AWG) |
1 |
솔리드 코어 와이어 몇 cm만 있으면 됨 |
| 아기 코 흡입기 |
1 |
부모용 마우스피스와 아기용 흡입부 사용. 피지오머, 치코 피지오클린 등 동일 타입 다수 존재 |
| 5mm 수족관 에어라인 튜브 |
1 |
흡입기 튜브가 짧을 경우 사용. 일반 에어라인 튜브면 충분 |
| 3-way 에어 튜브 커넥터 |
1 |
중국 OEM 제품으로 수족관 가게에서 다양한 이름으로 판매. 센서에 직접 장착 가능 |
필수 도구 및 장비
| 도구 |
| 와이어 스트리퍼 & 커터 (32-20 AWG / 0.05-0.5mm²) |
앱 및 온라인 서비스
프로젝트 소개
소개
이 프로젝트에서는 간단한 USB MIDI 플러그앤플레이 호흡 컨트롤러를 제작합니다. 저렴하고 구하기 쉬운 부품만 사용해 총 비용을 상용 제품보다 훨씬 낮게 유지했습니다. 현재는 공기 압력 센서만 사용하는 기본 버전이며, 향후 바이트(물기) 센서와 고개 끄덕임/기울임 센서 추가를 계획 중입니다.
기초적인 전자 회로와 아두이노 지식이 필요하지만, 납땜이 필요 없어 초보자도 제작 가능합니다. 고급 사용자는 퍼마 프로토보드에 납땜해 영구적으로 만들 수 있습니다. IDE/라이브러리 설치와 코드 업로드 방법은 인터넷에 훌륭한 튜토리얼이 많으므로 생략합니다.
오버샘플링으로 센서 입력을 부드럽게 처리하며 결과가 매우 좋습니다. 측정 범위를 취향에 맞게 조정하거나 커스텀 커브를 적용해 컨트롤러 반응 특성을 바꿀 수도 있습니다.
센서가 음압(들이마시기)도 측정할 수 있어, 불어넣기와 들이마시기에 각각 다른 컨트롤러 메시지를 출력합니다. 예를 들어 불어넣기는 피치 벤드 업, 들이마시기는 피치 벤드 다운으로 설정 가능합니다. 기본값은 둘 다 컨트롤러 번호 2(브레스 컨트롤러 표준)입니다.
제작 단계
- 사진/회로도와 같이 아두이노를 브레드보드에 장착
- 센서와 오피앰프를 측면의 작은 홈 방향에 맞춰 장착
- 저항 다리를 적당히 잘라 장착
- 솔리드 코어 와이어를 잘라 피복을 벗기고 연결 (빨강: 5V, 검정: GND, 노랑: 신호 - 이해를 돕기 위함, 보유한 와이어 사용 가능)
- 마우스피스, 튜브, 3-way 커넥터, 흡입기를 사진과 같이 연결. '배기'용 튜브 조각 필요
- 3-way 커넥터를 센서에 단단히 눌러 고정
- 아두이노 IDE 설치 후 Oversampling과 USB-MIDI 라이브러리를 도구 → 라이브러리 관리에서 설치. USB 케이블로 아두이노 연결 후 코드 업로드
- 완료! 아두이노가 DAW/음악 소프트웨어에서 MIDI 장치로 인식됨. 활성화 후 키보드와 함께 브레스 컨트롤러를 지원하는 플러그인에 라우팅


고급 정보
이 설계의 물리적 단점은 침이 튜브로 흘러 들어가 공기 흐름 변동을 일으킬 수 있다는 점입니다. 3-way 커넥터는 침을 '배기' 튜브로 배출해 이 문제를 해결합니다. 마우스피스에서 3-way 커넥터까지 튜브가 지속적으로 아래로 기울어지도록 길이 조절해 침이 고이지 않게 하세요. 튜브가 커넥터 아래로 처지면 침이 낮은 지점에 고여 변동을 유발합니다. 흡입기의 아기용 필터 부품을 배기 측에 연결해 물방울 떨어짐과 소음을 줄이고 센서로의 공기 흐름을 늘립니다.
코드 내 조정 가능한 값들(커스텀 커브 포함)은 주석으로 설명되어 있습니다. 최대값(127) 도달이 힘들면 최대 범위를 줄이고, 너무 쉽게 도달하면 늘리세요. 값 변경 후 코드 재업로드 필요.
아두이노 부팅 후 처음 몇 회 측정값을 평균내 휴지 상태를 보정합니다. 장치 연결/리셋 시 불어넣지 마세요.
MIDI 장치 이름은 boards.txt 파일 수정으로 변경 가능 (플랫폼별 방법은 인터넷 검색).
코드
호흡 컨트롤러 코드 (아두이노)
/* Breath Controller */
// 라이브러리 - 도구 → 라이브러리 관리에서 설치
#include <Oversampling.h>
#include <USB-MIDI.h>
// 디버그 모드 (주석 해제 시 활성화)
//#define DEBUG 1
// USB MIDI 인터페이스 생성
USBMIDI_CREATE_DEFAULT_INSTANCE();
// 오버샘플링 초기화
Oversampling adc(10, 13, 6);
// ***************** 사용자 설정 *****************
// 1로 끝나는 값: 불어넣기, 2로 끝나는 값: 들이마시기
// 핀 설정
const int sensorPin1 = A0; // 센서/오피앰프 출력 연결 아두이노 입력 핀
// 범위 보정. 최댓값에 도달 가능하지만 너무 쉽지 않게 수동 조정
int sensorRange1 = 800;
int sensorRange2 = 800;
// 출력 컨트롤러 번호 (아래 표 참고)
// 0-127: 일반 컨트롤 체인지 메시지
// 128: 모노포닉 애프터터치
// 129: 피치 벤드 업
// 130: 피치 벤드 다운
int controllerNumber1 = 2; // 불어넣기 시 전송
int controllerNumber2 = 2; // 들이마시기 시 전송
// 출력 컨트롤러 채널
int controllerChannel1 = 1;
int controllerChannel2 = 1;
// 안전 임계값 (휴지/최대 상태에서 변동 방지)
// 휴지 상태에서 다중 메시지 전송 시 lowThreshold 증가
// 최대 상태에서 다중 메시지 전송 시 highThreshold 증가
const int lowThreshold1 = 5;
const int lowThreshold2 = 5;
const int highThreshold1 = 0;
const int highThreshold2 = 0;
// 커브 정의. 테이블 길이 2 이상, 값 0-127. in 테이블은 오름차순.
// 변환은 판독값 레벨에서 수행해 정의 손실 최소화
int in1[] = {0, 127};
int out1[] = {0, 127};
int in2[] = {0, 127};
int out2[] = {0, 127};
// 예시 커브 (센서 번호에 맞게 수정)
// 부드러운 커브
// int in1[] = {0, 6, 24, 78, 127};
// int out1[] = {0, 32, 64, 96, 127};
// 범위 축소
// int in1[] = {50, 100};
// int out1[] = {50, 100};
// 새로고침 주기 (밀리초). 낮을수록 작동 중 메시지 빈번 전송
int refreshCycle = 0;
// ***************** 구현부 *****************
// 이 아래부터는 센서 동작 변경 의도 없으면 수정하지 마세요.
// 센서 내부 값
int sensorValue1 = 0;
int sensorValue2 = 0;
// 센서 최솟값
int sensorMin1;
int sensorMin2;
// 출력 컨트롤러 값
int controllerValue1 = 0;
int controllerValue2 = 0;
// 이전 주기 값 (동일 메시지 반복 방지)
int previousControllerValue1 = 0;
int previousControllerValue2 = 0;
// 범위 변환 변수
int outputRange1;
int outputRange2;
int sensorLow1;
int sensorLow2;
int sensorHigh1;
int sensorHigh2;
void setup() {
MIDI.begin(1);
#ifdef DEBUG
Serial.begin(115200); // 디버그 모드용
#endif
// 센서 휴지점 보정: 최초 10개 값 평균. 부팅 중 센서 사용 금지
sensorMin1 = adc.read(sensorPin1);
sensorMin2 = 0;
// 선택된 컨트롤러의 출력 범위 결정
outputRange1 = outputRange(controllerNumber1);
outputRange2 = outputRange(controllerNumber2);
}
void loop() {
// 센서 값 읽기
sensorValue1 = adc.read(sensorPin1); // 불어넣기
sensorValue2 = sensorMin1 - sensorValue1; // 들이마시기
// 이전 값 저장
previousControllerValue1 = controllerValue1;
previousControllerValue2 = controllerValue2;
// 센서 상하한 사용 범위
sensorLow1 = sensorMin1 + lowThreshold1;
sensorLow2 = sensorMin2 + lowThreshold2;
sensorHigh1 = sensorLow1 + sensorRange1 - highThreshold1;
sensorHigh2 = min(sensorMin1, sensorRange2) - highThreshold2;
// 내부 값을 커브 적용해 출력 범위(컨트롤러/애프터터치 0..127, 피치벤드 0..+/-8191)로 변환
controllerValue1 = map(mapToCurve(constrain(sensorValue1, sensorLow1, sensorHigh1), sensorLow1, sensorHigh1, in1, out1, sizeof(in1)/sizeof(int)), sensorLow1, sensorHigh1, 0, outputRange1);
controllerValue2 = map(mapToCurve(constrain(sensorValue2, sensorLow2, sensorHigh2), sensorLow2, sensorHigh2, in2, out2, sizeof(in2)/sizeof(int)), sensorLow2, sensorHigh2, 0, outputRange2);
// MIDI 메시지 전송 (값 변경 시만)
if (controllerValue1 != previousControllerValue1)
sendSensorOutput(controllerNumber1, controllerValue1, controllerChannel1);
if (controllerValue2 != previousControllerValue2)
sendSensorOutput(controllerNumber2, controllerValue2, controllerChannel2);
// 디버그
#ifdef DEBUG
// Serial.print(sensorValue1); Serial.print(","); Serial.print(sensorValue2); Serial.print(",");
Serial.print(controllerValue1); Serial.print(","); Serial.println(controllerValue2);
#endif
delay(refreshCycle);
}
// 컨트롤러 번호에 따른 MIDI 메시지 전송
void sendSensorOutput(int number, int value, int channel) {
if (number < 128) MIDI.sendControlChange(number, value, channel);
else if (number == 128) MIDI.sendAfterTouch(value, channel);
else if (number == 129) MIDI.sendPitchBend(value, channel);
else if (number == 130) MIDI.sendPitchBend(-value, channel);
}
// 컨트롤러별 출력 범위 결정 (피치벤드는 더 큰 범위)
int outputRange(int number) {
if (number > 128) return 8191;
else return 127;
}
// 커브 생성을 수정한 multiMap 함수 (원작: Rob Tillaart)
int mapToCurve(int val, int sensorLow, int sensorHigh, int* _in, int* _out, uint8_t size) {
if (val <= map(_in[0], 0, 127, sensorLow, sensorHigh))
return map(_out[0], 0, 127, sensorLow, sensorHigh);
if (val >= map(_in[size-1], 0, 127, sensorLow, sensorHigh))
return map(_out[size-1], 0, 127, sensorLow, sensorHigh);
uint8_t pos = 1;
while(val > map(_in[pos], 0, 127, sensorLow, sensorHigh)) pos++;
int inPos = map(_in[pos], 0, 127, sensorLow, sensorHigh);
int outPos = map(_out[pos], 0, 127, sensorLow, sensorHigh);
int inPrv = map(_in[pos-1], 0, 127, sensorLow, sensorHigh);
int outPrv = map(_out[pos-1], 0, 127, sensorLow, sensorHigh);
if (val == inPos) return outPos;
return ((long)val - (long)inPrv) * ((long)outPos - (long)outPrv) / ((long)inPos - (long)inPrv) + (long)outPrv;
}
회로도
프리칭 회로도

CircuitLab 회로도
