Sandcorp샌드코프 · 웹·AI 제품 개발

새 프로젝트 받는 중 · 갱신 2026.10sandcorp.co.krGitHub

← 기록

사이드 프로젝트오디오FastAPIYAMNetWeb Audio

자는 동안 튀는 소리만 기록하기 — nightear 다섯 밤의 기록

밤에 무슨 소리에 깨는지 알고 싶어서 브라우저 탭 하나와 Docker 백엔드로 수면 소음 기록기를 만들었습니다. AI 라벨 1순위가 거의 다 「Silence」였던 문제와, PC를 안 쓰는 시간을 잠든 시간으로 세던 문제를 고치며 다섯 밤을 기록한 과정을 적었습니다.

왜 만들었나

일하는 방에서 잠도 잡니다. 밤에 어떤 소리 때문에 깨는 것 같은데, 그게 언제 무슨 소리인지 알 방법이 없었습니다. 매일 밤 녹음 앱을 켜고 끄는 건 사흘이면 잊어버리고, 여덟 시간짜리 녹음 파일을 다 들어볼 수도 없습니다. 하루 종일 녹음하면 낮의 통화와 대화까지 남습니다.

그래서 조건을 셋으로 정했습니다.

  • 켜 두기만 하면 내가 잠든 것을 스스로 알아채고 그때만 녹음한다.
  • 주변 소음보다 확 튀는 소리의 앞뒤만 짧은 클립으로 남긴다.
  • 오디오는 방 밖으로 나가지 않는다. 분류도 PC 안에서 한다.

nightear는 이 조건대로 만든 기록기입니다. 브라우저 대시보드 탭이 마이크를 잡아 오디오를 흘려보내고, Docker로 띄운 FastAPI 백엔드가 감지·클립·라벨·저장을 맡습니다. 스펙 하나를 티켓 10개로 나눠 하루 만에 구현했습니다. 그 뒤 다섯 밤을 기록하면서 두 가지를 고쳤습니다. 하나는 첫날 밤 라벨이 거의 다 「Silence」였던 문제이고, 다른 하나는 「잠들었다」는 첫 번째 조건이 사실은 지켜지지 않고 있던 문제입니다.

소리가 클립이 되기까지

  1. 원음 받기

    getUserMedia에서 노이즈 제거·에코 캔슬·자동 게인을 모두 끈다. 바로 그 소음이 지워지면 안 되니까

    브라우저
  2. 16kHz로 줄여 보내기

    AudioWorklet이 48kHz를 16kHz 16-bit 모노로 줄여 100ms마다 WebSocket으로 보낸다

    /ws/ingest
  3. 기준선과 비교

    프레임마다 dBFS를 구하고, 최근 5분 레벨의 중앙값(기준선)보다 10dB 넘게 큰 소리가 0.2초 이어지면 이벤트

    백엔드
  4. 수면 모드 판단

    밤 시간대(22~12시)이고, PC 입력이 20분 넘게 없으면 수면 모드. 입력이 생기면 바로 해제

    IdleDetector
  5. 클립 저장

    수면 모드일 때만 앞 10초 + 이벤트 + 뒤 5초를 WAV로 남긴다. 아니면 시각·크기만 기록

    링버퍼
  6. 로컬 라벨링

    클립에서 이벤트 구간만 잘라 YAMNet(AudioSet 521 클래스)을 onnxruntime으로 돌리고, Silence를 뺀 상위 3개 라벨을 붙인다

    PC 안에서만
  7. 자는 동안 추정

    수면 모드 안에서 숨소리 라벨이 이어지기 시작한 시각부터를 「자는 동안」으로 본다. 통계·아침 요약은 이 구간만 센다

    라벨 흐름

감지 규칙 (기본값, 모두 설정으로 바꿀 수 있음)

규칙기본값이유
기준선최근 5분 레벨의 중앙값낮과 밤의 바닥 소음 차이에 저절로 맞춘다. 평균이 아니라 중앙값이라 큰 소리 하나에 끌려가지 않는다
트리거기준선 +10dB, 0.2초 이상, 절대 하한 -60dBFS아주 조용한 밤의 미세한 소리까지 이벤트가 되지 않게
병합10초 안에 다시 튀면 같은 이벤트코골이·발소리가 수백 개로 쪼개지지 않게
클립 길이앞 10초 + 이벤트 + 뒤 5초, 최대 2분큰 소리 직전의 단서와 끝나는 모양까지 듣게
라벨 구간이벤트 앞뒤 0.5초(최소 1초), Silence 제외 상위 3개클립의 조용한 앞뒤 여유가 라벨을 덮지 않게
보관클립 30일, 별표는 계속. 시각·크기·라벨은 영구디스크는 아끼고 몇 주짜리 패턴은 남게
  • 기준선은 정렬 리스트에 bisect로 넣고 빼서, 프레임(100ms)마다 중앙값을 바로 꺼냅니다.

부딪힌 문제와 해결

1. AI 라벨의 1순위가 거의 다 「Silence」였다

첫날 밤 이벤트는 491건, 그중 수면 모드에서 생겨 클립이 남은 것이 456건이었습니다. 1순위 라벨을 세어 보니 Silence가 362건, 확신도는 대부분 100%였습니다. 소리가 작았던 것도 아닙니다. Silence로 분류된 클립도 기준선보다 중앙값 17.4dB 높았고, 다른 라벨이 붙은 클립(18.9dB)과 별 차이가 없었습니다. 실제 소리는 2순위에 있었습니다(Speech 130건, Snoring 99건, Breathing 56건 순).

원인은 점수를 모으는 방식이었습니다. YAMNet은 0.96초 창마다 521개 클래스 점수를 냅니다. nightear는 클립 전체에서 클래스마다 가장 높았던 점수를 그 클립의 점수로 썼습니다. 그런데 클립에는 앞 10초와 뒤 5초를 일부러 붙여 두었으니 조용한 창이 반드시 들어 있습니다. 조용한 창 하나가 Silence에 100%를 주면 그 클립의 1순위는 늘 Silence가 됩니다.

해결은 두 단계였습니다. 먼저 이벤트 구간(앞뒤 0.5초, 최소 1초)만 잘라 분류기에 넘겼습니다. 구간을 자르는 일은 라벨 작업자가 하고 분류기 포트 classify(pcm, sample_rate)는 그대로 두었습니다. 그런데도 Silence가 261건 남았습니다. 10초 안에 다시 튄 소리를 한 이벤트로 합치다 보니, 이벤트 안에도 조용한 틈이 있었기 때문입니다. 그래서 Silence를 결과 순위에서 아예 뺐습니다. 이미 붙은 라벨은 POST /api/labels/relabel로 다시 매겼습니다.

첫날 밤 클립 456개 1순위 분포
고치기 전 Silence 362 / Speech 76 / Breathing 4 / …
이벤트 구간만 자름 Silence 261 / Speech 124 / …
+ Silence 제외 Speech 207 / Snoring 91 / Breathing 56 / Inside, small room 44 / …

2. 「수면 모드」는 「잠들었다」가 아니었다

라벨을 고친 뒤 두 밤을 더 기록했습니다. 이벤트는 901건과 597건, 클립은 846개와 502개였습니다. 그런데 1순위의 절반 이상(1,348개 중 735개)이 Speech였습니다. Speech의 확신도 중앙값이 0.99라서 오분류로 보기는 어렵습니다.

시간대별로 나눠 보니 모양이 뚜렷했습니다. 수면 모드가 시작된 뒤 앞쪽 몇 시간은 말소리가 대부분이고, 어느 시점부터 Breathing·Snoring으로 바뀝니다. 초반 클립에는 Typing도 섞여 있었습니다. 실제로 그 시간에는 깨어서 휴대폰이나 영상을 보고 있었습니다.

원인은 판단 기준 자체였습니다. nightear는 「밤 시간대에 PC를 20분 넘게 안 쓰면」 수면 모드로 봅니다. PC에서 손을 뗐다고 잠든 건 아니어서, 클립의 절반 이상이 잠들기 전 소리였고 아침 요약과 패턴 통계도 그만큼 오염됐습니다. 유휴 기준을 20분에서 더 늘리는 것은 답이 아니었습니다. 말소리 구간이 몇 시간씩 이어졌기 때문입니다.

해결은 거꾸로 이 라벨 흐름을 쓰는 것이었습니다. 수면 모드는 그대로 두고(클립은 계속 저장), 그 안에서 「자는 동안」을 따로 추정합니다.

  • 수면 모드 구간이 시작될 때는 깨어 있다고 봅니다. 직전까지 PC를 썼으니까요.
  • 1순위가 숨소리(Snoring·Breathing·Snort)인 클립이 20분 안에 5개 이상이고 말소리·키보드 클립보다 많아지면, 그 첫 클립 시각에 잠든 것으로 봅니다.
  • 반대로 말소리·키보드가 같은 조건으로 이어지면 다시 깨어 있는 것으로 봅니다. 밤중에 잠깐 깬 구간이 이렇게 빠집니다.
  • 숨소리가 거의 없는 밤은 「추정 불가」로 표시하고 예전처럼 수면 모드 전체를 씁니다.

통계·아침 Discord 요약·패턴 통계는 이제 「자는 동안」만 셉니다. 깨어 있던 때의 클립은 지우지 않고 대시보드에서 흐리게 보여 줍니다. 추정이 틀렸을 때 그 클립을 다시 들어 볼 수 있게 하려는 것입니다.

밤 수면 모드 클립 자는 동안 클립 자는 동안 1순위
둘째 밤 846 291 Breathing 141 / Snoring 71 / Speech 33 / …
셋째 밤 502 150 Breathing 108 / Snoring 23 / Speech 2 / …
넷째 밤 193 112 Breathing 73 / Snoring 16 / Speech 4 / …
다섯째 밤 382 178 Snoring 77 / Breathing 69 / Speech 12 / …

넷째·다섯째 밤은 이 기능을 넣은 뒤에 기록한 밤입니다. 다섯째 밤은 잠든 뒤 두 번 깨어 있던 구간이 따로 빠졌습니다.

3. 깨어난 뒤에도 수면 모드가 조금 이어졌다

첫날 아침, 수면 모드가 풀리기 전 4분 동안의 클립에 Typing·Computer keyboard 라벨이 붙어 있었습니다. 그 뒤에도 키보드 소리가 난 지 2분 30초쯤 뒤, 45초 뒤에 풀린 일이 있었습니다.

백엔드 로그를 보면 「idle → active」를 받은 시각과 해제 시각은 같습니다. 백엔드는 신호를 받자마자 풀었고, 그 전에는 신호가 오지 않았습니다.

처음에는 잠금 화면을 의심했습니다. 브라우저의 IdleDetector는 화면이 잠겨 있으면 locked를 내고, nightear는 이를 유휴로 칩니다. 그런데 로그 전체에 locked가 한 번도 없었습니다. 상태 변화는 active와 idle뿐이었습니다. 그래서 이 가설은 버렸습니다.

대신 브라우저가 무엇을 봤는지 남기게 했습니다. 활동 메시지에 IdleDetector의 원래 값(userState·screenState), 브라우저가 변화를 받은 시각, 탭이 보이는지·포커스가 있는지, 탭 안 마지막 입력 시각을 같이 보내고, 백엔드가 그것을 로그로 남깁니다.

그 뒤 두 아침을 보면, 키보드 소리에서 해제까지 한 번은 20초, 한 번은 1분 넘게 걸렸습니다. 두 번 다 깬 직후 이 PC의 키보드를 만진 소리였습니다. 그런데 해제 순간의 로그는 두 번 다 change=0.0s전 sent=0.0s전이었습니다. 브라우저는 변화를 받자마자 보냈고, 백엔드도 바로 처리했다는 뜻입니다. 늦어지는 곳은 그 앞, PC에 입력이 들어간 뒤 IdleDetector가 「active」를 알리기까지입니다. 브라우저가 OS의 유휴 상태를 언제 다시 읽는지는 아직 확인하지 못했습니다.

4. 가짜 분류기는 받은 소리를 보지 않았다

시험은 수집 WebSocket에 hello 메시지와 합성 PCM(잡음 바닥, 특정 시각의 버스트, 무음)을 흘려 넣고, 결과를 HTTP API와 실시간 채널 메시지로만 확인합니다. 시계는 가짜로 주입해 「20분 유휴」 「30일 경과」 「밤 시간대 종료」를 기다리지 않고 넘깁니다. 분류기와 Discord 발송기도 포트로 두고 가짜로 바꿔 끼웁니다. 그래서 감지 엔진 내부를 몇 번 고쳐도 시험은 그대로였습니다.

하지만 1번 문제는 이 시험으로 잡히지 않았습니다. 가짜 분류기가 정해 둔 라벨을 돌려줄 뿐, 무엇을 받았는지는 보지 않았기 때문입니다. 이제 가짜 분류기는 받은 PCM의 길이와 RMS를 기록하고, 시험은 「이벤트 구간만 넘어갔는지」 「짧은 이벤트도 1초 이상 넘어갔는지」를 확인합니다. 실제 YAMNet으로 도는 스모크 시험에도 「앞 15초·뒤 20초가 조용한 클립에서 Silence가 라벨에 없다」를 넣었습니다.

2번을 고칠 때는 같은 가짜 분류기가 시각별로 정해 둔 라벨(앞쪽 Speech, 뒤쪽 Snoring)을 돌려주게 해서, 추정한 잠든 시각·깨어 있던 구간·「추정 불가」를 시험했습니다.

남은 한계

  • 「자는 동안」은 소리로 추정한 값입니다. 숨소리가 거의 잡히지 않는 밤에는 추정하지 못하고 수면 모드 전체로 돌아갑니다. 「20분 안에 5개」라는 기준도 몇 밤 기록만 보고 정한 값입니다.
  • 자는 동안에도 Speech가 조금 남습니다(다섯 밤 944건 중 76건).
  • 깬 뒤 PC 키보드를 만져도 수면 모드가 20초~1분 넘게 늦게 풀립니다. 브라우저가 입력을 늦게 알린다는 것까지는 확인했지만, 왜 늦는지는 아직 모릅니다(위 3번).
  • 수집이 브라우저 탭에 달려 있습니다. 탭이 닫히면 멈춥니다. 끊긴 구간은 「기록 없음」으로 따로 남아 조용한 밤과 구분되고, 수집 프로토콜이 WebSocket 하나라 Windows 네이티브 수집기 같은 다른 소스를 붙일 수는 있습니다.
  • 유휴 감지는 Chromium 계열 브라우저에서만 됩니다. 다른 브라우저에서는 밤 시간대만으로 수면 모드를 판단합니다.
  • 아직 다섯 밤 치 기록뿐이라, 트리거 마진(+10dB)이 알맞은지는 더 지켜봐야 합니다.

문의 →