LabHub
시작하기
배우기 러닝패스 코스

음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인

말이 끝났나 — VAD 로 턴을 나누고 끼어들기를 찾는다

LabHub 에서 이어서 보기

목표

에너지 VAD 와 신경망 VAD 를 같은 통화에 돌려 오경보를 세고, VAD 구간을 턴으로 묶는 규칙을 만들어 처음 보는 통화에서 시험한다. 에코가 섞인 마이크에서 사람이 끼어든 시각을 찾는다.

왜 중요한가

끝점 판정의 늦음은 모델을 빠르게 해도 줄지 않는 대답 지연이다. 짧게 잡으면 말 중간을 끊고, 길게 잡으면 느리다. 이 실습의 통화(/opt/lab/fixtures/voice/vad/call_a.wav)는 합성한 문장 조각을 정해진 시각에 놓아 만들어서 '실제로 말한 구간' 이 call_a.truth.json 에 있다. 채점기는 여러분의 turns.py 를 여러분이 본 적 없는 두 번째 통화에서 다시 돌린다 — 한 파일에 문턱을 맞춘 코드는 거기서 넘어진다.

단계

  1. 30 ms(480 샘플) 프레임의 dBFS 가 -35 보다 큰 구간을 이어 붙인 에너지 VAD 결과를 /root/voice/vad/energy.json 에 적으세요.
  2. silero VAD(voicekit.models.vad(threshold=0.5, min_silence=0.1, min_speech=0.1), 512 샘플씩 넣기)의 구간을 /root/voice/vad/silero.json 에 적으세요.
  3. 두 결과를 call_a.truth.json 의 speech_segments 와 겹쳐 false_alarms·missed 를 센 /root/voice/vad/score.json 을 만드세요.
  4. detect_turns(samples, sr, gap_s) 가 있는 /root/voice/vad/turns.py 를 만드세요 — silero 구간 가운데 간격이 gap_s 보다 짧은 것을 이어 붙여 [(시작, 끝), …] 을 돌려줍니다.
  5. gap 0.2 · 0.3 · 0.5 · 0.8 · 1.2 초에서 call_a 의 턴 수를 센 /root/voice/vad/sweep.json 을 만드세요.
  6. 턴 수가 맞는 gap 가운데 가장 짧은 값으로, 턴마다 대답 지연 (VAD 가 본 말 끝 + gap) − 실제 말 끝 을 /root/voice/vad/latency.json 에 적으세요.
  7. /opt/lab/fixtures/voice/bargein/mic.wav 에서 playback.wav 의 에코 지연·배율을 추정해 빼고 /root/voice/vad/residual.wav 를 저장한 뒤, 원본과 뺀 뒤의 첫 말 시각을 /root/voice/vad/bargein.json 에 적으세요.
  8. 앞 단계의 숫자를 모은 /root/voice/vad/report.json 을 만드세요.

참고

에너지 VAD — 큰 소리는 전부 말

/opt/lab/fixtures/voice/vad/call_a.wav 를 30 ms(480 샘플) 프레임으로 나눠 dBFS 가 -35 보다 큰 프레임이 이어진 구간을 /root/voice/vad/energy.json 에 [{"start_s": …, "end_s": …}](소수 둘째 자리)로 적으세요. 끝의 모자란 조각은 버립니다.

프레임 i 의 시작은 i×480/16000 초입니다. 말 프레임이 시작되면 번호를 기억하고, 끝나는 프레임의 시작 시각에서 닫습니다. voicekit.dbfs 가 RMS dBFS 를 줍니다.

silero VAD 의 구간

voicekit.models.vad(threshold=0.5, min_silence=0.1, min_speech=0.1) 에 call_a 를 512 샘플씩 넣고 flush() 한 뒤 나온 구간을 /root/voice/vad/silero.json 에 같은 모양으로 적으세요(시작 = front.start ÷ 16000, 끝 = 시작 + len(front.samples) ÷ 16000).

구간은 VAD 안의 큐에 쌓입니다. empty() 가 참이 될 때까지 front 를 읽고 pop() 합니다. flush() 를 부르지 않으면 파일 끝에서 열린 구간이 안 나옵니다.

정답과 겹쳐 오경보·놓침 세기

call_a.truth.json 의 speech_segments 를 정답으로 두고, energy·silero 각각의 false_alarms(정답과 전혀 안 겹친 예측 구간 수)·missed(어떤 예측과도 안 겹친 정답 구간 수)를 /root/voice/vad/score.json 에 적으세요.

두 구간 (a0,a1)·(b0,b1) 이 겹친다 = a0 < b1 and b0 < a1. 에너지 VAD 는 6초의 문 두드림을 어떻게 잡았을까요?

VAD 구간을 턴으로 묶는다

/root/voice/vad/turns.py 에 detect_turns(samples, sr=16000, gap_s=0.5) 를 만드세요. silero VAD(threshold 0.5 · min_silence 0.1 · min_speech 0.1)의 구간을 차례로 보며 앞 턴 끝과의 간격이 gap_s 보다 짧으면 이어 붙이고, 아니면 새 턴을 시작해 [(시작 초, 끝 초), …] 를 돌려줍니다. 채점기는 gap 0.5 로 call_a 와 처음 보는 통화를 둘 다 돌립니다.

잘게 자르는 일(VAD)과 묶는 일(gap)을 나누면 gap 하나만 바꿔 가며 실험할 수 있습니다. 실행 코드는 if name == 'main': 아래에 두세요 — 채점기가 이 파일을 모듈로 불러옵니다.

gap 을 훑는다 — 끊기와 붙기

turns.py 로 gap 0.2 · 0.3 · 0.5 · 0.8 · 1.2 초에서 call_a 의 턴 수를 세어 /root/voice/vad/sweep.json 에 {"0.2": {"turns": n}, …} 로 적으세요(열쇠는 문자열).

정답 턴은 truth 의 turns 에 3개 있습니다. 턴이 그보다 많으면 말 중간의 쉼에서 끊은 것이고, 적으면 다른 차례까지 붙인 것입니다.

턴을 넘기기까지 기다린 시간

05 에서 턴 수가 정답과 같은 gap 가운데 가장 짧은 값을 gap_s 로 골라, 턴마다 (VAD 가 본 턴 끝 + gap_s) − 실제 턴 끝 을 per_turn_delay_s 에, 그 평균을 mean_delay_s 에 적은 /root/voice/vad/latency.json 을 만드세요.

턴이 끝났다고 판정하는 순간은 VAD 가 말 끝을 본 뒤 gap 만큼 더 조용했을 때입니다. 실제 턴 끝은 truth 의 turns 에 있습니다.

에코를 빼고 끼어든 시각 찾기

/opt/lab/fixtures/voice/bargein/mic.wav(사용자 + 스피커 에코)와 playback.wav(우리가 내보낸 소리)로, 앞 1.5초에서 상호상관이 가장 큰 지연(0~200 ms)과 최소제곱 배율을 구해 에코를 뺀 /root/voice/vad/residual.wav 를 저장하세요. /root/voice/vad/bargein.json 에 echo_delay_ms·echo_gain·naive_first_speech_s(원본 마이크에서 silero 가 처음 말로 잡은 시각)·barge_in_s(residual 에서 처음 말로 잡은 시각)를 적으세요.

지연 d 마다 np.dot(mic[d:], play[:n-d]) 를 재서 가장 큰 d 를 고릅니다. 배율은 g = (mic·ref)/(ref·ref). 사용자가 말하기 전 구간만 써야 사람 목소리가 추정을 흐리지 않습니다.

보고서

/root/voice/vad/report.json 에 energy_false_alarms·silero_false_alarms(score.json)·gap_s·mean_delay_s(latency.json)·turns_call_a(정답 턴 수)·barge_in_s(bargein.json)를 옮겨 적으세요.

앞 단계 파일을 읽어 그대로 옮기면 됩니다. 손으로 적으면 반올림이 달라져 어긋납니다.