[Rust/Tauri] 말 끝나면 알아서 꺼지는 음성 녹음 기능 구현하기

CharlieMoon·2026년 2월 5일
post-thumbnail

VAD & Trimming

최근 사내 프로젝트를 개발하면서 "사용자가 버튼을 누르고 말을 마친 뒤, 다시 버튼을 누르지 않아도 알아서녹음이 종료되는" 기능이 필요했다.

스포츠 현장이나 손이 바쁜 상황에서는 녹음 종료 버튼을 누르는 것조차 번거로운 일이기 때문이다. 이번 포스팅에서는 Rust 오디오 라이브러리인 cpalhound를 사용하여 VAD(Voice Activity Detection)오디오 트리밍(Trimming)을 구현과정을 공유한다.

사용한 라이브러리 (Crates)

  • cpal: Rust의 표준적인 로우레벨 오디오 입출력 라이브러리다. (WASAPI, CoreAudio, ALSA 등 지원)
  • hound: WAV 포맷의 인코딩/디코딩을 쉽게 처리해주는 라이브러리다.
    [dependencies]
    cpal = "0.15"
    hound = "3.5"

핵심 시나리오

  1. 녹음 시작: 사용자가 버튼을 누르면 녹음이 시작된다.
  2. 묵음 감지 (Auto-Stop): 사용자가 말을 하다가 멈추면(약 2초간 침묵), 시스템이 이를 감지하고 녹음을 자동 종료한다.
  3. 후처리 (Trimming): 자동 종료를 위해 기다렸던 2초간의 공백과, 말하기 전의 공백을 잘라내어 깔끔한 파일로 저장한다.

1. 묵음 감지 (Silence Detection) 구현

녹음을 계속 진행하면서 들어오는 오디오 샘플의 진폭(Amplitude)을 실시간으로 체크한다. 특정 임계값(threshold)보다 작은 소리가 일정 시간(max_silence_samples) 이상 지속되면 녹음을 중단하는 신호를 보낸다.

주요 코드 로직

  // 설정값
  let silence_threshold = 0.01; // 이 값보다 작으면 묵음으로 간주 (0.0 ~ 1.0)
  let sample_rate = config.sample_rate().0;
  let max_silence_samples = (sample_rate as usize) * 2; // 2초 분량의 샘플 수


  // 상태 공유를 위한 아토믹 변수
  let consecutive_silence = Arc::new(AtomicUsize::new(0));
  let stop_signal = Arc::new(AtomicBool::new(false));

  // ... (스트림 생성 코드 생략)


  // 샘플 처리 클로저
  move |data: &[f32], : &| {
      for &sample in data {
          // 절대값이 임계값보다 작으면 침묵 카운트 증가
          if sample.abs() < silence_threshold {
              let count = consecutive_silence.fetch_add(1, Ordering::Relaxed) + 1;

              // 2초 이상 침묵 시 정지 신호 발생
              if count >= max_silence_samples {
                  stop_signal.store(true, Ordering::Relaxed);
              }
          } else {
              // 소리가 감지되면 카운트 리셋
              consecutive_silence.store(0, Ordering::Relaxed);
          }

          // ... (채널로 데이터 전송)
      }
  }

이 로직 덕분에 사용자는 말을 끝낸 후 가만히 있으면, 약 2초 뒤에 녹음이 자동으로 종료된다.


2. 오디오 트리밍 (Trimming) 구현

VAD 방식으로 녹음을 종료하면 필연적으로 "말을 마친 후 2초간의 정적"이 파일 뒷부분에 포함된다. 이를 그대로 사용하여 재생 시 답답함을 유발하므로, 앞뒤의 묵음 구간을 잘라내야 한다.

단, 너무 칼같이 자르면 소리가 부자연스럽게 끊길 수 있으므로 0.2초 정도의 패딩(Padding)을 둔다.

  fn trim_silence(file_path: &Path) -> Result<f32> {
      // 1. 파일 전체를 읽어서 샘플 벡터로 변환
      let (spec, samples) = {
          let mut reader = hound::WavReader::open(file_path)?;
          let spec = reader.spec();
          let samples: Vec<f32> = reader.samples::<f32>().collect::<Result<Vec<>, >>()?;
          (spec, samples)
      let threshold = 0.01;

      // 2. 유효한 소리가 시작되는 지점과 끝나는 지점 찾기
      let start = samples.iter().position(|&s| s.abs() > threshold);
      let end = samples.iter().rposition(|&s| s.abs() > threshold);


      if let (Some(start_idx), Some(end_idx)) = (start, end) {
          // 3. 앞뒤로 0.2초씩 여유(Padding) 주기
          let padding_sec = 0.2;
          let padding_samples = (spec.sample_rate as f32 * padding_sec) as usize;


          let start_padded = start_idx.saturating_sub(padding_samples);
          let end_padded = (end_idx + padding_samples).min(samples.len() - 1);


          // 4. 해당 구간만 잘라서 다시 저장
          let trimmed_samples = &samples[start_padded..=end_padded];
          let mut writer = hound::WavWriter::create(file_path, spec)?;


          for &sample in trimmed_samples {
              writer.write_sample(sample)?;
          }
          writer.finalize()?;

          // 최종 재생 시간 반환
          return Ok(trimmed_samples.len() as f32 / spec.sample_rate as f32);
      }


      Ok(0.0) // 소리가 아예 없는 경우
  }

결과 및 장점

이러한 VAD + Trimming 구조를 적용하면 다음과 같은 이점이 있다.

  1. UX 향상 (Hands-Free): "녹음 시작" 버튼 한 번만 누르면 된다. 장갑을 끼고 있거나 화면을 계속 볼 수 없는 환경에 매우 유용하다.

  2. 데이터 효율성: 불필요한 공백을 제거하여 파일 용량을 줄이고, 전송 속도를 높인다.

  3. 명확한 의사 전달: 재생 시 딜레이 없이 즉각적으로 음성이 나오므로, 피치컴(PitchCom)과 같은 신속한 명령 전달 장치에 적합하다.

    주의할 점: Threshold 튜닝

    silence_threshold 값(여기서는 0.01)은 환경에 따라 튜닝이 필요하다.

  • 너무 낮으면: 주변 소음(바람 소리, 관중 소리) 때문에 녹음이 절대 안 끝날 수 있다.
  • 너무 높으면: 작게 말하는 소리나 'ㅅ, ㅎ' 같은 고주파 발음이 잘려 나갈 수 있다.

실제 환경 테스트를 통해 적절한 값을 찾거나, 사용자 설정으로 빼두는 것이 좋다.


참고: 이 코드는 Tauri 앱의 백엔드(Rust) 부분에서 동작하며, 프론트엔드와는 비동기 커맨드로 통신한다. 웹 브라우저 API(MediaRecorder)를 쓰지 않고 네이티브 오디오 장치를 직접 제어함으로써 더 정밀한 제어가 가능했다.

profile
산만한 개발자의 노트

0개의 댓글