你睡著時只有兩件事在跑:一個 AAC 編碼器,和一個 20Hz 的特徵迴圈,後者只留下 10Hz 的音量包絡。就這樣。夜間不跑任何機器學習、不上傳任何東西,也不做任何偵測判斷——半夜只看得到一部分資料,這時做的判斷事後無法修正。早上 App 把整晚錄音從頭到尾過一次:包絡挑出有聲音的區段,Apple 的聲音分類器只讀這些區段,通過篩選的片段才會成為事件,附上音檔、音量與嚴重度。這一頁的每個數字都直接取自正式版的原始碼。
上面寫的是最初的設計。後來有了突破,現在 MoyiSnore 有兩種模式:整晚錄音——整晚只錄音,早上起來才一次跑完整晚的 ML,也就是本頁描述的做法;以及即時 ML——睡覺的同時就在跑 ML。
以下參數取自 App 原始碼(AudioPipeline、Screeners、EnvelopeGate、BatchProcessor、Theme、CLOUD_SYNC_SPEC)。模擬資料都有標示。
設計原則是:夜間階段要靠電池,在 iOS 背景撐過整整八小時。所以夜間只做加減乘除——整流、濾波、累加——凡是「事後看得更清楚」的判斷,全部留到早上;那時整晚的檔案都已存在手機裡,門檻設錯可以重跑一次。
AVAudioEngine tap,4800 frame 一批(48kHz 約 100ms)。工作階段用 .playAndRecord + .measurement,關掉系統 AGC 與 EQ,量測才乾淨。另外掛一個不出聲的 player node,讓錄音在來電、鬧鐘響過之後不會中斷。
50ms 一框,每秒 20 框。每框產出四個數字:整體 RMS、鼾聲帶 RMS、人聲帶 RMS、零交越率。兩條四階 Biquad 並行——鼾聲帶 40–400Hz,人聲帶 250–3000Hz。
每兩框取較大值,得到 10Hz 的音量軌——峰值不能被平均洗掉。每 30 秒寫入檔案一次。十小時約 1.2MB。
把錄音時已經算好的包絡掃過一次,找出活躍區段:門檻 = 第 20 百分位底噪 × 2.5,每段前後外擴 2 秒,相距不到 5 秒就併成一段。用相對底噪,冷氣很吵的旅館和安靜的臥室才都能正確運作。
SNAudioStreamAnalyzer 搭配 Apple 內建分類器,只送入活躍區段,並帶上它們在檔案裡的真實位置。鼾聲類 = {snoring, snort};說夢話類 = {speech, whispering, shout, singing, babbling}。
同類視窗間隔 ≤ 2 秒就合併,短於 0.5 秒丟棄。留下來的每一筆裁出前後各 1 秒的音檔、正規化到 0.9 峰值,並算出峰值 dB、平均位準與嚴重度。
整晚錄音完成任務後預設刪除——你保留的是事件片段。轉文字是按需的:你點哪一段才辨識那一段。
一排是一小時,一格是十秒。方格亮度是固定刻度上的音量,所以不同夜晚的排可以並排比較——多數睡眠 App 把每晚各自正規化到當晚最大值,結果每個夜晚看起來都一樣。在織帶上拖動,看看處理流程在那一刻看到什麼。這一夜是模擬資料;參數、配色與對應方式都和 App 相同。
這裡的音量是 dBFS(數位滿刻度)。App 另外呈現推估的 dB SPL,那取決於裝置與手機擺放位置——夜與夜之間一致,但不是校準過的噪音計。
這是把五分鐘的分析變成幾秒鐘的那個判斷。包絡很便宜、而且已經在硬碟上;閘門用它決定哪些部分值得交給耗時的分類器處理。拖動三個控制項,看活躍占比怎麼變。第 20 百分位底噪由資料自己決定、不由你決定——這正是相對門檻的意義。
包絡以 10Hz 繪製,依畫布寬度做 max pooling。高亮區段就是 SNAudioStreamAnalyzer 實際收到的內容,並帶著真實 frame 位置,所以視窗時間戳不需要換算。
靈敏度設定只是一個 1 到 10 的數字,它把最低信心門檻從 0.70 拉到 0.25——嚴到寬,預設 5 對應 0.50。更關鍵的是:分類器的結果要往下看到第幾名。旅館的冷氣會產生一個恆定類別、整晚霸佔第一名,把鼾聲擠到第二名。只讀第一名會丟掉整夜;App 掃 top-5,取第一個達到門檻的目標類別。
模擬視窗,信心值落在真實分類器的輸出範圍內。合併與最短長度用的是出貨規則:同類 2 秒內合併,短於 0.5 秒丟棄。
| 1 | white noise | 0.71 | — |
| 2 | mechanical fan | 0.63 | — |
| 3 | snoring | 0.58 | ← target ✓ |
| 4 | breathing | 0.31 | — |
| 5 | speech | 0.12 | — |
這是開著風扇的房間裡真實的 top-5。第一個達到門檻的目標類別勝出,不管它排第幾。
鼾聲是低頻主導,講話是中頻主導,所以夜間迴圈把兩條帶分開保留。它同時保留一個主導性判準:鼾聲要 low > mid × 2,人聲要 mid > low × 0.35。以真實 zh-TW 語音過管線量測,語音框只有約 23% 達到 low > mid × 2——這就是講話不會被記成鼾聲的原因。這些粗篩用在工程模式的量測面板,也影響了整個設計;但它們在夜間產出的事件是算完就丟的,因為真正的偵測器是早晨的分類器。
| rawRMS | 未濾波 RMS | 現場波形、織帶、包絡 |
| lowRMS | 40–400Hz RMS | 鼾聲粗篩、主導性判準 |
| midRMS | 250–3000Hz RMS | 人聲粗篩、主導性判準 |
| zcr | 零交越次數 ÷ 框長 | 像不像人聲,0.006–0.35 |
| 自適應底噪 | EMA,α = 0.002,只在未觸發時更新——如果事件進行中也更新,門檻會一路被拉高,最後把事件本身吃掉。 |
| 觸發門檻 | max(底噪 × 倍率, 0.0009)。絕對下限約 −61 dBFS,死寂房間不會被自己的底噪抖動觸發。 |
| 遲滯 | 釋放門檻 = 觸發 × 0.6,且需維持 1.2 秒——鼾聲一陣一陣,中間有換氣空隙。 |
| 啟動 | 鼾聲需連續 4 框(200ms),人聲需 6 框(300ms)。 |
| 事件長度 | 鼾聲 0.5–30 秒,說夢話 0.4–20 秒。結尾取最後一個高於釋放門檻的框,不是等待結束的那一刻。 |
| 持續噪音防護 | 一次鼾聲事件不可能持續 8 秒——換氣會結束它。連續 8 秒沒結束就判定是風扇或冷氣:底噪學習到觀測峰值的 90%,該段丟棄。 |
| 靈敏度映射 | 1 → 5.0×,10 → 1.5×,線性內插;預設 5 得到高於底噪 3.4×。 |
| 平台 | iPhone 與 iPad,iOS 17.6 或以上 |
| 音訊工作階段 | .playAndRecord、mode .measurement——關閉 AGC 與 EQ |
| Tap buffer | 4800 frame,48kHz 約 100ms |
| 整晚錄音 | AAC m4a,裝置原生取樣率,quality medium,約 200MB/晚 |
| 分框 | 50ms 一框,20 fps |
| 濾波帶 | 鼾聲 40–400Hz、人聲 250–3000Hz,各為四階 Biquad |
| 包絡 | 10Hz,每 2 框取 max,每 30 秒落地,10 小時約 1.2MB |
| 原始環形緩衝 | 45 秒音訊,供抽取片段 |
| 靜音閘門 | 第 20 百分位底噪 × 2.5,外擴 2s,合併 5s,活躍 70% 即退回整檔 |
| 分類器 | SNClassifySoundRequest,identifier .version1,裝置端 |
| 信心門檻範圍 | 0.70(靈敏度 1)到 0.25(靈敏度 10),預設 0.50 |
| 類別集合 | snoring, snort / speech, whispering, shout, singing, babbling |
| 事件規則 | 同類 2 秒內合併,短於 0.5 秒丟棄 |
| 音檔片段 | 事件前後各 1 秒,正規化到 0.9 峰值 |
| 嚴重度 | 0–100:0.6 × 響度(−50 → −10 dBFS)+ 0.4 × 持續時間(0 → 20s) |
| 轉文字 | zh-TW,requiresOnDeviceRecognition = true,30 秒超時,按需執行 |
| 新引擎 | 若已安裝則優先用 iOS 26 SpeechTranscriber,全程裝置端 |
| 同步 | CloudKit 私有資料庫,自訂 SessionsZone,CKSyncEngine + CKOperation |
| 網路呼叫 | 整個專案沒有任何一處 |
| 主題 | 29 套預設,每套含深色與淺色兩份配色 |
| 織帶方格 | 一格 10 秒,預設絕對刻度 |
| 時間軸縮放 | 第 95 百分位正規化,單一巨響不會把整夜壓扁 |
我們沒有自己的伺服器,也沒有帳號系統。同步用的是你自己 Apple ID 底下的 CloudKit 私有資料庫:事件、時間等資料透過 CKSyncEngine 同步,音檔則個別上傳下載,所以事件片段可以等你按播放時才下載。整晚錄音從不上傳,分析完就在手機上刪除。沒有網路不算錯誤:iCloud 無法使用時,App 就是一個純本機的 App,網路狀態也永遠不會擋住你開始錄音。
| session.json — 事件、時間、狀態 | 數十 KB | iCloud 與本機都會保留 |
| envelope.bin — 10Hz 整晚包絡 | 約 1.2MB | iCloud 與本機都會保留 |
| clips/evt-* — 事件音檔 | 數 MB | 存在 iCloud;本機副本可清掉,播放時再下載 |
| audio.m4a — 整晚原始錄音 | 約 200MB | 從不上傳,分析完就刪除 |
| 帳號、email、識別碼 | — | 不收集,沒有東西可送 |
| 分析工具、廣告、崩潰回報 SDK | — | 完全沒有 |
用一筆 CloudKit 記錄當作錄音鎖,以 compare-and-swap 取得,並每 5 分鐘送一次心跳續約(租約 15 分鐘)——裝置沒電或當機時,永遠沒機會執行「釋放」,所以沒有心跳的鎖,只要出一次意外就會永遠卡死。而且這個鎖只是為了避免兩台同時錄,不是硬性限制:檢查失敗或離線時照常開錄。兩段重疊的 session 就是兩個 UUID,兩筆都留。
MoyiSnore 不是醫療器材。它記錄與量測聲音,不偵測呼吸中止或任何疾病。一份客觀、有時間戳的紀錄適合帶去給醫師參考——但不能取代醫師。
手機無法分辨聽到的是誰的鼾聲。同床時,請把報告讀成「這個房間」的紀錄。
音量取決於距離,以及手機放在什麼東西上。每晚放在同一個位置,才讓夜與夜之間可以比較。
App 用的是 Apple 內建的聲音分類器,不是專門用鼾聲訓練的模型。這點我們不隱瞞:每個事件都保留它的類別、信心值與音檔,任何一筆你都能自己核對。
電風扇、路過的車、身旁的人翻身。持續噪音防護與信心門檻能濾掉大部分,但不是全部。
參考文獻只作為設計依據;App 不提出任何醫療上的主張。