테스트
Source: Dev.to
DTMF Hand‑Raise System – Corrected technical research: feasibility issues with the original approach, verified working patterns, 5 alternative architectures, and an implementation‑recommendation matrix for the muted‑conference hand‑raise use case.
Participants: 5–7명의 발신자 + 1명의 호스트
Core Issue: Gather는 “을 감쌀 수 없습니다
Recommended Production Path: Media Streams + DTMF 감지
System Feasibility: ✓ 여전히 완전히 실현 가능
01 ❌ 근본 문제
Twilio의 TwiML 사양은 통화가 “ 안에 있을 때 DTMF 감지를 허용하지 않습니다. 참가자가 누른 키패드 숫자는 오디오 톤으로 컨퍼런스 룸에 전송되며 서버‑사이드 웹훅이 발생하지 않습니다. 이를 위한 기본 이벤트도 없습니다.
원본 문서에서는 안에 를 중첩하는 방식을 제안했지만, 이는 구조적으로 유효하지 않음을 의미합니다. Twilio TwiML 스키마는 엄격한 부모‑자식 규칙을 강제합니다:
| TwiML Verb | 유효한 자식 | 비고 |
|---|---|---|
| “ | , , “ | DTMF 또는 음성 입력을 수집 |
| “ | , , , , “ | 은 안에 들어가는 명사 |
| “ | — | 자식이 없음. “ 안에 있을 수 없음 |
Twilio Node.js SDK도 이 스키마를 강제합니다. Gather 혹은 VoiceResponse 객체에서 .conference() 를 호출하면 즉시 오류가 발생합니다:
// TypeError: twiml.conference is not a function
⚠️ statusCallbackEvent 수정
statusCallbackEvent 매개변수에는 “unmute”(음소거 해제) 이벤트가 포함되지 않습니다. 유효한 값은 다음과 같습니다:
start, end, join, leave, mute, hold, modify, speaker, announcement
Twilio는 participant-mute 이벤트를 음소거와 음소거 해제 모두에 대해 발생시킵니다. 웹훅 본문의 Muted 필드(true 또는 false)가 어떤 동작이 일어났는지를 알려줍니다.
if (StatusCallbackEvent === 'participant-mute') {
// Muted === true → mute
// Muted === false → unmute
res.sendStatus(200);
}
02 Proof‑of‑Concept (POC) – TwiML 제약을 준수하는 두 접근법
접근법 A – 회의 전 사전 수집
- 참가자는 회의에 입장하기 전 짧은 “ 창을 가집니다.
- 이 창 동안
*1을 누르면 손들기가 입장 전에 등록됩니다.
장점:
- ✅ 오디오 중단 없음
- ⚡ 한 번만
접근법 B – REST API 호출 리다이렉트 (호스트 주도)
- 호스트가 대시보드에서 회의 중 DTMF 프롬프트를 트리거합니다.
- 백엔드가
twilioClient.calls(callSid).update({ url: gatherUrl })를 호출해 일시적으로 참가자를 끌어와 키 입력을 수집한 뒤 다시 반환합니다.
장점:
- ✅ 통화 중 가능
- ⚡ 호스트 주도
twiml.say({ voice: 'Polly.Joanna' }, 'Press *1 to raise hand.');
// Gather window — participant can press *1 NOW
// Fall‑through: no key pressed → enter conference muted
twiml.redirect(`${BASE_URL}/webhooks/conference`);
twiml.play(`${BASE_URL}/hold-music`);
res.type('text/xml').send(twiml.toString());
// Handle pre‑join keypress
if (Digits === '*1') {
// Either way, enter the conference
res.type('text/xml').send(twiml.toString());
}
대시보드에는 참가자당 “Prompt Hand Raise” 버튼이 표시됩니다. 클릭하면 백엔드가 해당 발신자의 현재 통화를 수집 TwiML 페이지로 리다이렉트하고, 응답을 수집한 뒤 다시 회의로 반환합니다.
// /voice/gather-hand-raise – the gather prompt TwiML page
const gather = twiml.gather({
action: `${BASE_URL}/handle-hand-raise`,
method: 'POST',
timeout: 5,
numDigits: 2,
});
gather.say('Press *1 to raise your hand.');
twiml.redirect(`${BASE_URL}/webhooks/conference`);
res.type('text/xml').send(twiml.toString());
// Process their response
if (Digits === '*1') {
const dial = twiml.dial();
dial.conference('myConference', { muted: true });
}
ℹ️ 패턴 B의 트레이드‑오프
- 참가자는 수집 프롬프트가 재생되는 동안 회의 오디오에서 잠시(약 3‑5 초) 끊깁니다.
- 이는 호스트 주도이며, 참가자가 회의 중 스스로 트리거할 수 없습니다.
패턴 B는 사용 가능한 POC 솔루션이지만, 실제 운영에서는 Media Streams(섹션 03)로 업그레이드하여 참가자 주도 손들기를 구현해야 합니다.
03 Alternative 1 – Media Streams + Goertzel (Production‑Grade)
목표: 회의에서 참가자를 아예 끊어내지 않고 실시간으로 DTMF 톤을 감지합니다.
작동 방식
- Twilio Media Streams가 각 참가자의 통화에서 원시 오디오 스트림(8 kHz µ‑law)을 서버의 WebSocket 엔드포인트로 전송합니다.
- 서버에서 Goertzel 기반 DTMF 감지기가 들어오는 오디오를 처리합니다.
- 키가 눌리면 톤이 서버 측에서 감지되고 손들기 이벤트가 생성됩니다.
Participant presses *1
↓
Phone keypad → Twilio streams audio (8 kHz µ-law) via WebSocket
↓
Your WS server → Goertzel detector
↓
Hand‑raise event → Dashboard notified
샘플 WebSocket 서버 (Node.js)
const WebSocket = require('ws');
const mediaWss = new WebSocket.Server({ path: '/media-stream', server });
mediaWss.on('connection', (ws) => {
let callSid = null;
ws.on('message', (msg) => {
const data = JSON.parse(msg);
if (data.event === 'start') {
callSid = data.start.callSid; // map stream → Call SID
}
if (data.event === 'media') {
// Decode base64 µ-law audio payload
const audio = Buffer.from(data.media.payload, 'base64');
// Run Goertzel DTMF detector on this audio chunk
const digit = detector.detect(audio);
if (digit) {
handleDTMFDigit(callSid, digit);
}
}
if (data.event === 'stop') {
detector.reset();
}
});
});
async function handleDTMFDigit(callSid, digit) {
// Your business logic – e.g., flag hand‑raise in DB, push to dashboard, etc.
}
DTMF‑감지 라이브러리
| 라이브러리 | 언어 | 비고 |
|---|---|---|
node-dtmf | Node.js | µ‑law 오디오용 간단한 Goertzel 구현 |
goertzel-js | Node.js | 저수준 Goertzel 필터; DTMF 주파수를 직접 매핑해야 함 |
dtmf-decoder | Python | 백엔드가 Python/FastAPI인 경우에 적합 |
librosa + custom | Python | 과도하게 복잡하지만 매우 정확함 |
장·단점
| ✅ 장점 | ❌ 단점 |
|---|---|
| 참가자는 회의에 그대로 남음 – 오디오 중단 없음 | 오디오를 받기 위한 WebSocket 서버가 필요 |
| 언제든지 참가자가 직접 손들기를 할 수 있음 | DTMF 감지 라이브러리와 Goertzel 구현이 필요 |
| 추가 Twilio 비용 없음 – Media Streams는 Voice에 포함 | 약간 높은 엔지니어링 노력 |
| 정의한 어떤 키패드 조합도 동작 |
04 대안 2 – Twilio Flex / TaskRouter
(세부 내용은 간략히 생략 – 전체 설명은 원본 문서를 참조하십시오.)
05 Alternative 3 – Twilio Sync State
(세부 사항은 간략히 생략되었습니다 – 전체 설명은 원본 문서를 참조하십시오.)
06 대안 4 – 컨퍼런스 보류 + 수집
(간략히 생략 – 전체 설명은 원본 문서를 참조하십시오.)
07 대안 5 – 듀얼‑채널 (전화 + 웹)
(간략히 생략 – 전체 설명은 원본 문서를 참조하십시오.)
08 Decision Matrix & Recommendation
| Architecture | Audio Interruption | Participant‑Initiated | Implementation Effort | Cost |
|---|---|---|---|---|
| Gather‑Before‑Conference | ✅ 없음 | ❌ 아니오 (사전 참여만) | 낮음 | 무료 |
| Host‑Initiated Redirect | ⚠️ 짧은 일시정지 | ❌ 아니오 (호스트 전용) | 중간 | 무료 |
| Media Streams + Goertzel | ✅ 없음 | ✅ 예 | 높음 (WebSocket + DSP) | 무료 (음성 전용) |
| Flex / TaskRouter | 다양함 | 다양함 | 높음 | 유료 (Flex) |
| Sync State | 다양함 | 다양함 | 중간 | 유료 (Sync) |
| Conference Hold + Gather | ⚠️ 일시정지 | ❌ 호스트 전용 | 중간 | 무료 |
| Dual‑Channel | ✅ 없음 | ✅ 예 (웹) | 높음 | 유료 (Web UI) |
Recommendation:
프로덕션 수준의 중단 없음, 참가자 주도 손들기 경험을 위해 대안 1 – Media Streams + Goertzel을 채택하십시오. 사전 참여 손들기에 대한 빠른 대체 방안으로만 Gather‑Before‑Conference 패턴을 사용하십시오.
켜짐
- 더 높은 서버 자원 사용 (참가자당 오디오 처리)
- 다자리 디바운싱 로직 필요 (*1 = 두 개 신호)