Skip to content

Latest commit

 

History

History
854 lines (700 loc) · 208 KB

ARCHITECTURE.md

File metadata and controls

854 lines (700 loc) · 208 KB

SoundVisualizer 아키텍처

폰 안에서 재생되는 소리가 캡처 → AI 분류 → 화면 표시·진동으로 이어지는 흐름과, 각 단계를 맡은 코드와 스레드를 정리한 문서입니다. 기능 소개와 빌드 방법은 README를 참고하세요.

전체 흐름

graph TD
    A["폰 내부 오디오<br/>게임 · 미디어"] -->|"MediaProjection<br/>AudioPlaybackCapture"| B["AudioCaptureService<br/>캡처 스레드"]
    B -->|"direct ByteBuffer<br/>복사 없음"| C["AudioEngine · native-lib.cpp<br/>좌우 피크 측정 (C++)"]
    B -->|"모노로 합쳐 복사"| D["AiAudioBuffer<br/>AI용 링버퍼"]
    D -->|"250ms 간격"| E["RealtimeAiPipeline<br/>16kHz 변환 → Qualcomm Log-mel → YAMNet<br/>→ 안전 신호·후처리"]
    C -->|"readPeaks"| F["VisualizerEngine<br/>8방향 합성 · 스무딩 · 도형"]
    E -->|"AiClassification.coarse"| F
    F --> G["OverlayService<br/>화면 가장자리 오버레이"]
    C -->|"takeHapticPeak"| H["HapticNotifier<br/>진동 알림"]
    E -->|"lastClassification"| H
Loading
단계 코드 언어
홈·설정·도움말 화면 MainActivity (액티비티), LauncherApp·HomeTab·SettingsTab·ClassifyTab·ColorPickerDialog·UiControls·UiColors·UiFonts (화면), EasySettingsView·EasyIcons·EasySize (설정 탭의 쉬운 설정, #408), SoundCatalog·SoundGroups (분류 탭의 소리 목록과 묶음), SettingsManager, help/, language/ (앱 언어), tutorial/ (처음 열 때의 튜토리얼, 쉬운 설정의 모양 카드 그림) Kotlin (Compose)
켜기·끄기 VisualizerController, tile/ (빠른 설정 타일), PendingStart (권한을 켜고 돌아오면 이어서 켜기), CaptureStartToken (사용자가 켠 시작만 받고, 어디서 받을지 전하기), StopReason·StopAlert (꺼짐 알림) Kotlin
캡처 AudioCaptureService, CaptureSource (폰 안의 소리인지 마이크인지), ScreenOffPause (화면 꺼짐 일시정지), BlockedCaptureNotice (받을 수 없는 소리 안내), MicSilenceNotice (마이크가 막혔다는 안내), NotificationActionReceiver (실행 중 알림 버튼) Kotlin
좌우 피크 측정 AudioEngine, cpp/native-lib.cpp C++ (JNI)
AI 분류 ai/ Kotlin + ONNX Runtime
분류 결과 연결 AiClassification Kotlin
오버레이 OverlayService, VisualizerOverlay, VisualizerEngine, VisualizerInputs, VisualMode, SoundWakeSignal (쉬는 동안 소리로 깨우기), OverlayVisibility (화면 꺼짐·잠금 화면에 가려졌는지) Kotlin (Compose Canvas)
진동 feedback/ Kotlin

1. 시작과 종료

시작과 종료는 앱의 버튼과 빠른 설정 타일이 같은 코드(VisualizerController)를 씁니다. 권한 목록(requiredPermissions), 서비스 시작(start), 종료(stop)가 여기 있습니다. 권한을 묻고 거부됐을 때 안내하는 흐름과 그 안내 창(CapturePermissionFlow)도 함께 씁니다.

앱에서 시작 (MainActivity)

  1. 다른 앱 위에 표시 권한(SYSTEM_ALERT_WINDOW)을 확인하고, 없으면 먼저 안내 창을 띄운 뒤 설정 화면으로 보냅니다. 그 화면은 기기에 따라 앱 목록만 뜨고 어느 앱을 켜야 하는지 알려주지 않아서, 설명 없이 보내면 처음 쓰는 사용자가 그냥 나가기 쉽습니다.
    • 이 화면은 결과를 돌려주지 않으므로, 허용하고 돌아온 것은 onResume에서 Settings.canDrawOverlays로 확인해 눌렀던 실행을 이어갑니다. onResume은 앱을 열 때마다·화면을 돌릴 때마다·다른 앱에서 돌아올 때마다 불리므로, 사용자가 실행을 눌러 설정 화면으로 보내진 경우에만 한 번 이어갑니다(PendingStart). 이 표시는 화면 회전처럼 설정이 바뀌어 다시 만들어질 때만 onRetainCustomNonConfigurationInstance로 넘기고, 저장 번들(onSaveInstanceState)에는 넣지 않습니다. 넣으면 프로세스가 죽은 뒤 한참 있다 앱을 열었을 때도 남아 누르지도 않은 실행이 이어지고, 외부 사운드 모드라면 동의 창 없이 마이크가 열립니다. 허용하지 않고 돌아왔거나 실행 종료를 누르면 버립니다. 이 규칙은 안드로이드에 의존하지 않아 JVM에서 검사합니다(PendingStartTest).
  2. 녹음(RECORD_AUDIO)과 알림(POST_NOTIFICATIONS, Android 13 이상) 권한을 요청합니다. 알림 권한은 거부해도 이어서 켭니다.
    • 녹음 권한이 필요하면 시스템 창보다 먼저 이유를 설명하는 창을 띄웁니다. 시스템 창에는 "마이크"라고만 떠서 녹음 앱으로 오해하고 거부하기 쉽기 때문입니다. 알림 권한만 필요하면 바로 묻습니다.
    • 녹음 권한이 거부됐는데 shouldShowRequestPermissionRationale이 false면 시스템이 더는 창을 띄우지 않는 상태로 보고, 앱 정보 화면(ACTION_APPLICATION_DETAILS_SETTINGS)을 여는 안내 창을 띄웁니다. 그 밖의 거부는 토스트로 알리고 멈춥니다.
    • 안내 창 상태는 액티비티의 저장 상태(SavedStateRegistry)에 두어 화면을 돌려도 남습니다. 창을 다시 그릴 뿐 권한 요청이나 동의 창을 다시 띄우지 않습니다.
  3. 화면 녹화 동의 창(MediaProjection)을 띄웁니다. 오디오 캡처에도 이 동의가 필요합니다. 홈에서 외부 사운드 모드를 켜 두었으면 이 창 없이 바로 4로 갑니다(2장 외부 사운드 모드).
  4. 동의하면 AudioCaptureService(포그라운드 서비스)와 OverlayService를 함께 시작합니다(VisualizerController.start, 마이크는 startMicrophone).
    • 서비스를 띄우기 바로 앞에서 시작 표(CaptureStartToken)를 발급합니다. 서비스의 onCreate는 인텐트를 보기 전에 포그라운드를 시작해야 해서(Android 14 이상은 캡처를 열기 전에 포그라운드가 떠 있어야 함) 누가 띄웠는지 모릅니다. 그래서 표를 한 번 쓰고, 표가 없거나 10초보다 오래됐으면 사용자가 켠 것이 아니라고 보고 포그라운드도 캡처도 시작하지 않고 내립니다. 켜진 적이 없으니 꺼짐 알림도 없습니다.
    • 표에는 어디서 소리를 받을지(CaptureSource)도 실어 보냅니다. 포그라운드 타입은 onCreate에서 정해야 하는데, 거기서 설정을 다시 읽으면 켜기를 누른 쪽이 동의 창을 건너뛸지 정한 값과 어긋날 수 있기 때문입니다.
    • 표는 프로세스 메모리에만 둡니다. 프로세스가 새로 떠서 서비스를 만든 경우(옛 알림 버튼)에는 표가 있을 수 없습니다. 10초는 시스템이 startForegroundService 뒤에 포그라운드 시작을 기다려 주는 시간과 같습니다. 표를 받지 못한 서비스가 포그라운드 없이 내려가면 시스템이 앱을 죽이는데, startForegroundService로 띄우는 곳은 표를 발급한 직후뿐이라 그럴 일이 없습니다.
    • 판단 규칙은 CaptureStartTokenTest가, 표 없이 띄운 서비스가 실제로 걸러져 내려가는지는 계측 테스트(UnrequestedStartInstrumentedTest)가 봅니다.

빠른 설정 타일에서 시작 (tile/VisualizerTileService → tile/StartVisualizerActivity)

  • 권한 팝업과 동의 창은 액티비티에서만 띄울 수 있어서, 타일은 투명 화면을 열고 알림창을 접습니다. 잠금 화면이면 잠금 해제를 먼저 요구합니다(unlockAndRun).
  • 투명 화면은 위 2~4단계를 그대로 밟고 닫힙니다. 권한 안내 창도 이 화면 위에 그리고, 취소하거나 설정 화면으로 보내면 바로 닫힙니다. 오버레이 권한만은 설정 화면이 필요해 앱을 열어 안내합니다.
  • 투명 화면은 taskAffinity=""로 앱과 다른 작업에 뜹니다. 그래서 앱이 백그라운드에 있어도 앱 화면이 올라오지 않고, 닫히면 보던 게임·영상으로 돌아갑니다.
  • 타일은 알림창이 열려 있는 동안 SettingsManager.isServiceRunning을 구독해 켜짐·꺼짐을 표시합니다. 추가·제거될 때는 SettingsManager.tileAdded에 기록해 설정 탭의 빠른 설정 카드가 "빠른 설정에 추가" 버튼이나 "추가되어 있음"을 보입니다.
  • 설정 탭의 "빠른 설정에 추가"(자세한 설정에서는 소리 분류와 배터리 사이, #308. 예전에는 홈에 있다가 #245 에서 설정 탭 맨 위로 옮겼습니다. 쉬운 설정에서는 맨 끝에 설명 없이 제목과 버튼만 둡니다, #408)는 시스템의 추가 창(StatusBarManager.requestAddTileService)을 띄웁니다. 이미 있으면(TILE_ALREADY_ADDED) 추가된 것으로 기록하고, 추가되지 않은 결과(TILE_NOT_ADDED, 요청 실패)는 직접 추가하는 방법을 토스트로 알립니다. 사용자가 이 창에서 세 번 거절하면 시스템이 그다음부터는 창 없이 바로 거절을 돌려주는데, 그대로 두면 버튼을 눌러도 아무 일도 일어나지 않기 때문입니다.

실행 중 알림 (AudioCaptureService.createNotification)

  • 접힌 알림에는 상태 문구와 중지 버튼(ACTION_STOP)이 있고, 제목 옆에 지금 표현 모드 이름을 둡니다(setSubText). 접힌 상태에서는 아래 칩이 보이지 않기 때문입니다.
  • 펼치면 상태 한 줄 아래에 모드 칩 네 개가 나옵니다(res/layout/notification_modes.xml, DecoratedCustomViewStyle). 알림 기본 버튼(addAction)은 보통 세 개까지만 보여서 모드 넷과 중지가 들어가지 않습니다. 본문만 직접 그리고 머리말과 중지 버튼은 시스템이 그립니다.
  • 버튼은 서비스가 아니라 NotificationActionReceiver로 보냅니다(PendingIntent.getBroadcast). 서비스로 보내면 서비스가 떠 있지 않을 때 시스템이 새로 만듭니다. 그 서비스는 사용자가 켠 것이 아니라서 아무것도 하지 않고 내려가므로(1장 시작 표) 남은 알림을 지울 곳이 없습니다. 시작 표가 생기기 전에는 이 경우 onCreate가 화면 녹화 동의 없이 mediaProjection 포그라운드를 시작하다 SecurityException으로 죽었습니다(Android 14 이상). 받는 쪽은 떠 있는 서비스(instance)가 있을 때만 전하고, 없으면 서비스보다 오래 남은 알림으로 보고 그 알림만 지웁니다. 리시버와 서비스의 onCreate·onDestroy는 모두 메인 스레드라 그 사이에 끼어들지 않습니다. 누른 사람이 기다리므로 포그라운드 방송(FLAG_RECEIVER_FOREGROUND)으로 보냅니다. 인텐트를 명령(Stop·SetMode·Ignore)으로 바꾸는 규칙은 NotificationCommand로 떼어 JVM에서 검사하고(NotificationCommandTest, 리시버가 매니페스트에 exported=false로 선언됐는지 포함), 서비스 없이 눌렀을 때 서비스가 뜨지 않고 알림이 지워지는지는 계측 테스트(NotificationActionReceiverInstrumentedTest)가 봅니다. 걸러진 서비스는 실행 중으로 표시되지 않으므로, 떴는지는 걸러진 횟수(AudioCaptureService.unrequestedStartCount)로 봅니다.
  • 이전 버전(v1.4.0 이하)은 버튼을 서비스로 보냈습니다. 그때 올라간 알림이 남아 있다가 눌리면, 서비스가 떠 있지 않을 때는 새로 뜬 서비스가 시작 표가 없어 아무것도 하지 않고 내려갑니다(Android 14 이상에서도 죽지 않습니다). 떠 있을 때는 동작 이름이 있는 인텐트가 시작 요청이 아니므로 무시합니다. 시작 요청으로 읽으면 동의 결과가 없어 "켜지 못했다"고 잘못 알립니다.
  • 칩은 ACTION_SET_MODE(모드 순서 번호)를 보냅니다. 캡처와 AI는 건드리지 않고 설정만 바꾸며, 오버레이는 프레임마다 모드를 읽으므로 바로 바뀝니다. 서비스는 SettingsManager.visualMode를 지켜보다가 어디서 바뀌든(설정 화면이든 칩이든) 알림을 고쳐 답니다. 모르는 번호가 오면 무시합니다(VisualMode.fromOrdinal).
  • Android 12 이상은 칩이 라디오 버튼(layout-v31)이라, 고른 칩 표시를 알림을 그리는 쪽이 바로 옮깁니다. 알림을 다시 올려서 옮기면 시스템이 다시 그리는 데 약 0.5초가 걸려 눌리지 않은 것처럼 보입니다. 한 칩이 켜지면 꺼진 칩도 함께 알려 오므로 RemoteViews.EXTRA_CHECKED가 켜짐인 것만 받습니다. 받지 않으면 방금 끈 모드가 뒤늦게 덮어써 원래 모드로 돌아갑니다. 이 값을 채울 수 있게 칩의 PendingIntent는 FLAG_MUTABLE이지만, 받을 곳을 지정한 인텐트라 바뀔 수 있는 것은 그 값뿐입니다.
  • Android 11 이하는 칩 배경과 글자색을 직접 넣고, 알림을 다시 올릴 때 표시가 옮겨갑니다.
  • 칩마다 PendingIntent 요청 번호를 다르게 둡니다(REQUEST_MODE_BASE + 순서 번호). 같으면 하나로 합쳐져 네 칩이 모두 같은 모드를 켭니다. 칩 자리 수가 모드 수와 같은지는 VisualModeOrdinalTest가 봅니다.
  • 켜진 칩은 색만으로 알리지 않습니다. Android 11 이하는 화면 읽어주기에 "선택됨"을 덧붙이고(notification_mode_selected), 12 이상은 라디오 버튼이 선택 상태를 스스로 알리므로 이름만 둡니다. 덧붙이면 "선택됨"이 두 번 읽힙니다(#300).
  • 칩 색은 알림 배경(기기·테마마다 다름)과 상관없이 읽히도록 칩을 진한 색으로 채우고 글자를 밝게 둡니다. 고른 칩은 앱의 강조색입니다.

종료: 아래 경우 모두 캡처·오버레이·AI·진동을 함께 내립니다. 멈춘 이유(StopReason)에 따라 사용자에게 알릴지가 갈립니다.

경우 이유 알림
앱의 실행 종료 버튼, 켜진 상태에서 타일 누르기 (VisualizerController.stop → stopService) UserRequested 없음
실행 중 알림의 중지 버튼(ACTION_STOP) UserRequested 없음
다른 앱이 화면 녹화·공유·전송을 시작했거나, 사용자가 시스템 UI에서 캡처를 끈 경우(MediaProjection.Callback.onStop) ProjectionStopped 있음
오디오 서버 재시작 등으로 캡처 읽기가 실패했거나, 화면을 다시 켰는데 녹음을 다시 시작하지 못한 경우 CaptureError 있음
동의까지 받았는데 캡처를 시작하지 못한 경우 StartFailed 있음
캡처는 시작했는데 오버레이를 띄우지 못한 경우("다른 앱 위에 표시" 권한, addView 실패) OverlayFailed 있음
  • 안드로이드는 화면 녹화(MediaProjection)를 한 번에 한 앱만 쓰게 하므로, 다른 앱이 시작하면 우리 것을 끝냅니다. 콜백으로는 이 경우와 사용자가 시스템 UI에서 끈 경우를 구분할 수 없어서 알림 문구는 누구 탓도 하지 않습니다.
  • 서비스는 stopEverything(reason)으로 멈추며 이유를 남기고, 처음 남긴 이유로 한 번만 알립니다. 프로젝션이 끊기면 뒤따라 읽기 오류가 나는데, 먼저 난 쪽이 원인이기 때문입니다. 이 규칙(처음 이유만, 한 번만, 내려간 뒤에는 알리지 않음)은 안드로이드에 의존하지 않는 StopLatch에 모아 두어 JVM에서 검사합니다(StopLatchTest).
  • 오버레이가 실패하면 AudioCaptureService.stopForFailure가 떠 있는 캡처 서비스(onCreate에서 넣고 onDestroy에서 비우는 instance)의 stopEverything을 부릅니다. 이유를 실어 startService로 보내지 않는 것은, 캡처 서비스가 이미 내려가는 중이면 새 인스턴스가 만들어지는데 그 인스턴스는 사용자가 켠 것이 아니라서 아무것도 하지 않고 내려가 이유가 전해지지 않기 때문입니다.
  • 앱 버튼이나 타일은 stopEverything을 거치지 않고 stopService로 바로 내리므로 알리지 않습니다.

꺼짐 알림 (StopAlertPlan, StopAlert): 오버레이는 소리가 없으면 아무것도 그리지 않아서, 청각장애 사용자는 조용한 장면과 꺼진 상태를 구분할 수 없습니다. 그래서 사용자가 끈 경우가 아니면 알립니다.

알리는 때는 stopEverything에서 서비스를 내리기 전입니다. onDestroy까지 미루면 포그라운드 서비스와 오버레이가 이미 내려가, 안드로이드가 이 앱을 백그라운드로 보고 진동을 버릴 수 있습니다(Android 12 이상은 알람·알림·벨소리 같은 용도만 백그라운드 진동을 허용). 게임 화면 위에서 꺼지는, 알려야 할 바로 그 경우입니다.

  1. 고유한 진동: 길게 세 번. 사용자가 소리 종류에 고를 수 있는 어떤 방식과도 박자가 겹치지 않고(0.3초 울림 뒤 0.15초 쉼, 사용자가 고르는 방식은 쉼이 울림보다 깁니다), 소리 종류별 진동 설정과 상관없이 울립니다. 진동 모터가 없으면 뺍니다. 알람 용도로 울립니다(소리 종류별 진동은 접근성 용도). 이 시점에는 캡처 서비스가 아직 포그라운드라 백그라운드 진동 규칙에 걸리지는 않지만, Android 12(API 31) 이상에서는 알람 용도가 접근성보다 우선순위가 높아 뒤늦게 결정된 진동 한 번에 끊기지 않고, 절전 모드에서 접근성 진동을 막는 버전(Android 14 이하)에서도 울립니다. 이 우선순위 규칙은 Android 12부터라, minSdk 29(Android 10·11)에서는 늦은 진동 한 번이 이 진동을 덮어쓸 수 있습니다. HapticNotifier.stop()이 진동 스레드를 기다리는 이유가 이것입니다. 대신 방해 금지 모드에서 알람을 허용하지 않았거나(완전 무음 포함), Android 13 이상에서 알람 진동을 꺼 두면 이 진동은 울리지 않습니다(접근성 용도는 그 설정들을 타지 않습니다). 그때는 알림과 홈 안내가 알립니다. 진동 알림(HapticNotifier)을 먼저 떼어 멈춘 뒤에 울립니다. 그쪽이 멈출 때 부르는 HapticPlayer.close()가 같은 앱의 진동을 끊기 때문입니다. stop()은 진동 스레드가 끝나기를 잠깐 기다리고, 기다림이 넘쳐도 close()가 모든 vibrate()와 같은 잠금 안에서 플레이어를 닫으므로 그 뒤로는 진동 알림이 아무것도 보내지 못합니다(Android 10·11에서 늦은 진동이 이 알림을 덮지 않습니다). 설정의 진동 미리보기가 돌고 있으면 알림 직전에 멈춥니다(HapticPreviewGate.stopPreview). 이 기다림은 메인 스레드를 최대 200ms 잡습니다(HapticNotifier.JOIN_TIMEOUT_MS). 진동 틱이 나가는 중에 꺼지는 경우가 최악이므로, 꺼짐 진동이 얼마나 늦는지는 실기기(S22, Android 12 출시)에서 한 번 재 보는 것이 좋습니다. 이때 aiPaused를 세워 onDestroy 전에 모델 로딩이 끝나도 진동 알림을 새로 붙이지 않게 합니다.
  2. 알림: 실행 중 알림과 다른 채널(VisualizerStoppedAlertChannel, 중요도 HIGH)에 올립니다. 소리를 못 듣는 사용자는 게임 화면 위로 헤드업이 떠야 알아채기 때문입니다. 헤드업은 중요도만 보므로 채널의 소리와 진동을 모두 끕니다. 진동만 끄고 소리를 남기면 진동 모드에서 시스템이 소리 대신 기본 알림 진동을 울려 1번 진동을 끊고 덮어씁니다. 채널의 소리·진동은 처음 만든 뒤로 앱이 바꿀 수 없으므로 바꾸려면 채널 ID도 바꿔야 합니다. 다시 켜기 버튼은 타일과 같은 StartVisualizerActivity를 엽니다. 화면 녹화 동의는 켤 때마다 다시 받아야 해서 서비스를 바로 되살릴 수 없습니다. 다시 켜지면(onCreate) 이 알림을 치웁니다.
  3. 홈 안내: 알림을 올렸는지와 상관없이 이유를 SettingsManager.lastUnexpectedStop에 저장하고, 홈 화면이 상태 아래에 무엇이 꺼졌는지와 안내 닫기 버튼을 보여줍니다. 앱 알림을 꺼 두면 알림도 토스트도 뜨지 못해 진동만 남기 때문입니다. 안내가 홈에만 있으므로, 설정·도움말 탭을 보던 중에 꺼졌으면 MainActivity.onResume이 홈 탭으로 옮겨 놓습니다. 옮기는 것은 안내 하나에 한 번뿐입니다(routedStopNotice, 화면 회전·언어 변경으로 다시 만들어져도 유지되게 onSaveInstanceState에 저장). 안내를 구분하는 번호도 이유와 함께 저장합니다. 이 값들과 타일 추가 여부는 그 기기에만 뜻이 있어서, 저장할 때 기기 표시(Settings.Secure.ANDROID_ID)를 함께 남기고 다른 기기에서 복원된 것이면 비웁니다(#181). 처음에는 Build.FINGERPRINT를 썼는데 그 값은 OS 빌드가 바뀌면 달라져서, 업데이트를 받은 같은 폰을 "다른 기기"로 보고 이 값들을 지웠습니다(#204). ANDROID_ID는 OS 업데이트로 바뀌지 않고, 같은 모델·같은 빌드의 두 폰도 구분합니다. 못 읽는 드문 경우에는 예전처럼 FINGERPRINT로 물러납니다. 같은 기기에 다시 깐 경우도 비웁니다(#266). 자동 백업은 같은 기기에 다시 깔아도 설치할 때 값을 되살리는데, ANDROID_ID는 같은 서명·사용자·기기면 그대로라 위 방법으로는 걸러지지 않습니다. 앱을 지우면 시스템이 빠른 설정 타일도 지우므로, 되살아난 "타일 추가됨" 때문에 설정 탭이 타일이 있다고 말하고 추가 버튼을 숨겼습니다. 그래서 설치 표시(firstInstallTime, 다시 깔면 바뀌고 업데이트로는 그대로)를 함께 남기고, 달라지면 타일 추가 여부와 꺼짐 안내를 비웁니다. 마이크 감도는 같은 기기의 마이크라 둡니다. 자동 백업은 프리퍼런스 파일을 통째로 옮기고 백업 규칙은 파일 단위라 키 하나만 뺄 수 없습니다. 그대로 두면 새 폰에서 앱을 처음 열었을 때 켠 적도 없는데 꺼짐 안내가 뜹니다. 번호만 프로세스마다 0부터 다시 세면 되살린 "이미 옮긴 번호"와 겹쳐, 설정·도움말 탭에 있던 사용자가 새 안내를 한 번도 못 보고 넘어갑니다(#176). 돌아올 때마다 옮기면 안내를 닫기 전까지는 보던 탭에 남을 수 없고, 설정 탭에서 언어를 바꿀 때마다 홈으로 끌려 나옵니다. 안내가 사라지면 되돌려 다음 안내에 다시 한 번 옮깁니다. 타일 길게 누르기로 설정을 열러 온 경우는 처음부터 옮긴 것으로 쳐서 설정 탭에 그대로 둡니다. 이유는 이름으로 저장하며, 저장·복원 규칙은 StopNoticeSettingsTest가 검사합니다. 다시 켜는 버튼은 따로 두지 않습니다(바로 아래 실행 버튼). 다시 켜지면(onCreate) 지웁니다.
  4. 토스트: 알림을 올릴 수 없을 때만 띄웁니다(Android 13 이상에서 알림 권한 거부, 앱 알림 끄기, 채널 끄기). 다만 앱 알림이 꺼져 있으면 시스템은 앱이 맨 앞에 있을 때만 토스트를 보여주므로, 게임 위에서는 뜨지 않습니다. 앱을 보고 있을 때의 덤입니다.

onDestroy는 AudioRecord를 멈춘 뒤 캡처 스레드가 끝날 때까지 기다리고 나서 해제합니다. 스레드가 아직 읽는 중에 해제하면 네이티브에서 크래시가 나기 때문입니다. 끝나지 않으면 해제하지 않고 남겨 둡니다.


2. 오디오 캡처

  • API: AudioPlaybackCaptureConfiguration으로 미디어·게임·알 수 없음 용도의 소리를 받습니다. 캡처를 막아둔 앱의 소리는 받을 수 없습니다(Android 정책).
  • 형식: 스테레오 float PCM. 샘플레이트는 기기 출력 레이트를 우선 쓰고, 안 되면 48kHz → 44.1kHz 순으로 시도합니다. 출력과 다른 레이트를 요청하면 캡처 경로에 리샘플러가 끼어 지연이 늘기 때문입니다.
    • 2채널보다 많이 받을 수 없습니다. 그래서 앞뒤(서라운드)는 구분하지 못합니다(도움말 help_note_direction). Galaxy S25+(Android 16)에서 확인했습니다(#136).
      • 오디오 정책에는 원격 서브믹스가 4채널(앞 좌우 + 뒤 좌우, 입력 0x3000c)까지 적혀 있습니다.
      • 그러나 이 경로로 4채널·5.1·채널 번호 4개를 요청하면 AudioRecord.Builder 안에서 거절됩니다. AudioPolicy.createAudioRecordSink → AudioFormat.inChannelMaskFromOutChannelMask 가 1·2채널만 받고, IllegalArgumentException("Unsupported channel configuration for input.") 을 던집니다. 요청이 오디오 서버까지 가지 않습니다.
      • 정책을 직접 등록하는 API(AudioManager.registerAudioPolicy)는 시스템 권한이 필요해 우회할 공개 경로가 없습니다. 재생하는 앱이 5.1 로 내더라도 우리가 받는 것은 2채널로 섞인 소리입니다.
      • 2채널에서 위상으로 뒤쪽 성분을 추정하는 방법(매트릭스 디코딩)은 넓게 퍼지는 울림도 "뒤" 로 읽혀, 방향을 틀리게 보여 줄 위험이 커서 쓰지 않습니다.
  • 캡처 스레드 SV-AudioCapture (THREAD_PRIORITY_URGENT_AUDIO)가 한 번에 1024 float(512프레임)씩 읽고, 읽을 때마다 두 곳으로 넘깁니다.
    1. 시각화용: AudioRecord가 채운 direct ByteBuffer를 AudioEngine.pushAudioBuffer로 넘깁니다. JNI가 버퍼 주소를 그대로 읽으므로 복사와 할당이 없습니다.
    2. AI용: 재사용하는 FloatArray에 복사해 AiAudioBuffer에 넣습니다. 여기서 좌우를 평균해 모노로 합칩니다. 추론은 캡처 스레드에서 하지 않습니다. 외부 사운드 모드에서는 넣기 직전에 우리 진동이 든 구간을 바로 앞의 깨끗한 소리로 바꿉니다(5장 "외부 사운드 모드: AI 입력", #352). 폰 안의 소리는 복사한 그대로 넣습니다.

외부 사운드 모드 (마이크, CaptureSource, #226)

홈의 외부 사운드 모드 스위치(기본 꺼짐)를 켜고 실행하면, 폰에서 재생되는 소리 대신 마이크로 주변 소리를 받아 같은 오버레이와 진동으로 알립니다. 화재경보·사이렌·부르는 소리처럼 폰 밖의 소리를 알기 위해서입니다. 초인종(Doorbell·Ding-dong)은 기본 분류가 환경음이고 이 모드에서 환경음은 진동하지 않으므로(#354), 화면이 꺼진 동안 알리려면 사용자가 분류 탭에서 위협음이나 사람 소리로 바꿔야 합니다. 새로 위협음으로 정한 소리는 보통의 top-3 투표에만 들어가고 안전 단서로는 쓰이지 않으므로(docs/ai/user-mapping.md), README 는 바꿔도 모든 초인종 소리를 알린다고 약속하지 않습니다. 스위치를 끈 동작은 예전과 같습니다.

  • 소스 하나가 정하는 것(CaptureSource): 화면 녹화 동의가 필요한지, 포그라운드 서비스 타입(mediaProjection 또는 microphone, 하나만), 받을 수 없는 소리 안내를 돌릴지. 흩어 두면 동의 없이 mediaProjection 포그라운드를 시작하는 조합이 생길 수 있어 한곳에 모았고, CaptureSourceTest가 어긋난 조합이 없는지 봅니다.
  • 소스는 켤 때 정합니다. 켜기를 누른 쪽이 시작 표에 실어 보내고(1장), 서비스는 onCreate에서 받아 그 실행이 끝날 때까지 바꾸지 않습니다. 그래서 홈의 스위치는 실행 중에는 잠급니다. 바꿀 수 있게 두면 스위치와 실제로 듣는 곳이 어긋납니다.
  • 포그라운드 타입: 매니페스트에 mediaProjection|microphone과 FOREGROUND_SERVICE_MICROPHONE을 두고, 실행마다 한 타입만 넘깁니다. Android 11 이상은 microphone 타입이 아니면 다른 앱으로 나가 있는 동안 마이크를 막습니다(0만 들어옴). 에뮬레이터(API 37)에서 홈으로 나가 있는 동안 dumpsys audio의 녹음이 silenced:false로 남는 것을 확인했습니다. 빠른 설정 타일로 켤 때는 투명 화면이 앞에 있는 동안 서비스를 띄우므로, 화면을 바로 닫아도 앱이 사용자 앞에 있는 것으로 판정됩니다.
  • 소스: VOICE_RECOGNITION. 안드로이드가 자동 음량 조절과 잡음 제거를 끄고 주파수 응답을 고르게 두도록 정한 소스라 AI 가 가공되지 않은 소리를 듣습니다. 형식은 내부 소리와 같은 스테레오 float 이고, 레이트 후보도 같습니다(openAudioRecord).
  • 좌우를 늘 같게 만듭니다(toDualMono). Galaxy S25+ 에서 재 보니 VOICE_RECOGNITION은 아래쪽 마이크 하나를 두 채널로 복제해 주었지만, MIC·CAMCORDER는 아래쪽(0.027, 0.006m)과 뒷면 위쪽(0.055, 0.146m) 마이크를 따로 주어 크기가 약 4dB 달랐습니다. 두 마이크는 폰의 긴 축으로 약 14cm 떨어져 있어 그 축의 도착 시간차만 잴 수 있습니다. 세로로 들면 그 축이 위아래라 좌우를 가릴 수 없고, 가로로 들면 좌우가 되어 가릴 수 있습니다(앞뒤는 어느 쪽이든 가릴 수 없습니다). 가로의 좌우 표시는 #248 에서 합니다. 좌우 크기 차이로 방향을 그리는 오버레이에 그대로 넘기면 마이크 감도 차이만으로 한쪽으로 기운 그림이 나오므로, 캡처 스레드가 버퍼마다 좌우를 평균해 두 채널에 같이 넣습니다(할당 없음). 그래서 마이크 모드의 그림은 늘 가운데이고 틀린 방향이 나오지 않습니다. 기기별 측정은 MicProbeInstrumentedTest(기본으로는 건너뜀, -e micProbe true)로 소리 없이 다시 할 수 있습니다.
  • 가로 좌우 준비(#248, 아직 앱에 연결하지 않음): direction/ArrivalDelayEstimator가 두 마이크의 도착 시간차를 창마다 재고, ScreenSideMapper가 화면 회전과 묶어 왼쪽·가운데·오른쪽으로 바꿉니다.
    • 시간차는 ±24샘플(48kHz) 안에서만 찾고 고역 강조 뒤 정규화 상호상관으로 잽니다. 소리가 작거나, 상관이 0.6보다 낮거나, 봉우리가 범위 끝에 붙으면 판단하지 않습니다.
    • 연결하려면 입력을 MIC/CAMCORDER 스테레오로 바꿔야 해서 AI 입력이 달라집니다. 그래서 지금 입력으로 하는 마이크 AI 평가 뒤에 따로 검토합니다.
    • 기기 측정은 MicDirectionProbeInstrumentedTest(기본으로는 건너뜀, -e micDirectionProbe true)로 소리 없이 합니다.
    • Galaxy S25+ 측정(2026-10-01).
      • 안드로이드의 마이크 채널 배치로는 어느 채널이 위쪽 마이크인지 알 수 없습니다. 두 마이크 모두 0번 채널로 보고되어, 소리를 내어 정해야 합니다.
      • 사무실 소리만 들을 때 판단한 창은 467개 중 0~3개였습니다.
      • VOICE_RECOGNITION과 MIC를 한 앱에서 동시에 열면 같은 흐름을 나눠 받습니다(상관 1.000). 그래서 AI 는 VOICE_RECOGNITION으로 두고 방향만 MIC로 받는 길은 이 기기에서 막혀 있습니다.
      • 번갈아 2초씩 5쌍을 재 보니 MIC가 VOICE_RECOGNITION보다 RMS 로 약 59dB 컸습니다. 250Hz2kHz 에서는 약 11~17dB 컸고, 8kHz 에서는 약 7dB 작았습니다. 마이크 하나를 바꾸는 것 이상으로 크기와 음색이 달라집니다.
  • 기본 입력 이득은 걸지 않고, 감도는 사용자가 맞춥니다. 같은 폰의 사무실 잡음이 VOICE_RECOGNITION으로 RMS -52dBFS, 피크 -40dBFS 안팎이라 오버레이가 깨어나는 기준(0.01)에 이미 닿습니다. 기본으로 이득을 걸면 조용한 곳이 아니면 늘 그림이 켜져 있게 됩니다. 대신 홈의 외부 사운드 모드 카드에 마이크 감도(MicSensitivity, #306)를 둡니다. 화면에는 숫자 없이 16칸만 보이고(화면 읽어주기는 "4/16" 처럼 몇 번째 칸인지 읽음), 크기에 ×0.42~×32 를 곱하며 한 칸마다 약 2.5dB 로 고르게 커집니다. 기본은 왼쪽에서 네 번째 칸(×1)입니다. ×32 를 최대로 둔 까닭은, 그 위로는 조용한 방의 마이크 잡음(약 -65dBFS)만으로도 깨어나는 기준을 넘어 잡음만 더 그리기 때문입니다. 마이크 하드웨어의 증폭은 앱이 바꿀 수 없습니다. 저장은 퍼센트로 하므로 칸을 바꿔도 저장된 값은 가장 가까운 칸으로 읽힙니다. 감도는 캡처 스레드가 버퍼마다 읽어 네이티브의 pushAudioBuffer(…, levelGain)로 넘기고, 네이티브는 잰 크기에만 곱해 1에서 자릅니다. 오버레이·진동·"마이크가 막혔다" 안내가 같은 크기를 보고, PCM 은 그대로라 같은 버퍼를 읽는 AI 의 입력은 바뀌지 않습니다. 실행 중에도 바로 먹습니다. 기기의 마이크에 맞춘 값이라 다른 기기로 복원하면 지웁니다(dropOtherDeviceValues).
  • 통화 중에는 버립니다. 확인 틱(0.5초)이 AudioManager.mode가 MODE_NORMAL이 아니면 캡처 스레드에 알려 입력을 0으로 버립니다. 안드로이드도 통화 중 다른 앱의 녹음을 막지만, 막지 않는 기기에서도 통화 소리가 오버레이·진동·AI 에 닿지 않게 하기 위해서입니다. addOnModeChangedListener는 Android 12부터라 틱에서 봅니다.
  • 마이크가 막혔을 때(MicSilenceNotice): 통화·다른 앱·빠른 설정의 마이크 차단이면 AudioRecord는 오류 없이 0만 돌려줍니다. 시스템이 막았다고 알려 주면(isClientSilenced) 바로, 아니면 틱 전체의 피크가 사실상 0(1e-6 이하)인 상태가 3초 이어지면 홈과 실행 중 알림에 "마이크로 아무 소리도 들어오지 않는다"고 알립니다. 샘플 몇 개가 정확히 0인 것은 흔해서(측정: 3초에 수백 개) 샘플이 아니라 피크로 봅니다. 에뮬레이터에서 마이크 차단(cmd sensor_privacy enable 0 microphone)을 켜면 바로 뜨고 풀면 바로 내려가는 것을 확인했습니다. 받을 수 없는 소리 안내(BlockedCaptureNotice)는 재생 중인 앱을 탓하므로 마이크 모드에서는 돌리지 않습니다.
  • 알림과 홈: 실행 중 알림의 제목에 "주변 소리"를, 홈 상태에 "실행 중 · 주변 소리"를 적습니다. 방 소리를 들어 확인할 수 없는 사람에게는 지금 방을 듣고 있는지 알 수 있는 곳이 여기뿐입니다. 제목은 "주변 소리 · SoundVisualizer"처럼 모드를 앞에 둡니다. 접힌 알림은 제목과 모드 칩, 시각을 한 줄에 그려서, 긴 언어에서는 제목 뒤쪽이 잘립니다(러시아어에서 확인). 홈은 설정값이 아니라 서비스가 이번 실행에 실제로 연 소스(SettingsManager.runningCaptureSource)를 보고 적습니다. 설정은 다음 실행의 소스이므로, 둘이 어긋나면 방을 듣지 않는데도 듣는다고 믿게 됩니다.
  • 진동: 폰이 자기 진동을 마이크로 다시 듣습니다. 진동은 소리가 이어지는 동안 울리므로 그대로 두면 자기 진동 소리에 멈추지 않을 수 있어(#290), 진동 판단은 앱이 울린 진동 사이의 쉼에서만 소리를 보고, 그 쉼이 넉넉하도록 종류마다 방식에 상한(환경음 꺼짐, 사람 소리 느림, 위협음 중간)을 둡니다(5장 "외부 사운드 모드: 자기 진동", #354). AI 는 진동 소리를 대개 말소리로 들으므로, AI 에 넘기는 소리에서는 우리 울림과 그 꼬리를 빼고, AI 가 쉬면 진동과 잠금 화면 알림이 그 라벨을 쓰지 않습니다(5장 "외부 사운드 모드: AI 입력", #352). 화면 그림은 짧아진 울림을 그대로 그립니다.
  • 문구: 폰 안의 소리만 받을 때의 마이크 권한 문구("주변 소리는 녹음하지 않습니다")는 모드가 꺼져 있으면 여전히 사실이라 그대로 두고, 모드가 켜져 있으면 _external 문구(무엇을 듣는지, 안드로이드 12 이상에서 마이크 표시가 뜬다는 것, 소리 자체는 파일로 남기거나 밖으로 보내지 않는다는 것)를 씁니다. 마이크 표시는 안드로이드 12 부터라 그 앞 버전에서는 우리 실행 중 알림만 보이고, 개발자 모드의 결과 기록은 들은 소리의 이름과 시각을 CSV 로 남기므로 "소리 자체"로 한정합니다. 튜토리얼의 소리 쪽 설명, 켜고 끄는 순서의 2단계와 개인정보 안내, 도움말 시작하기의 2단계도 모드에 따라 고릅니다. 들은 소리는 크기를 재고 분류하는 데만 쓰고, 다음 버퍼가 덮어씁니다.
  • 다시 켜기: 꺼짐 알림의 ‘다시 켜기’는 앱 프로세스가 죽은 뒤에도 타일용 투명 화면(StartVisualizerActivity)을 바로 엽니다. 그 화면은 모드를 고르기 전에 SettingsManager.init으로 저장된 설정을 읽습니다. 읽지 않으면 모드가 기본값(꺼짐)으로 보여 마이크 대신 폰 안의 소리로 켜고, 뒤이어 서비스가 읽은 설정 때문에 홈은 "주변 소리"라고 잘못 알립니다.
  • 이번에 하지 않은 것: 방향 표시(가로에서 긴 축의 도착 시간차로 좌우, 화면 회전 — #248), 마이크 모드에 맞춘 AI 라벨 조정, 유선·블루투스 헤드셋 마이크 고르기, 배터리 측정.

좌우 피크 측정 (native-lib.cpp)

C++은 버퍼마다 좌우 채널의 최대 진폭(max|sample|)만 계산합니다. 방향 계산은 오버레이 쪽 Kotlin에서 합니다.

함수 반환 읽은 뒤 읽는 곳
readPeaks(out) [좌 피크, 우 피크, 도착한 버퍼 수] (지난 호출 이후 최대값) 0으로 초기화 오버레이
currentLevel() 가장 최근 버퍼의 좌우 중 큰 값 유지 개발자 모드 HUD
takePeakSinceLastCheck(out) [피크, 도착한 버퍼 수] (지난 호출 이후 최대값) 0으로 초기화 보호된 소리 확인
takeHapticPeak() 지난 호출 이후 최대 피크 0으로 초기화 진동 알림

상태는 정적 std::atomic 뿐이라 락이 없습니다. 읽으면서 0으로 되돌리는 누적값은 소비자마다 따로 둡니다. 하나를 나눠 쓰면 먼저 읽는 쪽이 비워, 다른 쪽은 그 구간을 통째로 놓칩니다. 아무 때나 읽어도 되는 currentLevel은 가장 최근 버퍼(11.6ms) 하나만 담고 있어서, 0.5초마다 보는 쪽(보호된 소리 확인)이 쓰면 구간의 2% 남짓만 들여다보게 됩니다. 진동도 같은 이유로 누적값을 씁니다(#174). 0.1초마다 가장 최근 버퍼만 보면 구간의 12% 남짓만 보게 되어, 총소리 한 발처럼 30~60ms 짜리 소리는 확인과 확인 사이에 들어왔다 사라집니다. 화면은 누적값을 읽으니 그려지는데 진동만 빠지는, 가장 나쁜 방향의 실패였습니다.

화면이 꺼지면 쉬기 (ScreenOffPause)

설정 탭 자세한 설정의 배터리에 있는 화면이 꺼지면 일시정지(SettingsManager.pauseWhenScreenOff, 기본 켜짐)가 켜져 있으면, 화면이 꺼진 동안 캡처·AI·진동·오버레이 확인을 멈추고 화면이 켜지면 다시 켭니다. 꺼 두면 화면이 꺼져도 지금처럼 계속 돕니다(화면이 꺼진 채 음악을 들으며 진동 알림을 받고 싶은 경우).

  • 외부 사운드 모드는 쉬지 않습니다(#260, CaptureSource.followsScreenOffPause). 이 설정과 상관없이 화면이 꺼져도 듣습니다.

    • 이 모드는 폰을 내려놓아 화면이 꺼진 동안 화재경보·사이렌 같은 위협음과 부르는 소리를 알리려는 것이라, 화면이 꺼진 때가 가장 필요한 때입니다. 초인종은 기본 분류가 환경음이라 분류 탭에서 바꿔야 진동합니다(위 "외부 사운드 모드"). 아래 ‘왜 쉬나’의 이유(게임과 영상이 멈추고 오버레이가 보이지 않는다)는 폰 안의 소리에만 맞습니다.
    • 화면이 꺼진 동안 상태 표시줄의 마이크 표시는 보이지 않습니다. 그래서 외부 모드의 마이크 권한 안내, 도움말의 권한 설명과 ‘화면이 꺼지면 쉼’ 항목, 배터리 설정 설명에 화면이 꺼져도 듣는다고 적습니다.
  • 왜 쉬나: 화면이 꺼지면 게임과 대부분의 영상은 멈추고 오버레이는 보이지도 않습니다. 그래도 AudioRecord가 녹음 중이면 오디오 서버가 앱 몫의 wake lock을 쥐어 CPU가 잠들지 못하고, AI는 250ms마다 추론하고, 진동 판단은 100ms마다 돌고, 주머니 속에서 음악의 사이렌 소리에 위협음 진동이 울릴 수도 있습니다.

  • 감지: AudioCaptureService가 실행 중에만 ACTION_SCREEN_OFF·ACTION_SCREEN_ON 수신기를 등록합니다. 이 두 방송은 매니페스트로는 받을 수 없습니다. 시스템만 보내는 보호된 방송이지만 ContextCompat.registerReceiver(..., RECEIVER_NOT_EXPORTED)로 앱 밖에 열어 두지 않습니다(Android 13 이상은 플래그, 그 아래는 앱 서명 권한으로 같은 효과). 등록할 때 이미 화면이 꺼져 있으면(PowerManager.isInteractive) 바로 쉽니다.

  • 쉬기 (메인 스레드): 결과 읽기(AiClassification.detach) → 진동(HapticNotifier.stop) → AI(RealtimeAiPipeline.stop) → 캡처(isRecording=false → AudioRecord.stop → 캡처 스레드 종료 대기) → AudioEngine.reset.

    • stop()은 마지막 결과를 지우지 않으므로 읽는 쪽(AiClassification)도 같이 뗍니다. 붙여 둔 채로 쉬면 화면을 켠 뒤 start()가 비울 때까지(AI는 300ms 늦게 켭니다) 오버레이가 쉬기 직전 라벨을 읽어, 첫 소리를 위협음 색으로 번쩍이거나(위협음 색은 즉시 칠해집니다) 숨긴 종류였다면 반대로 가려 버립니다.
    • MediaProjection과 AudioRecord는 놓지 않습니다. 놓으면 화면을 켤 때마다 화면 녹화 동의를 다시 받아야 합니다. 녹음만 멈춰도 오디오 서버가 wake lock을 놓습니다.
    • isRecording을 먼저 내려서, stop()으로 풀린 read()가 내는 오류를 캡처 오류로 보지 않습니다. 쉬기는 사용자가 끈 것도 실패도 아니므로 꺼짐 알림을 띄우지 않습니다.
    • AudioEngine.reset을 하지 않으면 네이티브에 남은 마지막 소리 크기를 진동 판단과 오버레이가 "계속 나는 소리"로 봅니다.
    • 캡처 스레드가 끝나지 않으면 같은 AudioRecord로 다시 켤 수 없으므로 CaptureError로 내리고 알립니다.
  • 다시 켜기: startRecording과 새 캡처 스레드, 새 HapticNotifier(한 번만 시작·정지하는 객체), 그래픽(isCapturePaused=false)은 바로 → (300ms 뒤) RealtimeAiPipeline.start(링버퍼·후처리·마지막 결과를 비워 쉬기 직전 소리를 다시 분류하지 않음) → AiClassification.attach. 진동은 AI 가 다시 붙을 때까지 라벨이 없어 울리지 않습니다. 녹음을 다시 시작하지 못하면 CaptureError로 내리고 알립니다.

    • AI만 300ms 미루는 이유: 쉴 때 부르는 stop()은 이미 돌던 추론 한 번을 기다리지 않습니다. 화면을 껐다 바로 켜서 그 추론이 start()로 비운 뒤에 끝나면 쉬기 직전 결과가 되살아나, 첫 소리에 위협음 진동이 잘못 울릴 수 있습니다. 추론 한 번보다 길게 기다렸다 시작하고, 그때까지 aiPaused를 세워 둡니다. 미뤄 둔 재시작은 다시 쉬거나 내려갈 때 취소합니다(scheduleAiResume·cancelAiResume).
    • 그래픽은 기다리지 않습니다. 소리를 보는 것이 이 앱의 본 일이라 화면을 켠 뒤 300ms를 비워 둘 수 없습니다. 기다리는 동안 라벨만 환경음으로 떨어집니다(읽는 쪽을 떼어 두었으므로). 붙이기는 start()가 비운 뒤에 하므로 그 사이에 옛 라벨이 화면에 나오지 않습니다.
    • 대신 환경음 표시를 꺼 둔 사용자는 그동안 소리가 나도 아무것도 보이지 않습니다. 기다리는 300ms에 더해 링 버퍼가 다시 찰 때까지(약 1초) 이어집니다. 옛 라벨로 위협음 색이 잘못 번쩍이는 것보다는 낫다고 보고 이렇게 두었습니다. 기기에서 체감을 확인할 항목입니다.
  • 모델 로딩이 쉬는 중에 끝나면 파이프라인만 들고 있고, 추론·진동·결과 읽기는 화면이 켜질 때 시작합니다(aiPaused, aiLock으로 보호).

  • 쉬는 동안 SettingsManager.isCapturePaused가 true이고, 오버레이 렌더 루프가 이 값을 봅니다(4장).

  • 늦게 도착한 읽기 오류가 다시 켠 새 캡처를 내리지 않도록, 오류를 낸 스레드가 지금 캡처 스레드일 때만 멈춥니다.

받을 수 없는 소리 알리기 (BlockedCaptureNotice)

폰 안의 소리를 받을 때만 돌립니다. 외부 사운드 모드에서는 MicSilenceNotice가 대신합니다(위 외부 사운드 모드).

안드로이드는 앱이 자기 소리를 다른 앱에 넘기지 않도록 막을 수 있습니다(android:allowAudioPlaybackCapture="false", DRM이 걸린 영상 등). 그런 앱의 소리는 우리 쪽 믹스에 섞이지 않아 오버레이가 아무것도 그리지 않습니다. 청각장애 사용자는 "조용한 장면"과 "받을 수 없는 소리"를 스스로 구분할 수 없어, 알리지 않으면 앱이 고장 났다고 여깁니다.

  • 재료: ① 우리가 받기로 한 소리(미디어·게임)를 지금 누가 내고 있는지(AudioManager.getActivePlaybackConfigurations에서 용도가 CAPTURED_USAGES인 것), ② 지난 확인 이후 우리가 받은 구간 전체의 최대 진폭, ③ 그사이 버퍼가 실제로 도착했는지(②③ 모두 AudioEngine.takePeakSinceLastCheck). ①과 ②가 어긋난 상태가 이어지면 받지 못하는 것으로 봅니다. 새 권한은 필요 없습니다.
    • isMusicActive를 쓰지 않는 이유: STREAM_MUSIC으로 나가는 모든 소리에 true라서, 우리가 애초에 받지 않는 어시스턴트 응답이나 내비게이션 안내가 길게 이어지기만 해도 화면에 떠 있던 멀쩡한 앱이 누명을 씁니다. 재생 목록을 용도별로 보면 캡처 설정과 정확히 같은 집합만 셀 수 있습니다(목록은 CAPTURED_USAGES 한 곳).
  • 기준값: 무음 0.000001(약 -120dBFS, 사실상 정확히 0), 버티는 시간 15초, 내리는 시간 2초.
    • 무음 기준을 "작으면 무음"으로 잡으면 안 됩니다. 우리가 받는 소리에는 미디어 볼륨이 곱해져 있어서(볼륨 0에서 정확히 0이 들어오는 이유), 15단계 중 1~2단계에서는 보통의 영상도 0.002 언저리로 들어옵니다. 화면을 보려고 볼륨을 낮춰 두는 것은 이 앱 사용자에게 아주 흔한 사용법이라, 그 정도를 무음으로 치면 멀쩡한 앱을 탓하게 됩니다. 반대로 막힌 앱의 소리는 볼륨과 상관없이 정확히 0으로 들어옵니다.
    • 15초는 잠깐 조용한 구간(로딩 화면·장면 전환·광고 사이)을 지나 보내는 것을 넘어, 소리를 끈 채 자동 재생되는 피드 영상 위를 몇 초 훑고 지나가는 흔한 상황에 닿지 않기 위한 길이입니다. 그러면서도 막힌 앱을 틀어 둔 사용자가 "고장 났나" 하고 앱을 끄기 전에는 뜹니다.
  • 확인 틱: AudioCaptureService가 캡처가 도는 동안에만 메인 스레드 핸들러로 500ms마다 봅니다. 새 스레드를 만들지 않고, 캡처 스레드는 버퍼마다 원자 연산 두 번만 더 합니다. 오버레이가 프레임을 그리는 바로 그 스레드이므로 싼 질문부터 합니다. 네이티브에서 읽는 것은 공짜에 가깝고, 소리가 들어왔거나 버퍼가 끊겼으면 그것만으로 끝나 시스템에는 아예 묻지 않습니다. 아무것도 재생하지 않는 흔한 상태에서는 한 번만 묻습니다.
  • 잘못 뜨지 않게 거르는 것들: 버퍼가 한 개도 도착하지 않음(우리 캡처가 멈춘 것이므로 앱 탓이 아닙니다 — 필요한 것은 다시 켜기입니다), 화면 꺼짐 일시정지 중, 미디어 볼륨 0(믹스가 통째로 0이라 아무것도 가려낼 수 없습니다), 통화·음성 채팅 중(AudioManager.mode != MODE_NORMAL). 캡처가 멈추면(종료, 화면 꺼짐) 틱과 안내를 함께 내립니다. 틀린 안내는 침묵보다 나쁩니다. 멀쩡한 앱을 탓하게 만들기 때문입니다.
    • 출력 기기는 보지 않습니다. 캡처는 소리가 기기로 나가기 전의 믹스를 받으므로 이어폰·블루투스 경로에서도 받는 소리는 같습니다. 연결만 되어 있어도 거르면 보청기나 워치를 늘 차고 있는 사용자에게는 안내가 영영 뜨지 않는데, 정작 이 안내가 가장 필요한 사람들입니다.
  • 가려낼 수 없는 것: 앱이 스스로 음소거한 경우(피드의 자동 재생 영상, 게임 안의 음악 슬라이더 0)도 우리에게는 똑같이 아무것도 들어오지 않습니다. 시스템은 둘을 구분해 주지 않습니다. 그래서 문구는 원인을 단정하지 않고 두 가능성을 함께 말하고, 안내를 받는 쪽도 "이 앱"이 아니라 지금 재생 중인 앱이라고 가리킵니다(실행 중 알림에서는 제목이 SoundVisualizer라 "이 앱"이 우리 자신으로 읽힙니다).
  • 보여주는 곳: SettingsManager.isCaptureBlocked → 홈 탭의 상태 아래 안내와 실행 중 알림 문구. 소리가 다시 들어오면 그 즉시 내립니다. AI 사용 불가 안내와 겹치면 받지 못한다는 쪽만 보여줍니다. 받는 소리가 없으면 분류할 소리도 없어 AI 안내는 그 순간 의미가 없고, 소리가 다시 들어오면 나옵니다.
  • 판단 규칙만 BlockedCaptureNotice로 떼어 내 JVM에서 검사합니다(BlockedCaptureNoticeTest). 시스템에 묻는 부분은 기기에서 확인할 항목입니다: ① 볼륨 1~2단계로 보통 영상(안내가 뜨면 안 됩니다), ② 소리를 끈 자동 재생 피드 스크롤(뜨면 안 됩니다), ③ 게임 안에서 음악·효과음을 0으로(뜨더라도 문구가 음소거 가능성을 함께 말해야 합니다), ④ 보호된 영상 앱(15초 뒤에 떠야 합니다), ⑤ 블루투스 이어폰·워치 연결 상태에서 ④ 반복(그래도 떠야 합니다).

3. AI 분류 (ai/)

준비

  • 서비스가 시작되면 SV-AiInit 스레드(THREAD_PRIORITY_BACKGROUND)가 RealtimeAiPipeline.create로 모델을 불러옵니다. 서비스 시작을 막지 않기 위해서입니다.
  • YAMNet은 가중치가 별도 파일(yamnet.data, 약 15MB)이라, ONNX Runtime이 파일 경로로 찾을 수 있게 assets에서 앱 내부 저장소로 복사한 뒤 세션을 만듭니다.
  • 로딩이 끝나기 전이나 모델 로딩이 실패하면 분류 결과가 없고, 오버레이는 모든 소리를 환경음으로 그립니다.
  • 로딩이 실패하면 캡처와 시각화는 계속 돌지만, 진동 알림(HapticNotifier)은 캡처와 함께 돌되 라벨이 오지 않으므로 종류를 가리지 않고 큰 소리에만 위협음 설정으로 울립니다(5장). 설정 화면만 보면 종류별 진동이 그대로인 것처럼 보이므로, SettingsManager.aiAvailable을 false로 두고 홈의 상태 아래, 설정의 소리 분류 카드 맨 위(쉬운 설정에서는 소리 종류 카드 맨 위, #408), 켜 둔 진동 줄의 제목 아래(쉬운 설정에서는 켜 둔 진동 칸 아래), 실행 중 알림 문구에 소리 종류를 구분하지 못한다고 알립니다. 큰 소리에는 울린다는 말은 위협음의 표시와 진동이 켜져 있고 진동 모터가 있을 때만 쓰고, 아니면 진동 알림이 꺼져 있다고 씁니다(AiUnavailableNotice, #232). 진동에 기대는 사용자를 거짓으로 안심시키지 않기 위해서이며, 실행 중 알림은 그 설정이 바뀌면 다시 올립니다. 알림을 다시 올리는 일은 메인 스레드로 넘기며, 그사이 멈추는 중이거나(StopLatch) 끄기를 누른 뒤면(AudioCaptureService.stopRequested) 올리지 않습니다. 포그라운드 알림이 치워진 뒤 같은 번호로 올리면 지워지지 않는 알림이 남기 때문입니다. 끄기 신호는 서비스가 직접 들고, VisualizerController.stop이 stopService 앞에 세웁니다. UI 상태(isServiceRunning)로는 거를 수 없습니다. 액티비티가 onResume에서 AudioCaptureService.isRunning으로 다시 세우는데, 그 값은 onDestroy 전까지 true이기 때문입니다. 이 값은 켤 때와 끌 때 true로 되돌립니다(로딩 중에도 true). ai/ 코드는 바꾸지 않고, 실패는 create가 던지는 예외로 압니다.

분석 주기

코루틴(Dispatchers.Default)이 250ms마다 분석을 시작합니다. 분석에 걸린 시간을 빼고 남은 만큼만 쉬므로 시작에서 다음 시작까지가 250ms입니다(#379, S25+에서 결과 간격 중앙값 273ms → 250ms). 분석이 150ms보다 오래 걸리는 느린 기기에서도 쉬지 않고 연달아 돌지 않도록 최소 100ms는 쉽니다(remainingTickDelayMs). 한 번의 분석은 다음 순서입니다.

  1. 입력 자르기: 링버퍼에서 최근 약 0.975초(16kHz 기준 15,600샘플)를 가져옵니다.
  2. 16kHz 변환: 캡처 레이트의 소리를 16kHz로 줄입니다. 그냥 솎아 내면 8kHz 위의 소리가 아래로 접혀 들어오므로(에일리어싱) 96탭 Kaiser 창 sinc 저역통과 FIR(차단 7.8kHz, β 8.6)을 거칩니다. 계수 표는 캡처 레이트마다 한 번만 만들고(44.1kHz는 위상 160개, 48kHz는 1개), 원본 위치와 위상은 정수로 계산해 15,600샘플 동안 어긋나지 않습니다. 16kHz 입력은 그대로 통과합니다. AI가 받는 캡처 레이트는 16·44.1·48kHz뿐이라(AiCaptureSampleRatePolicy) 그보다 높은 레이트에서 필터가 모자라는 일은 없습니다. (CaptureAudioMath)
  3. Log-mel 스펙트로그램: pinned Qualcomm source frontend 계약으로 Kotlin에서 계산합니다. (QualcommSourceAudioPreprocessor: centered STFT/reflect padding, 64개 멜 대역, 96프레임 → [1, 1, 96, 64])
  4. YAMNet 추론: 521개 소리 클래스의 확률을 냅니다. (YamnetInference, 연산 스레드 1개로 제한해 캡처·렌더와 CPU를 다투지 않게 함)
  5. 3종 분류: 상위 클래스들을 투표해 환경음(ambient)·사람 소리(speech)·위협음(danger)으로 묶습니다. (YamnetCoarseClassifier, YamnetThreeClassMapper)
  6. 안전 신호 보존: YAMNet top-5 안의 alarm·siren·explosion 계열은 모델 점수와 독립적으로 위험 신호로 보존합니다. (YamnetSafetyCueDecision)
    • 올리는 조건(#353, #358): 단서가 top-5 안에서 확률 0.05 이상이어야 합니다. 단서는 이름에 siren·alarm(alarm clock 제외)·explosion·fireworks·firecracker 가 든 소리와 경적·깨짐 같은 승인 목록(APPROVED_ATTENTION_CUE_LABELS)이고, 분류 탭에서 위협음에서 뺀 소리는 빠집니다(docs/ai/user-mapping.md). 기본 매핑으로 낸 top-3 투표(defaultCoarse)가 사람 소리이거나, 1순위가 말소리·무음 계열이거나, 1순위 확신도가 0.12 미만이면 올리지 않습니다. 총기 후보가 함께 있어도 막지 않지만, 총기 이름 자체는 이 길로 올리지 않습니다.
  7. 후처리: 확신도 기준과 히스테리시스를 적용해 라벨이 매번 흔들리지 않게 합니다. 위협음은 다른 종류보다 빨리 전환됩니다. 확신도가 기준에 못 미치면 이전 라벨을 유지합니다. (AiPostProcessor)
  8. 결과를 AtomicReference에 저장합니다. 읽는 쪽은 락 없이 가져갑니다.
  9. 사람 소리 조기 표시(#422): 정규 추론이 첫 사람 소리 후보(확정까지 한 틱 남음)를 내면, 그 뒤에 새로 들어온 소리를 Silero VAD 가 확인해 화면에만 사람 소리를 한 박자 먼저 보입니다. 32ms 덩어리 3개가 연달아 0.9 이상이고 정규 추론이 소리를 가져간 때부터 250ms 안이어야 하며, 다음 정규 결과가 오거나 350ms 가 지나면 사라집니다(추론이 오래 걸리는 폰에서도 다음 결과까지 이어지게, #456). 정규 결과·위협음 판단·진동은 바꾸지 않고, 분류 탭에서 바꾼 소리가 하나라도 있으면 쓰지 않습니다. (SileroStreamWorker, SileroPreviewGate, 측정은 docs/ai/silero-galaxy-422.md)

Qualcomm source frontend가 유일한 production 경로입니다. 개발자 모드는 관찰용 top-5·입력 통계·CSV 기록만 제공하며 frontend 또는 보조 모델을 바꾸는 A/B 선택은 없습니다. Gunshot Booster ONNX 모델과 점수 기반 승격은 production 경로에서 제거되었습니다.

결과 전달 (AiClassification)

캡처 서비스와 오버레이 서비스는 서로를 모릅니다. 캡처 쪽이 파이프라인을 시작하면서 결과를 읽는 함수를 AiClassification.attach로 걸어두고, 오버레이는 AiClassification.coarse()만 호출합니다. 분류기가 없으면 항상 ambient를 돌려줍니다.

붙어 있는 구간은 파이프라인이 도는 동안뿐입니다. 멈춘 파이프라인도 마지막 결과를 들고 있어서, 붙여 둔 채로 두면 옛 라벨이 계속 나옵니다. 그래서 startAiLocked(비운 뒤 붙이기)와 pauseForScreenOff·onDestroy(떼기)가 짝을 이루고, 화면이 꺼져 쉬는 동안과 다시 켠 뒤 300ms 동안은 붙어 있지 않습니다.

라벨은 AiClassification의 문자열 상수 세 개(AMBIENT·SPEECH·DANGER)입니다. 화면 색·표시 여부와 진동 설정은 모르는 라벨을 환경음으로 처리하므로, ai/ 코드가 내보내는 라벨 이름이 이 상수와 어긋나면 오류 없이 위협음이 환경음처럼 표시됩니다. 그래서 ai/ 밖의 AiLabelContractTest가 매핑·투표·안전 신호·후처리의 결과 라벨이 세 상수 중 하나인지와 대표 소리(총소리·말소리·빗소리)가 맞는 상수로 나오는지 확인합니다.

화면과 진동에는 라벨 하나면 충분하지만, 파이프라인이 넘기는 결과에는 모델이 말한 이름·확신도·임계값 통과 여부·안전 신호 승격·단계별 소요시간이 함께 들어 있습니다. 개발자 모드(4장)가 AiClassification.latest()로 그 전부를 읽습니다.

잠금 화면 위협음 알림(5장)도 AiClassification.latest()의 같은 결과에서 확정 이름과 상위 후보를 읽어 적을 소리 이름을 고릅니다(#356).

기준 구현과 검증

  • tools/ai_reference/: 전처리·추론의 Python 기준 구현과 골든 데이터 생성 스크립트
  • 유닛 테스트(app/src/test/.../ai/): Qualcomm 전처리 골든 비교, 분류 매핑, 안전 신호, 후처리
  • 계측 테스트(app/src/androidTest/.../ai/): 실제 ONNX 모델 추론 비교 (기기 필요)
  • 골든 픽스처(app/src/test/resources/ai_reference/)는 고리 셋의 가운데입니다. Kotlin ↔ 커밋된 픽스처는 위의 테스트가 보고, 픽스처를 만든 Python 기준 구현 ↔ 실제 TensorFlow·torch_audioset은 AI frontend reference parity 워크플로(verify_tensorflow_logmel_parity.py, verify_torchaudio_logmel_parity.py)가 봅니다. 픽스처만 다시 만들어 올려도 두 번째 검사가 돌아야 하므로 — 안 돌면 골든 테스트가 그 새 픽스처에 맞춰 통과합니다 — 그 워크플로의 paths 에는 tools/ai_reference/ 와 함께 픽스처 폴더도 들어 있습니다(#152).
  • 리샘플러는 Python 기준 구현과 44.1·48kHz 모두 오차 1e-5 안으로 맞는지(CaptureAudioPathTest), 통과대역과 저지대역 요건을 채우는지 봅니다. 이 비교에 쓰는 짧은 픽스처는 라이선스 오디오 없이 export_resample_parity_golden.py로 다시 만들 수 있습니다. 실제 소리로 끝까지 도는 e2e 골든은 원본 오디오의 출처·라이선스·SHA-256을 realtime_e2e_sources.json에 적어 두고, 오디오 파일 자체는 커밋하지 않습니다.

진단 로그 (debuggable 빌드만): 분석 결과를 2초에 한 번 AI_RESULT 로그로 풀어 남깁니다. Qualcomm frontend, 입력 형식(레이트·채널), 16kHz 신호의 RMS·피크·평균, log-mel의 최솟값·최댓값·평균·표준편차, YAMNet top-5, 종류별 점수, 안전 신호 승격, 후처리의 임계값·확정 상태·연속 횟수입니다. 캡처를 시작할 때는 요청한 형식과 실제로 열린 형식을 한 줄 남깁니다. 배포판(릴리스 APK)에는 남지 않으므로, 배포판을 쓰는 기기에서 볼 때는 개발자 모드(4장)를 씁니다.


4. 오버레이

코드 역할
OverlayService 오버레이 창을 띄우고 내리는 서비스
VisualizerOverlay 창 안에서 도는 Compose 렌더 루프
VisualizerEngine 피크를 8방향 깊이·도형으로 바꾸고 캔버스에 그리는 엔진
VisualizerInputs 엔진이 읽는 바깥 입력 인터페이스와 실제 연결(LiveVisualizerInputs)
VisualMode 네 가지 표현 모드 (파도·패드·원형·외곽선)
AiDebugOverlay / AiDebugText 개발자 모드에서 창 왼쪽 위에 띄우는 AI 분류 결과

창 (OverlayService)

WindowManager에 TYPE_APPLICATION_OVERLAY 창을 띄우고 그 안에 ComposeView를 둡니다.

  • FLAG_NOT_TOUCHABLE, FLAG_NOT_FOCUSABLE: 터치와 입력이 아래 앱(게임)으로 그대로 전달됩니다.
  • 창 불투명도(alpha): Android 12 이상은 InputManager.maximumObscuringOpacityForTouch(기본 0.8)로 맞춥니다. 다른 앱 위에 겹친 창이 이 값보다 불투명하면 시스템이 아래 앱으로 가는 터치를 막기 때문입니다(신뢰할 수 없는 터치 차단).
  • FLAG_LAYOUT_NO_LIMITS, FLAG_LAYOUT_IN_SCREEN: 상태바·내비게이션 바 영역까지 화면 전체에 그립니다.

렌더 루프 (VisualizerOverlay)

  • withFrameNanos로 화면 프레임마다 VisualizerEngine.tick을 부르고, 다시 그릴 필요가 있을 때만 그리기를 무효화합니다. 리컴포지션은 일어나지 않습니다.
  • 그리기는 Compose Canvas의 nativeCanvas(android.graphics.Canvas)에 Path·Paint·Shader로 합니다. OpenGL은 쓰지 않습니다.
  • 모든 버퍼·Path·Paint를 미리 만들어 두어 프레임당 힙 할당이 없습니다.
  • 파도·외곽선·패드는 가장자리 띠만 칠하지만 경로의 범위는 화면 전체라, 그대로 그리면 안티앨리어싱 마스크를 매 프레임 화면 전체 크기로 만듭니다. 그래서 도형이 가장자리에서 들어온 거리만큼의 띠 네 개로 잘라 그립니다(VisualizerEngine.paintEdgeBand, #172). 띠 두께는 변마다 따로 잽니다(#365, 아래). 그려지는 화면은 같습니다.
    • 띠 폭은 설정한 최대 깊이가 아니라 실제로 그리는 도형으로 잽니다. 둥근 모서리는 최대 깊이의 약 1.25배까지 들어옵니다. 잰 값은 어림이 아니라 보장된 경계입니다(#350).

      • 파도·외곽선: 곡선 조각마다 시작점·조절점 둘·끝점을 놓고, 변마다 네 점 가운데 가장 먼 거리를 잽니다. 3차 베지에 곡선은 네 점의 볼록 껍질 안에 있고 한 변까지의 거리는 좌표의 일차식이므로, 조각의 어느 점도 네 변 어디에서 재든 그 변에서 잰 값보다 깊이 들어오지 못합니다. 조각마다 변 하나에 맡기고, 변마다 맡은 조각 값의 최댓값을 그 변의 경계로 삼습니다(아래 #365). 파도 점만 재면 안 됩니다. 점 사이에서 곡선이 몇 px 더 부풀고, 소리가 없는 모서리에서는 모서리 양쪽 변의 점을 잇는 곡선이 모서리를 가로질러 점보다 깊이 들어옵니다.
      • 패드: 막대는 직선이고 안쪽 점이 막대 두께만큼만 들어오므로, 막대가 놓인 변에서 그 막대의 두께가 경계입니다. 다만 세로가 가로의 4배를 넘는 화면(h/4 > w)에서는 위·아래 가운데 막대(길이 h/4)가 변을 넘어 모서리를 돌고, 그 선이 모서리를 가로질러 막대 두께보다 깊이 들어올 수 있습니다. 그런 화면에서는 패드를 자르지 않고 그립니다.
      • 여기에 안티앨리어싱 1px, 외곽선의 선 두께, 발광이 번지는 폭을 더해 올림합니다. 외곽선은 선 두께를 다 더합니다. 선은 곡선 양쪽으로 절반씩 나가지만 꺾인 이음은 더 멀리 나갈 수 있기 때문입니다.
      • 예전에는 파도 점으로 재고 곡선이 부푸는 몫을 8dp 로 어림해 더했습니다. 평소 소리 크기에서는 이 여유가 띠의 3057% 였습니다. #350 으로 1080×2400 화면(420dpi)에서 띠 네 개의 넓이, 곧 매 프레임 래스터화하고 올리는 마스크의 크기가 소리 0.05 에서 파도 −40%, 외곽선 −31%, 패드 −80%, 0.15 에서 −27%, −22%, −60% 줄었습니다. 발광(세기 50)을 켜면 번지는 폭이 띠의 대부분이라 −28% 입니다. 가장 큰 소리(1.0)에서는 파도·외곽선이 −2% 입니다. 외곽선을 그리다 파도로 바꾸면 외곽선의 선 두께가 파도 띠에 남던 것도 함께 고쳤습니다.
      • 그릴 때와 같은 곡선과 막대를 점 사이까지 짚어, 점마다 맡긴 변의 띠 밖으로 나가지 않는지 JVM 테스트가 확인합니다. 같은 장면을 띠로 자른 그림과 자르지 않은 그림으로 그려, 띠로 둘러싼 가운데가 한 픽셀도 다르지 않은지는 계측 테스트(EdgeBandInstrumentedTest)가 봅니다. 띠 안은 Skia 가 잘린 경로를 래스터화하며 생기는 반올림만큼 다를 수 있어(예전 8dp 띠도 같았습니다), 32 넘게 다른 픽셀을 그린 픽셀의 0.5% 까지 받아들입니다. 계측 테스트는 자르기의 배치(겹치지 않고 픽셀에 맞는지)와, 한쪽만 들리는 소리에서 조용한 변의 띠가 얇은지도 봅니다.
    • Galaxy S25+ 에서 가짜 소리 크기로 엔진을 계속 그리는 로컬 벤치로 잰 한 코어 기준 값입니다(디버그 빌드, 30초씩).

      모드 자르기 전 자른 뒤 프레임 시간 중간값
      파도 (기본) 68% 45% 13ms → 6ms
      외곽선 89% 46% 11ms → 7ms
      패드 68% 26% 13ms → 5ms
      원형 (해당 없음, 대조군) 23% 24% 5ms
    • 발광이 켜져 있어도 자릅니다. 띠 폭에 블러가 번지는 폭(반경의 3배와 블러 커널이 닿는 거리 + 1px 가운데 큰 쪽, VisualizerEngine.glowSpread. 반경이 작으면 3배로는 커널을 다 덮지 못합니다)을 더하고, 발광도 띠 안에서 그립니다. 도형이 띠 안에 있으므로 발광이 번지는 곳까지 띠에 들어오고, 띠 네 개는 겹치지 않게 화면을 나누므로 잘라도 나오는 그림은 같습니다. 같은 기기에서 발광을 켠 채 12초씩, 두 방식을 한 APK에 넣어 번갈아 재고 순서를 뒤집어 한 번 더 쟀습니다(디버그 빌드). 한 코어 기준입니다.

      모드 자르기 전 자른 뒤 늦은 프레임
      파도 90~91% 52% 97% → 33~35%
      외곽선 85~90% 59~62% 9397% → 4655%
      패드 87~88% 31% 96~97% → 2%
      원형 (띠를 쓰지 않음, 대조군) 46% / 28% 45% / 25% 변화 없음

      대조군이 회차 묶음 사이에서 46% → 28% 로 움직인 것은 기기의 다른 부하 때문입니다. 두 방식을 붙여 재고 대조군을 함께 두는 것이 그 흔들림을 가려내는 방법입니다.

      #172 에서는 반대로 보아 발광이면 자르지 않았습니다. 그 측정이 틀렸습니다. 회차마다 벤치가 앞에서 밀려나 그린 프레임 수가 달라진 것을 걸러 내지 않았습니다. 지금은 회차마다 그린 프레임 수로 회차를 판정하고(화면이 둘인 기기에서는 앞 화면 검사가 맞지 않습니다), 두 방식을 번갈아 재며, 띠를 쓰지 않는 원형 모드를 대조군으로 함께 잽니다. 나오는 그림이 같은지는 같은 정지 화면을 두 방식으로 찍어 픽셀로 비교했습니다(최대 차이 255 중 4, 외곽선만 화면 맨 아랫줄 2픽셀).

    • 띠 두께는 변마다 따로 잽니다(#365). 예전에는 네 띠가 가장 깊이 들어온 변의 두께 하나를 써서, 소리가 한쪽에서만 크거나(게임 효과음, 좌우로 갈린 음악) 뒤쪽만 크면 조용한 변도 그 두께로 그렸습니다.

      • 띠는 위 [0,w]×[0,T], 아래 [0,w]×[h−B,h], 왼쪽 [0,L]×[T,h−B], 오른쪽 [w−R,w]×[T,h−B] 입니다. 겹치지 않고, 두께가 정수라 경계가 픽셀에 맞습니다. 모서리는 위·아래 띠가 가집니다.

      • 위·아래 띠가 너비 전체라서, 넷을 합친 곳은 "위에서 T 안, 아래에서 B 안, 왼쪽에서 L 안, 오른쪽에서 R 안 가운데 하나"인 점 전체와 같습니다. 그래서 도형의 점이 안티앨리어싱·선 두께·발광 여유까지 어느 한 변에서 그 변의 두께 안이면 잘리지 않습니다. 왼쪽에 맡긴 모서리 조각의 점이 왼쪽 띠 사각형보다 위로 나가도 그 점은 위 띠에 들므로, 모서리를 도는 조각을 따로 다루지 않습니다.

      • 곡선 조각은 어느 변에 맡겨도 그 변에서 잰 값을 쓰면 보장된 경계입니다. 맡기는 방법은 넓이만 정합니다.

        1. 조각을 값이 가장 작은 변(껍질이 붙어 있는 변)에 맡깁니다. 그 값이 #350 의 조각 깊이라서 어느 변도 #350 의 띠보다 두껍지 않고, 가장 두꺼운 변은 #350 의 띠와 같습니다. 그래서 자를지 말지(한 변이라도 짧은 변의 절반에 닿으면 자르지 않음)도 #350 과 똑같이 정해집니다.
        2. 다른 변의 1. 경계에도 이미 드는 조각은 들 수 있는 변 가운데 경계가 가장 두꺼운 변에 다시 맡깁니다. 소리가 없는 모서리를 가로지르는 조각은 두 변 쪽 값이 비슷해서, 1. 만으로는 조용한 변이 그 조각 몫(1080×1920 에서 20px)만큼 두꺼워질 수 있습니다. 어느 변도 1. 보다 두꺼워지지 않습니다.
      • 패드는 막대의 두께를 그 막대가 놓인 변에 넣습니다. 모서리 막대는 가운데 점이 모서리에 놓여 앞 절반과 뒤 절반이 이웃한 두 변에 놓이고, 모서리의 점은 두 변 모두에서 막대 두께 안이므로 두 변 모두에 넣습니다. 막대가 하나도 놓이지 않은 변은 자르지도 그리지도 않습니다.

      • 띠로 둘러싼 가운데는 #350 의 가운데보다 넓을 뿐 곡선이 들어오지 않으므로, #350 처럼 통째로 칠하지 않는 곳입니다.

      • 프레임마다 새로 할당하지 않습니다. 조각마다의 변별 값(150×4)과 맡긴 변은 미리 만든 배열에 채웁니다.

      • 띠 네 개의 넓이를 #350 과 견준 계산값입니다(파도 / 외곽선 / 패드, 1080×2400·420dpi, 기본 설정, 발광 끔). 엔진을 JVM 에서 300프레임 돌린 뒤의 띠 두께로 계산했고, 잰 값이 아닙니다. CPU 사용률과 프레임 시간은 폰에서 재야 합니다.

        소리 (좌/우) 세로 가로
        가운데 0.05 −31% / −19% / −17% −18% / −11% / −13%
        가운데 0.15 −16% / −14% / −21% −14% / −11% / −18%
        가운데 0.4 −11% / −10% / −20% −13% / −12% / −17%
        왼쪽으로 치우침 0.3/0.1 −29% / −26% / −27% −14% / −13% / −20%
        왼쪽만 0.3/0 −41% / −37% / −42% −28% / −25% / −33%
        뒤쪽만 (0.4/0.4 를 끊은 직후) −80% / −76% / −68% −58% / −55% / −51%

        발광(세기 50)을 켜면 번지는 폭(197px)을 모든 변에 더하므로 가운데 소리는 4% 이하, 왼쪽만은 파도·외곽선 −814%, 패드 −1333%(막대가 없는 오른쪽을 그리지 않습니다), 뒤쪽만은 −9~40% 입니다.

      • 넓이를 가장 작게 하는 맡기기는 아닙니다. 큰 가운데 소리에서는 위 띠를 더 두껍게 해 위쪽 둥근 모서리를 통째로 맡기고 긴 왼쪽·오른쪽 띠를 얇게 하면 더 줄어듭니다(세로 화면 파도, 0.4 에서 −11% 대신 −21%, 1.0 에서 −4% 대신 −27%). 그런 두께는 프레임마다 찾아야 해서 하지 않았습니다.

  • 120Hz 화면에서도 최대 60fps로 제한합니다. 설정의 그래픽 덜 자주 그리기를 켜면 30fps입니다. 엔진은 이 값을 VisualizerInputs.framesPerSecond()로 프레임마다 읽으므로 실행 중에 바꿔도 바로 적용됩니다.
    • 오버레이 창에서 화면 주사율을 요청하지 않습니다(#428 을 #431 로 되돌림).
      • 시도한 것: Galaxy S25+ 는 오버레이가 60fps 로 그려도 홈 화면 위에서 화면 전체를 120Hz 로 돌렸습니다. 그래서 오버레이 창에 WindowManager.LayoutParams.preferredRefreshRate = 60 을 걸었습니다. 손대지 않은 홈 화면에서는 60Hz 로 내려가 화면 합성 CPU 가 17.4% → 14.1% 로 줄었습니다.
      • 되돌린 까닭: 이 값은 화면 합성기에 고정 요청(ExplicitDefault)으로 들어갑니다. 안드로이드는 그런 층이 하나라도 있으면 화면을 만질 때 최고 주사율로 올리지 않습니다(touch boost). 그래서 시각화가 켜져 있는 동안 우리 앱을 포함한 폰 전체가 만지거나 넘길 때도 60Hz 에 머물러 끊겨 보였습니다.
      • 다시 시도한다면: 만질 때 주사율을 막지 않는 요청(예: 프레임 속도 범주 Normal)이 이 창에서 실제로 걸리는지, 화면을 만지는 동안의 주사율까지 재고 넣습니다.
      • Compose 의 Modifier.preferredFrameRate 는 이 창에서 화면 합성기까지 요청이 나타나지 않았습니다.
    • 실기기(Galaxy S21 Ultra, 음악 재생 60초)에서 스레드별로 잰 앱 CPU는 한 코어 기준 합계 22.9%였고, 그중 약 21%가 그리기(GPU 명령 생성·렌더링·경로 래스터화·그리기 계산)였습니다. AI 추론은 약 1%, 캡처는 0.2%입니다. 30fps로 바꾸면 17.2%(25% 절감)입니다. 프레임 수와 상관없는 비용이 섞여 있어 절반이 되지는 않습니다.
    • 소리가 없을 때 켜 둔 비용도 쟀습니다. Galaxy S25+, 릴리스 빌드, 홈 화면에서 1분씩 재서 꺼 둔 상태와 비교했고, 수치는 한 코어 기준입니다.
      • 늘어난 양은 약 56%입니다. 앱이 3.03.2%, 화면 합성이 약 1%, 오디오 서버·HAL 이 약 1.6% 입니다.
      • 앱 안에서는 메인 스레드(33ms 쉬기 확인과 0.5초 받지 못하는 소리 확인)가 1.2%, 캡처 스레드가 1.0%, 코루틴 타이머가 0.7% 입니다.
      • 대부분은 소리를 받는 한 드는 비용입니다. 쉬는 동안 오버레이 창을 숨기거나 확인 간격을 늘려도 줄일 수 있는 양은 1% 안팎이라 그때는 손대지 않았습니다.
      • 위 수치는 아래의 소리 신호로 바꾸기 전입니다(#170). 뒤에 스레드별 깨어남 횟수까지 세어 보니, 쉬는 동안의 33ms 확인이 한 번에 스레드를 네다섯 번 깨우고 있었습니다. Compose 루프 안의 delay 는 코루틴 타이머 스레드를 거치고, 돌아올 때 Compose 디스패처가 핸들러 메시지와 vsync 콜백을 둘 다 요청합니다. 그리는 것이 없는데도 초당 30번 vsync 를 요청한 셈입니다. 위의 "코루틴 타이머 0.7%" 가 이것입니다.
    • 늦게 온 위협음을 위한 1초 보관 버퍼(아래 8번)는 경과 시간으로 정규화한 프레임 수로 칸을 넘기므로 30fps에서도 1초 그대로입니다.
  • 쉬는 동안 조용하면 캡처 스레드가 소리를 알려 줄 때까지 잠듭니다(SoundWakeSignal, #170). 캡처 스레드는 버퍼를 넘길 때마다 원자 변수를 읽고, 오버레이가 쉬는 중이면 소리 크기를 한 번 더 읽어 깨울지 정합니다. 할당은 없습니다. 33ms 주기를 기다리지 않으므로 소리에 반응하는 시점도 빨라집니다.
    • 표시를 꺼 둔 종류의 소리가 계속 나거나, 늦게 올 위협음 판정을 위해 소리를 보관하는 동안(아래 8번)에는 지금처럼 33ms 마다 확인합니다(VisualizerEngine.canSleepUntilLoud). 그때 소리 신호로 깨우면 버퍼마다(약 12ms) 깨어나 오히려 잦아집니다. 이 확인은 Compose 디스패처 대신 메인 스레드 핸들러(Dispatchers.Main)에서 기다려, 확인 한 번에 한 번만 깨어납니다.

    • 확인하기 직전부터 들어온 버퍼만 셉니다. 마지막 소리 크기만 보고 깨우면, 캡처가 멈춰 그 값이 크게 남았을 때 오버레이가 잠들지 못하고 메인 스레드에서 쉬지 않고 돕니다.

    • 릴리스 빌드, 개발자 모드 꺼짐, 홈 화면에서 60초씩 잰 앱 전체 값입니다. 폰은 소리를 낼 수 없어 조용할 때만 쟀고, 숨긴 소리는 에뮬레이터 안의 톤 재생기로 냈습니다.

      바꾸기 전 바꾼 뒤
      Galaxy S25+ 조용할 때 CPU (한 코어 기준) 3.07~3.25% 1.37~1.40%
      Galaxy S25+ 조용할 때 초당 깨어남 328~343 (메인 145, 타이머 120, 캡처 55) 82~83 (메인 4, 타이머 9, 캡처 53)
      Pixel 7 에뮬레이터(Android 17) 조용할 때 CPU 1.6% 0.55%
      에뮬레이터 조용할 때 초당 깨어남 196 (메인 75, 타이머 60, 캡처 47) 77 (메인 4, 타이머 8, 캡처 47)
      에뮬레이터 숨긴 소리가 날 때 초당 깨어남 210 (메인 83, 타이머 60) 108 (메인 32, 타이머 8)

      S25+ 에서는 앱의 대기 비용이 절반 넘게(약 1.8%p) 줄었고, 남은 것은 대부분 소리를 받는 캡처 스레드입니다. 숨긴 소리가 날 때의 CPU 는 소리가 있으면 도는 AI 추론이 대부분이라 덜 줄었습니다(에뮬레이터 2.20초 → 1.78초).

  • 화면이 꺼져 캡처를 쉬는 동안(SettingsManager.isCapturePaused)에는 쉬기(idle) 상태의 확인도 멈추고 켜질 때까지 기다립니다. 화면이 꺼져도 이 루프는 저절로 멈추지 않기 때문입니다. 소리가 나던 중에 쉬기 시작해도 캡처 서비스가 소리 크기를 지우므로, 엔진이 1초 남짓 뒤 쉬기로 내려와 거기서 멈춥니다.
  • 오버레이 창이 가려진 동안(화면 꺼짐·잠금 화면)은 쉬고, 잠금이 풀리면 창을 다시 보이게 한 뒤 다시 그립니다(#401, #400, OverlayVisibility).
    • 잠금 화면이 뜨면 안드로이드는 다른 앱의 오버레이 창을 숨깁니다. 여기까지는 정상입니다.
    • Galaxy S25+(Android 17)에서 결함 두 가지가 있었습니다. 둘 다 우리 앱 화면을 열기 전까지 오버레이가 보이지 않고 프레임도 오지 않았습니다(초당 0장).
      • 잠금을 풀어도 창을 다시 보이게 하지 않았습니다. dumpsys window 에서 창이 mPolicyVisibility=false, isVisible=false 로 남았습니다.
      • 창이 숨겨진 동안 오버레이가 새 프레임을 요청하면(조용해서 쉬다가 잠금 화면에서 소리에 깨어날 때 등), 잠금을 풀고 창이 다시 보여도 그 요청이 끝나지 않았습니다.
    • OverlayService 가 화면 켜짐·꺼짐과 잠금 풀림(USER_PRESENT)을 받아 가려졌는지 정합니다(OverlayVisibility).
      • 화면이 꺼지면 가려집니다.
      • 화면이 켜져도 잠금 풀림 방송이 올 때까지 가려진 채 기다립니다. 잠금 화면이 없는 기기도 화면이 켜지면 이 방송이 옵니다.
      • 자동 잠금 유예 시간 안에 화면을 다시 켜면 잠금 화면이 뜨지 않아 이 방송이 오지 않습니다. 이때는 아래의 잠금 확인으로 약 0.5초 뒤에 다시 그립니다(#437, 확인 간격이 1초였을 때는 약 2초). 화면이 켜질 때 바로 그리게 하면 아래처럼 S25+ 에서 멈췄으므로 바로 그리지는 않습니다.
      • 화면이 켜질 때 잠금이 이미 풀려 있어도(지문 등) 바로 다시 그리지 않습니다. 잠금 상태가 '풀림'으로 바뀐 뒤에도 안드로이드가 창을 다시 보이게 하는 데는 조금 걸리고, S25+ 에서 그 사이에 다시 그리게 하면 멈췄습니다.
      • 잠금 풀림 방송이 오지 않아도 쉰 채 남지 않게, 기다리는 동안 0.25초마다 잠금을 확인합니다(OverlayVisibility.LOCK_CHECK_MS). 두 번 연달아 풀려 있을 때만 다시 보이게 합니다. 처음 풀린 것을 본 뒤 한 간격을 더 기다리는 것이 잠금이 풀리는 도중에 다시 그리지 않을 여유입니다.
        • S25+(Android 17) 확인(#437): 잠금 풀림 방송을 일부러 무시한 시험용 빌드로, 모든 잠금 해제가 이 확인으로만 다시 그리게 했습니다. 지문으로 3번, 패턴으로 2번 풀었고 모두 약 0.5초 뒤 멈추지 않고 다시 그렸습니다. 그중 한 번은 화면이 켜질 때 이미 풀려 있던 경우입니다. 유예 시간 안에 다시 켠 경우는 고친 빌드에서 0.5초, 고치기 전 빌드에서 약 2초 뒤에 다시 그렸습니다.
      • 잠금 풀림은 시스템 UI 가 보내므로 내보낸 수신기로 받습니다(#385).
    • 다시 보일 때는 먼저 같은 배치(updateViewLayout)를 다시 요청합니다. 그러면 WindowManager 가 잠금 화면 정책을 다시 따져 창을 보이게 합니다. 그 뒤에 오버레이가 다시 그립니다.
    • 가려지면 오버레이는 바로 쉬고(VisualizerEngine.sleepNow), 다시 보일 때까지 새 프레임을 요청하지 않습니다. 캡처를 쉬지 않을 때(외부 사운드 모드, 화면이 꺼지면 일시정지 끔)도 마찬가지입니다.
      • 그리던 것은 지우고, 보관한 피크(아래 8번)도 비웁니다. 다시 보일 때 가려지기 전 모양이 남거나, 지난 소리가 다시 보인 뒤의 위협음 판정으로 번쩍이지 않게 하기 위해서입니다.
      • 다시 보이면 가려진 동안 쌓인 소리 크기를 먼저 버립니다(VisualizerEngine.onUncovered, #433). 소리 크기는 마지막으로 읽은 뒤의 최대값으로 쌓이는데 가려진 동안에는 아무도 읽지 않습니다. 버리지 않으면 다시 보인 뒤 첫 확인이 가려진 동안의 가장 큰 소리를 지금 소리로 보고, 조용한 방에서 지난 소리를 그립니다.
      • 그 뒤 33ms 안에 그때의 소리를 확인하고, 소리가 나면 평소처럼 0 에서 자랍니다.
    • 화면이 꺼진 동안에도 프레임 신호(vsync)를 초당 60번 보내는 버전에서는, 보이지 않는 그림을 계속 계산하던 것도 멈춥니다(#400). AOSP 14 의 SurfaceFlinger 가 그렇게 되어 있습니다.
      • Android 10 에뮬레이터(폰 소리 모드, 화면이 꺼지면 일시정지 끔, 핑크 노이즈 재생 중, 화면 꺼짐 60초)에서 앱 CPU 가 7.85% → 3.47% 로 줄었습니다. 메인 스레드는 4.08% → 0.05% 입니다.
      • 기본 설정(일시정지 켬)의 폰 소리 모드는 화면이 꺼지면 캡처가 멈춰, 고치기 전에도 1초 남짓 뒤 쉬었습니다. 위 숫자는 캡처가 이어지는 경우의 것입니다.
      • S25+(Android 17)는 화면이 꺼진 동안 프레임 신호를 보내지 않아 원래 쉬고 있었습니다(메인 스레드 0.36%).
    • 진동과 잠금 화면 위협음 알림은 그대로입니다. 오버레이가 아니라 캡처 서비스가 소리 크기와 AI 결과를 직접 보기 때문입니다.

개발자 모드 (AiDebugOverlay, AiDebugText)

설정 탭 자세한 설정 맨 아래의 개발자 모드(기본 꺼짐)를 켜면 오버레이 왼쪽 위에 AI 분류 결과가 뜹니다. 팀이 영상·게임을 틀어 놓고 보면서 분류가 맞는지 채점하는 도구입니다. 배포판에서는 AI_RESULT 로그가 남지 않으므로(debuggable 빌드 전용), 기기에서 무슨 일이 일어나는지 볼 방법이 이것뿐입니다.

DANGER ● Alarm 0.12
thr Y   age 0.2s
cue Y
lvl 0.14   shown Y   65ms (12/48)
1 Air horn, truck horn       0.12
2 Sound effect               0.10
3 Buzzer                     0.09
4 Alarm                      0.09
5 Vehicle horn, car horn, h… 0.08

라벨은 번역하지 않고 모델이 말한 그대로(영어 YAMNet 클래스명) 보여줍니다. 정확도를 채점하려면 모델이 실제로 뭐라고 했는지를 봐야 하기 때문입니다. 확신도가 낮을 때도 숨기지 않습니다.

무엇을 왜 보여주는지:

표시 없으면 생기는 오독
thr (meetsThreshold) 임계값을 못 넘는 동안 확정값이 전부 얼어붙습니다. 이게 없으면 "AI가 못 잡는다"와 "임계값을 못 넘는다"를 구분할 수 없습니다
age (결과가 나온 뒤 흐른 시간) 무음이면 추론을 건너뛰고 마지막 결과를 그대로 들고 있습니다. 나이가 자라는 것이 "지금 추론이 돌지 않는다"는 신호입니다
cue (dangerCuePromoted) YAMNet top-5의 경보·사이렌·폭발 신호를 위험으로 보존한 경우입니다
top-5 (순위·이름·확률) 모델이 1위만이 아니라 무엇을 함께 들었는지. 1위가 경보가 아니어도 4위의 Alarm으로 위험이 될 수 있습니다. 한 줄에 하나씩, 긴 이름은 26자에서 줄여 확률이 한 열에 서게 합니다
lvl·shown 진동 게이트 값과 그 종류의 표시 설정. "왜 진동이 안 울렸나", "왜 아무것도 안 그려지나"를 AI 탓으로 돌리지 않게 합니다
소요시간 주기는 시작에서 시작까지 250ms라 소요시간만큼 늘지 않습니다. 소요시간이 150ms를 넘으면 최소 100ms를 쉬므로 그때부터 주기가 totalMs + 100ms로 늘어납니다. "AI가 늦다"가 사실은 "이 기기가 느리다"인 경우를 가릅니다
  • confidence는 라벨이 확정된 순간의 값(confirmedConfidence)이라 몇 초 전 값일 수 있습니다. 현재 프레임의 확신도는 결과에 실려 있지 않고 meetsThreshold 불린으로만 나옵니다.

결과를 파일로 기록 (AiDebugCsv, AiDebugLogWriter, AiDebugRecorder, #193): 개발자 모드 아래의 딸린 스위치(기본 꺼짐)를 켜면, HUD 가 읽는 그 결과를 CSV 로 남깁니다. getExternalFilesDir()/ai-log/ai-<날짜-시각>.csv 에 쌓이고 adb pull /sdcard/Android/data/com.example.soundvisualizer/files/ai-log/ 로 받습니다. 각 행의 capture_source는 InternalPlayback 또는 Microphone이며, HUD가 결과를 큐에 넣을 때 고정해 비동기 파일 writer가 다음 실행의 소스를 잘못 적지 않습니다(#253).

  • 왜 파일인가. HUD 는 지금 순간만 보여주고, AI_RESULT 로그는 debuggable 빌드에만 남으면서 2초에 한 줄로 묶입니다. 추론은 초당 네 번 도니 총소리 한 발(30~60ms)처럼 짧은 소리는 로그 사이로 빠집니다. 이 기록은 배포판에서도 남고 추론 주기를 그대로 따라갑니다.
  • 디스크는 쓰는 스레드에서만 만집니다. 부르는 곳이 오버레이의 HUD 루프(메인 스레드)라서, 거기서 폴더를 확인하거나 파일을 열거나 쓰면 그리는 프레임이 밀립니다. offer() 는 큐에 넣고 바로 돌아오고, 큐가 차면 가장 오래된 줄을 버립니다(버린 수는 dropped 로 남습니다). 기록이 밀리는 것보다 화면이 밀리는 것이 나쁩니다.
    • 파일 경로를 정하는 일까지 쓰는 스레드가 합니다. getExternalFilesDir() 은 경로 계산만 하지 않고 대상 폴더를 확인하고(stat) 없으면 만듭니다(#208). 그래서 currentFile 은 켠 직후 잠깐 null 입니다.
    • 파일은 첫 줄이 올 때 만듭니다. 켜 보고 바로 끈 기기에 빈 파일이 남지 않습니다.
  • 켠 회차마다 큐를 따로 둡니다. stop() 은 쓰는 스레드가 끝나기를 기다리지 않습니다 — 메인 스레드를 잡을 수 없기 때문입니다. 그래서 껐다 곧바로 켜면 옛 스레드가 아직 살아 있는데, 큐가 하나면 두 스레드가 그것을 나눠 먹어 줄이 두 파일로 갈렸습니다(#208). 회차마다 큐가 따로면 옛 스레드는 자기 줄만 비우고 나가고(끌 때 남은 줄을 잃지 않습니다), 새 줄은 새 스레드만 봅니다. 상한이나 열기 실패로 스스로 그만둘 때도 자기 회차만 닫습니다.
  • 같은 결과는 한 번만 적습니다. 폴링은 100ms, 추론은 250ms 주기라 같은 결과를 두세 번 보게 되는데, timestampMs 가 같으면 건너뜁니다.
  • 20MB 에서 멈춥니다. 초당 네 줄이면 하루쯤 담기는 양이고, 켜 둔 것을 잊은 기기에서 저장 공간을 먹는 것을 막습니다.
  • 이름에 든 쉼표. YAMNet 클래스명에는 쉼표가 들어갑니다(Gunshot, gunfire). 칸을 따옴표로 감싸지 않으면 그 줄만 칸이 밀려 표 전체가 어긋납니다. 숫자 서식은 Locale.US 로 고정합니다 — 앱이 기본 로캘을 사용자가 고른 언어로 바꾸므로, 그대로 두면 아랍어에서 소수점이 아랍 숫자로 나갑니다.
  • 한 줄에 적는 것: 시각과 결과 시각·나이, 3분류, 모델이 말한 이름, 확신도, 임계값을 넘었는지, 경보 신호 승격, top-5 이름과 확률, 그때의 소리 크기, 그 종류 표시 여부, 전처리·YAMNet·전체 소요 시간입니다. 임계값 자체는 결과에 실리지 않아(후처리 안에만 있습니다) 넘었는지만 적습니다.
  • 그리기는 **오버레이 창 안의 형제 Compose Text**입니다. 엔진의 무할당 그리기 경로는 건드리지 않습니다. 다만 상태 읽기를 AiDebugOverlay 안에 가둬야 합니다. 바깥에서 읽으면 오버레이 전체가 초당 4회 리컴포지션되고 Canvas의 그리기 람다가 매번 새로 만들어집니다.
  • 갱신은 100ms 폴링입니다. 추론이 250ms 주기라 같은 주기로 읽으면 지터 때문에 같은 결과를 두 번 읽고 다음 결과 하나를 통째로 건너뜁니다. 총성 한 발이 만드는 250ms짜리 danger 틱 하나가 정확히 이 도구가 잡아야 할 사건입니다(진동 알림이 100ms인 것과 같은 이유). 화면이 꺼져 쉬는 동안에는 폴링도 멈춥니다.
  • 글자를 넣어도 터치 통과는 깨지지 않습니다. 그 성질은 창 플래그(FLAG_NOT_TOUCHABLE)에 걸려 있습니다. 같은 이유로 이 표시를 별도 창으로 띄우면 안 됩니다. 불투명한 두 번째 오버레이 창은 그 영역의 "신뢰할 수 없는 터치 차단"을 다시 부릅니다.
  • Android 12 이상에서는 창 알파(약 0.8)가 곱해지므로 판은 완전히 불투명한 검정으로 둡니다. 안쪽에 반투명을 쓰면 알파가 두 번 곱해져 흐려집니다.
  • 화면 읽어주기는 이 표시를 건너뜁니다(clearAndSetSemantics). 개발용 글자라 정작 이 앱 사용자에게는 소음입니다.
  • 줄 만들기는 AiDebugText라는 순수 로직으로 빼 기기 없이 검사합니다(AiDebugTextTest). 확정 이름이 빈 경우, 뒤로 간 시계, 긴 top-5 이름, 그리고 앱 언어 때문에 숫자가 아랍 숫자로 나오는 것까지 고정합니다.
  • 한 줄은 58자까지입니다(AiDebugText.HUD_MAX_COLUMNS). 폭 411dp 폰을 글꼴 크기 기본값으로 쓸 때 글자 영역이 383dp 이고, 고정폭 글꼴 11sp 는 글자당 6.6dp 입니다. 넘으면 줄이 두 줄로 꺾여 읽기 어려워집니다. 100초가 넘은 결과의 나이는 123s·17m 처럼 짧게 씁니다(#165).
  • 글꼴은 앱에 넣은 고정폭 글꼴(AppMonospace, UiFonts.kt)입니다. 시스템의 FontFamily.Monospace 를 쓰면 삼성 One UI 가 비례폭으로 덮어써서, 값이 바뀔 때 자리가 흔들리고 top-5 확률이 한 열에 서지 않습니다. Galaxy S25+ 에서 lvl 0.00 의 글자 간격이 11.5~19px 로 들쭉날쭉했고, 같은 코드가 Pixel(AOSP) 에서는 17px 로 고르게 나왔습니다(#162). 넣은 글꼴은 AOSP 의 DroidSansMono.ttf 를 고치지 않은 것이고, 도움말의 라이선스 고지와 제보용 기기 정보도 같은 글꼴을 씁니다. AppMonospaceFontTest 가 이 글꼴이 정말 고정폭인지, HUD 가 찍을 수 있는 글자(YAMNet 클래스 이름 521개, 말줄임표 포함)가 모두 들어 있는지 확인합니다. 빠진 글자는 그 글자만 시스템 글꼴로 그려져 그 줄이 다시 비례폭이 됩니다.

한 프레임의 계산 (VisualizerEngine.tick)

  1. 피크 읽기: 새 버퍼가 왔으면 그 피크를, 아니면 이전 값을 프레임마다 0.87배로 줄입니다.
  2. 8방향 합성: 좌우 2채널을 Mid/Side로 나눠 화면 둘레 8방향(상단 중앙, 우상단, 우측, 우하단, 하단 중앙, 좌하단, 좌측, 좌상단)의 세기로 펼칩니다. 공간 리플 효과가 켜져 있으면 뒤쪽 방향은 몇 프레임 늦은 값을 써서 앞에서 뒤로 퍼지는 느낌을 냅니다.
  3. 스무딩: 전체 크기(민감도)와 방향 분포(속도)를 따로 따라갑니다. 계수는 60fps 기준으로 시간 보정해 90/120Hz 화면에서도 같은 느낌이 나게 합니다.
  4. 깊이: 크기 100%가 화면 중앙 한계선에 닿도록 방향별 깊이(px)를 정합니다.
  5. 색·진하기·광원: AI 라벨에 맞는 색으로 즉시 바꾸고(위협음은 서서히 물드는 것보다 바로 뜨는 편이 낫습니다), 그 종류의 표시가 꺼져 있으면 그리지 않습니다. 기본색은 환경음 흰색, 사람 소리 노란색, 위협음 빨간색입니다.
  6. 도형: 모드에 따라 파도·외곽선(화면 둘레를 따라가는 곡선), 패드(가장자리 막대), 원형(모서리 파문)을 계산합니다.
  7. 쉬기(idle): 1초 넘게 아무것도 안 보이고, 조용하거나 지금 설정으로는 그릴 수 없으면(표시 꺼짐, 진하기·크기 0) 프레임 루프를 멈추고 쉽니다. 조용하면 캡처 스레드가 소리를 알려 줄 때까지 잠들고, 표시를 꺼 둔 소리가 나거나 소리를 보관하는 중이면 33ms 간격으로 확인합니다. 소리가 나고 그릴 수 있게 되면 다시 프레임 루프로 돌아옵니다.
  8. 늦게 온 위협음 판정: AI 판정은 0.25초 주기에 추론 시간까지 더해 소리보다 늦게 옵니다. 그래서 위협음만 보이게 해 두면 총소리 같은 짧은 소리는 먼저 숨긴 라벨(환경음 등)로 들어와 그려지지 않고, 판정이 올 때는 잔향만 남습니다. 이를 막으려고 표시가 꺼진 라벨 때문에 그리지 못한 피크를 약 1초(33ms 칸 30개, 고정 크기 링 버퍼) 보관합니다. 쉬는 중이든 프레임 루프 중이든 같습니다.
    • 라벨이 위협음으로 바뀌어 표시 대상이 되면 보관한 소리로 전체 크기와 방향을 바로 잡습니다. 쉬는 중이면 소리가 이미 끝났어도 깨어납니다. 이후에는 평소처럼 민감도 속도로 줄어들고, 한 번 쓴 피크는 비워서 같은 소리로 두 번 번쩍이지 않습니다.
    • 시작 크기는 그 종류를 제때 표시했다면 그렸을 크기입니다. 보관한 칸을 오래된 것부터 평소 스무딩(민감도)에 다시 흘려 가장 크게 도달한 값을 쓰고, 방향은 가장 큰 피크를 따릅니다. 피크를 그대로 전체 크기로 쓰면 소리가 1초 넘게 이어질 때의 크기라서, 짧은 총성마다 크기 한도까지 뛰고 같은 소리가 직전 라벨에 따라 열 배 넘게 다르게 그려집니다.
    • 그래도 가장 센 방향의 깊이가 8dp에 못 미치면 거기까지 올립니다. 판정까지 난 위협음이 안 보이면 안 되기 때문입니다. 다만 같은 소리가 계속 날 때보다 크게 그리지는 않고, 크기 고정 모드는 전체 크기가 깊이가 아니라 진하기에 쓰이므로 올리지 않습니다.
    • 위협음이 아닌 라벨로 바뀔 때는 쓰지 않습니다. 숨긴 사람 소리가 끝나고 환경음으로 돌아올 때 지나간 말소리가 환경음으로 번쩍이면 안 되기 때문입니다.
    • 숨겨서 놓친 소리가 없는 보통의 깨어남은 0에서 민감도 속도로 커집니다.

VisualizerEngine은 바깥 입력(피크, 설정, 라벨, 색)을 VisualizerInputs 인터페이스로만 받습니다. 실제 구동은 LiveVisualizerInputs, 테스트(VisualizerEngineTest)는 가짜 입력을 넣어 기기 없이 검증합니다.

가짜 입력을 쓰면 실제 배선은 한 줄도 지나가지 않습니다. 그래서 LiveVisualizerInputs가 라벨을 제 색·표시 여부로, 모드를 제 설정으로 잇는지는 LiveVisualizerInputsTest가 따로 고정합니다. 이 배선이 어긋나면 위협음이 사람 소리 색으로 그려지는데, 엔진 테스트와 AiLabelContractTest(라벨 문자열이 ai/와 같은지 보는 쪽)는 둘 다 통과합니다.

튜토리얼 그림 (tutorial/)

처음 열 때의 튜토리얼(6장·10장)은 쪽마다 가로로 눕힌 폰 그림 안에 이 엔진을 그대로 돌립니다. 소리는 TutorialScript가 시각마다 정해 둔 대본(좌우 피크와 라벨)이고, TutorialDemoInputs가 그것을 VisualizerInputs로 넘깁니다. 그래서 그림은 앱이 실제로 그리는 모양 그대로입니다.

  • 실제 소리·실제 오버레이와 섞이지 않습니다. 입력은 반드시 TutorialDemoInputs로 넘깁니다(생성자 기본값은 실제 소리입니다). 쉬는 동안에도 pollWake만 부르고 OverlayWake는 쓰지 않습니다. 그 신호를 기다리는 쪽은 실제 오버레이 하나뿐이라, 튜토리얼이 기다리면 켜져 있던 오버레이가 깨어나지 못합니다.
  • 튜토리얼은 모드와 설정이 고정입니다. 파도 모드에 기본값보다 크기(55)·진하기(80)·민감도(40)를 올린 설정을 씁니다. 폰 그림이 실제 화면보다 훨씬 작아 기본값으로는 어두운 장면 위의 빨간 위협음이 흐리고 박자가 묻힙니다. 사용자가 꺼 둔 종류도 그립니다. 색만은 사용자가 고른 색입니다(설정 탭에서 보는 색과 범례가 같아야 합니다).
  • 쉬운 설정의 모양 카드(#408, ModePreviewPhone)도 같은 폰 그림과 엔진을 씁니다. 다른 점은 이렇습니다.
    • 카드마다 그 모드를 사용자의 그 모드 설정으로 그립니다(TutorialDemoInputs의 mode·settings). 크기 단계를 바꾸면 그 모드의 카드가 새 크기로 다시 만들어집니다. 다만 진하기와 민감도는 튜토리얼 그림만큼은 올립니다(previewSettings). 작은 그림에서 기본값 그대로면 흐리기 때문입니다. 크기는 그대로라 크기 단계의 차이는 그대로 보입니다.
    • 대본은 종류 쪽(TutorialScene.Types)이고, 멈춘 그림은 오른쪽으로 기운 위협음입니다. 카드가 작아서 짧은 변 240dp 폰을 줄인 것으로 보아 띠와 선을 굵게 그리고(PREVIEW_REFERENCE_SHORT_SIDE_DP), 폰 테두리와 모서리도 너비에 맞춰 줄입니다(compactFrame). 줄이지 않으면 작은 폰이 알약 모양이 되고 화면 네 귀퉁이의 그림이 잘립니다. 패드는 막대 두께가 화면에 대한 비율(파도 깊이의 4분의 1)이라 이 비율과 상관없이 가장 은은하게 보입니다.
    • 잠깐만 움직입니다. 평소에는 네 장 모두 멈춘 그림이고, 선택한 카드만 대본의 처음부터 4.9초(PREVIEW_PLAY_SEC) 움직인 뒤 멈춘 그림으로 돌아갑니다. 환경음 → 사람 소리 → 첫 위협음까지라 멈춘 그림(위협음)과 이어집니다. 카드를 선택하거나, 선택한 카드를 다시 누르거나, 크기를 바꾸거나, 설정 탭에 들어올 때 움직입니다. 5초를 넘지 않아 멈춤 단추를 두지 않습니다(WCAG 2.2.2). 오래 도는 그림이 없어 설정 탭을 보는 동안 덜 일합니다(실증에서 나온 발열).
    • 설정 탭이 정말 멈춘 쪽일 때만(restingPage == 1, 페이저는 옆 탭을 미리 그려 둡니다), 시스템 애니메이션이 켜져 있을 때만 움직입니다. 애니메이션 설정은 액티비티를 다시 만들지 않고 바뀌므로(접근성의 애니메이션 삭제 등) 한 번만 읽어 두지 않고 움직이기 시작할 때마다 읽습니다. 움직이는 도중에 설정 탭에서 벗어나면 바로 멈춘 그림으로 돌아갑니다. 앱을 내리면 프레임이 오지 않아 멈춥니다. 1초에 30번쯤만 다시 그립니다.
    • 그 자리에서 멈추지 않고(튜토리얼, #331) 엔진을 멈춘 장면까지 다시 미리 돌립니다(stillKey). 그 자리에서 서면 쉬는 박자의 빈 화면이 남을 수 있기 때문입니다.
    • 카드들이 높이를 맞추려고 크기를 미리 물어보므로(IntrinsicSize.Min), 그림의 너비는 부르는 쪽이 정해 넘깁니다. 그림 안에서 BoxWithConstraints로 재면 그 물음에 답하지 못해 앱이 멈춥니다(처음 만들 때 실제로 멈췄습니다).
    • 진동은 넘기지 않습니다. 정해 둔 동안만 움직이고 스스로 멈추는지, 움직일 수 없게 되면 바로 멈추는지는 ModePreviewInstrumentedTest가 가짜를 넘겨 프레임을 세어 봅니다.
  • 크기: 폰 그림의 화면을 짧은 변 411dp 폰의 가로 화면을 줄인 것으로 보고 엔진의 density를 그 비율로 줄입니다. 여백·두께·보이기 시작하는 깊이가 실제 화면을 줄인 비율로 맞고, 어느 기기에서나 같은 그림이 됩니다.
  • 움직이는 동안만 돕니다. 쪽마다 엔진 하나를 그 쪽이 말하려는 장면(TutorialScene.stillAtSec)까지 미리 돌려 둡니다(백그라운드, 쪽마다 한 번, #318). 페이저가 옆 쪽을 미리 그려 두는 동안과 애니메이션을 꺼 둔 사람에게는 그 한 장이 보입니다. 미리 돌릴 때는 크기(setSurfaceSize)를 먼저 알립니다. 모른 채 돌리면 깊이가 거의 0으로 굳습니다.
    • 그 쪽에 들어와 멈추면(페이저가 정말 멈춘 뒤, rememberRestingPage) 그 엔진이 그 장면에서 이어서 움직입니다. 멈추면(쪽을 넘김·멈춤 버튼) 그 자리에서 멈추고, 다시 움직이면 멈춘 시각에서 이어 갑니다(#331).
    • 예전에는 쪽이 멈출 때마다 새 엔진으로 대본을 0초부터 틀었습니다. 그래서 넘기는 동안 보이던 장면(예: 종류 쪽의 빨간 위협음과 범례 표시)이 손을 떼는 순간 빈 화면으로 툭 바뀌었습니다. 사용자가 "툭툭 끊김"으로 느낀 것이 이것이었고, S25+ 에서 프레임 시간을 재 봐도 끊김은 없었습니다(#322 댓글). 화면 녹화로 찾았습니다.
    • 멈췄다 다시 움직인 첫 틱은 프레임 간격이 길지만, 엔진이 한 틱의 걸음을 네 프레임까지로 자르므로 그림이 튀지 않습니다.
  • 루프는 withInfiniteAnimationFrameNanos로 돕니다. 앱에서는 withFrameNanos와 같고, UI 테스트는 이런 끝없는 애니메이션을 멈춰 두므로 화면이 한가해질 수 있습니다. 120Hz 화면에서도 1초에 60번쯤만 다시 그립니다.
  • 진동 쪽은 폰도 실제로 울립니다(#323). 그림이 프레임마다 대본 시각을 TutorialHaptics에 넘기고, 그림 속 폰이 떨기 시작하는 프레임(TutorialScript.vibrationPulse의 번호가 바뀌는 프레임)에서 처음 설정의 위협음 진동 한 박자(0.2초)를 한 번 보냅니다.
    • 그래서 보이는 떨림과 느끼는 진동이 어긋나지 않고, 그림이 멈추면(쪽 넘김·멈춤 버튼·앱을 내림) 더 울리지 않습니다. 멈출 때는 울리던 것도 끊습니다.
    • 그림처럼 사용자의 지금 설정이 아니라 처음 설정(중간, 100%)을 보여 줍니다. 실제 알림이 한 박자에 보내는 것(HapticShapes.steady)과 같은 울림입니다.
    • 설정 미리보기와 같은 길(HapticPreviewGate)로 울려, 그동안 실제 진동 알림이 끼어들지 못합니다. 시각화가 실행 중이면 울리지 않습니다. 진동기는 앱에 하나라 튜토리얼 진동이 실제 위협음 진동을 끊을 수 있기 때문입니다(미리보기와 같은 규칙, #244). 진동 모터가 없는 기기는 그림만 떱니다.
    • 그림 루프는 진동과 소리를 TutorialFrameFollower(프레임마다 onFrame(t), 멈출 때 stop())로만 압니다. 언제 울리는지는 TutorialHapticsTest(JVM)가 프레임을 흘려 보고, 그림이 멈춰 둔 장면에서 이어서 프레임을 넘기고, 멈출 때 멈추라고 알리고, 다시 움직이면 멈춘 시각에서 이어 가는지는 TutorialFrameFollowerInstrumentedTest가 가짜를 넘겨 몇 프레임만 봅니다. 테스트 시계는 16ms 마다 실제 엔진으로 한 프레임씩 그려서, 몇 초를 넘기면 느린 에뮬레이터에서 그만큼 오래 걸리기 때문입니다.
    • 시험 폰은 진동이 나면 안 되므로 실제 느낌은 사람이 확인합니다.
  • 소리 켜기(#327): 멈춤 버튼 옆의 소리 버튼을 누르면 그림이 그리는 소리를 실제 녹음으로 함께 냅니다. 처음에는 꺼져 있고, 튜토리얼을 다시 열면 다시 꺼진 채로 시작합니다.
    • 소리는 TutorialScript.cues가 정합니다. 대본의 박자·구간과 같은 순간에, 그림이 그 순간 그리는 종류의 소리를, 그림이 기우는 쪽(좌우 크기)으로 냅니다. 첫 쪽은 쿵마다 새 울음, 방향 쪽은 박자마다 왼쪽·오른쪽 새 울음, 종류 쪽은 새소리 → 대화 → 경적 세 번, 진동 쪽은 위협음이 터질 때 경적 두 번입니다. 대본의 상수(박자, 좌우 기울기)를 같이 쓰므로 대본을 고치면 소리도 따라갑니다.
    • 그림이 프레임마다 대본 시각을 TutorialSounds에 넘기고, 그사이 지난 소리를 냅니다. 0.15초보다 늦게 알게 된 소리는 내지 않습니다. 앱을 내렸다 돌아오면 그림의 시각이 건너뛰는데, 그사이의 소리를 몰아 내지 않기 위해서입니다. 앱을 내리면(ON_STOP) 나던 소리도 멈춥니다.
    • 소리는 SoundPool 하나(TutorialSoundPlayer)가 냅니다. 소리를 켤 때 네 조각(합쳐 약 60KB, res/raw/tutorial_*.ogg)을 읽고 끄거나 닫으면 놓습니다. 미디어 볼륨을 따르고, 우리 캡처가 듣지 않게 캡처를 막아 둡니다(ALLOW_CAPTURE_BY_NONE).
    • 시각화가 실행 중이면 소리를 내지 않습니다. 외부 사운드 모드에서는 마이크가 튜토리얼 소리를 듣고 실제 오버레이와 진동이 반응하기 때문입니다(튜토리얼 진동과 같은 규칙).
    • 녹음은 모두 CC0 1.0 이거나 퍼블릭 도메인입니다(새소리: Joseph SARDIN, 대화: VOA Learning English, 경적: Freesound #461679). 원본, 라이선스, 자른 위치는 NOTICE에 있습니다. 대화는 영어 인사말입니다. CC0 로 풀린 한국어 대화 녹음은 찾지 못했습니다.
    • 소리가 대본의 종류·박자·쪽과 맞는지는 TutorialScriptTest, 제때 한 번씩 내고 늦은 소리는 건너뛰는지는 TutorialSoundsTest가 봅니다. 그림 루프와의 연결(움직이는 도중에 켠 소리도 다음 프레임부터 받고, 멈출 때 멈추라는 알림을 받는지)은 TutorialFrameFollowerInstrumentedTest가 봅니다.
  • 대본이 말하려는 것이 엔진을 거쳐서도 그렇게 그려지는지(왼쪽 소리는 왼쪽이 깊다, 위협음은 위협음 색이다, 첫 쪽의 쿵이 충분히 커졌다 작아진다, 멈춘 장면이 비어 있지 않다)는 TutorialScriptTest가 봅니다. 엔진의 기본값이나 계산이 바뀌어 그림이 밋밋해져도 여기서 걸립니다.

5. 진동 알림 (feedback/)

코드 역할
HapticNotifier 실행 루프. 라벨과 소리 크기를 읽어 HapticPolicy·HapticDriver에 맡기고, 나온 명령을 한 곳(issue)에서 보냄
HapticPolicy 지금 어느 종류의 진동을 울려야 하는지 판단하는 순수 로직
HapticDriver 울려야 할 진동을 시각에 맞춘 명령(울림 하나, 끊기)으로 바꾸는 순수 로직. 다음에 깨어날 시각도 정함
HapticShapes 한결같은 울림, 설정 미리보기, 꺼짐 알림의 모양
HapticPlan 한 번에 보내는 파형
HapticTuning 진동의 모든 수치. 폰에서 느껴 보고 고칠 곳은 여기 하나
HapticPlayer 계획을 진동기로 보냄. 모든 vibrate()가 한 잠금 안에서 닫혔는지 본 뒤에만 나감
HapticPreviewGate 설정 미리보기와 튜토리얼 진동 쪽의 진동(#323)을 울리고, 그동안 실제 진동 알림이 끼어들지 못하게 막음
HapticSettings 종류별 방식(꺼짐·느림·중간·빠름·연속)과 세기(10~100%). 외부 사운드 모드의 종류별 상한은 externalCap, 그 상한을 건 실제로 울릴 설정은 inExternalSound(#354)
SelfVibrationGate 외부 사운드 모드에서 앱이 울린 진동이 섞였을 수 있는 크기를 진동 판단에서 거르는 순수 로직(#290)
SelfVibrationTimeline 외부 사운드 모드의 AI 입력이 뺄 우리 울림·미리보기 구간의 최근 목록. 쓸 때마다 새 배열로 바꿔 끼워 캡처 스레드가 잠그지 않고 읽음(#352)
AiFeedFilter 외부 사운드 모드에서 AI 에 넘기기 직전에 그 구간을 바로 앞의 깨끗한 소리로 채우는 순수 로직(#352)
AiLabelFreshness 외부 사운드 모드에서 AI 가 1초 넘게 새 결과를 내지 않으면 진동·잠금 화면 알림이 라벨을 쓰지 않게 하는 순수 로직(#352)
HapticSettingRow 설정 화면의 종류별 진동 설정 줄. 외부 사운드 모드에서 보여 줄 방식과 저장할 방식은 HapticModeChoice가 정함
EasyHapticRow, EasyVibration 쉬운 설정(#408)의 종류별 진동 세 칸(끔·약하게·세게)과, 그 칸이 무엇을 보여 주고 저장할지 정하는 순수 로직
  • 캡처가 돌기 시작하면 캡처 서비스가 HapticNotifier를 시작합니다(startHaptics). 진동 모터가 없는 기기에서는 시작하지 않습니다. 화면이 꺼져 쉬면 멈추고, 켜지면 새로 만들어 시작합니다. 예전에는 AI 모델이 준비된 뒤에 시작해서, 모델을 못 불러온 실행에는 진동이 아예 없었습니다(#225). 화면을 볼 수 없을 때는 진동이 유일한 알림이라 AI 와 떼었습니다.
  • 라벨은 오버레이와 같은 AiClassification.latest()에서 읽습니다. AI 가 붙기 전(로딩 중, 화면을 켠 직후 300ms)에는 라벨이 없어 울리지 않습니다. 곧 올 라벨과 겹쳐 울리지 않게 하기 위해서입니다. 외부 사운드 모드에서는 AI 가 1초 넘게 새 결과를 내지 않아도(조용해서 쉬면) 라벨이 없는 것으로 봅니다(AiLabelFreshness, 아래 "외부 사운드 모드: AI 입력", #352).
  • AI 를 쓸 수 없는 실행(SettingsManager.aiAvailable이 false)에서는 라벨이 끝내 오지 않으므로, 종류를 가리지 않고 큰 소리(HapticTuning.UNLABELED_LOUD_LEVEL, -12dBFS)가 이어지는 동안 위협음 설정으로 울립니다. 큰 소리는 피크가 기준의 절반(HapticTuning.UNLABELED_RELEASE_RATIO) 아래로 0.4초 넘게 내려가야 끝나므로(#232), 게임 음악처럼 끊기지 않는 배경음 동안은 멈추고 그 사이의 폭발음마다 울리며, 기준 근처를 오르내리는 음악에는 끊겼다 울렸다 하지 않습니다. 위협음의 표시나 진동을 꺼 두면 울리지 않습니다. 소리가 난다는 기준(0.01)으로 울리면 게임·영상이 켜져 있는 내내 울리기 때문에 따로 둔 기준이며, 폰 체감 확인에서 정합니다. 이 경우를 어떻게 할지는 나중에 따로 정합니다(#242). 규칙은 HapticPolicyTest가, 진동이 AI 가 아니라 캡처와 함께 켜지는지는 HapticSourceContractTest가 봅니다.
  • 외부 사운드 모드(2장)도 같은 규칙으로 울리되, 폰이 자기 진동을 소리로 다시 듣지 않게 울림 사이의 쉼에서만 소리를 보고, 종류마다 방식에 상한(환경음 꺼짐, 사람 소리 느림, 위협음 중간)을 둡니다(아래 "외부 사운드 모드: 자기 진동", #290·#354). 종류를 모르는 큰 소리도 위협음 설정으로 울리므로 '중간'까지입니다. AI 가 진동 소리를 듣고 종류를 정하지 않도록 AI 입력에서도 우리 울림을 뺍니다(아래 "외부 사운드 모드: AI 입력", #352).
  • 시각화가 뜻하지 않게 꺼졌을 때의 진동(HapticPlayer.playStoppedAlert)은 이 설정과 상관없이 울립니다(1장 꺼짐 알림).
  • SV-Haptic 스레드(THREAD_PRIORITY_DISPLAY)가 100ms마다 최근 분류 라벨과 AudioEngine.takeHapticPeak()(지난 틱 이후 구간 전체의 최대 피크)을 읽어 무엇을 울릴지 판단합니다. AI 결과가 250ms마다 나오므로 100ms로도 놓치지 않습니다. 느림·중간·빠름의 울림은 이 주기와 상관없이 제 시각에 깨어나 보냅니다(HapticDriver.nextWakeMs). 다음 틱은 절대 시각(postAtTime)으로 잡고, 배경 우선순위는 렌더·AI가 바쁠 때 늦게 깨어 박자가 흔들려 쓰지 않습니다. 틱은 handler로만 다음 틱을 잡으므로 첫 틱은 handler를 둔 뒤에 보냅니다. 먼저 보내면 진동 스레드가 그 틈에 먼저 돌 때 틱이 끊겨, 그 실행 내내 진동이 오류 없이 멈춥니다(#232).
  • ai/ 코드에 콜백을 넣지 않고 결과를 읽어 가는 방식이라 AI 파트를 건드리지 않습니다.

언제 울리나: 라벨이 아니라 "소리가 이어지는 동안"

후처리는 확신도가 낮으면 이전 라벨을 유지하므로, 조용해져도 마지막 라벨(예: 위협음)이 남습니다. 라벨만 보고 울리면 무음에서도 끝없이 울립니다.

그래서 HapticPolicy는 실제로 소리가 나는 중(크기 0.01 초과, 끊겨도 400ms까지는 같은 소리로 봄)이면서 라벨의 화면 표시가 켜지고 진동이 꺼짐이 아닌 동안만, 그 종류의 방식과 세기로 울립니다. 소리가 끝나면 0.4초 뒤 멈춥니다.

  • 라벨이 다른 종류로 바뀌면 같은 틱에 그 종류의 진동으로 바뀝니다. 화면 색도 같은 라벨을 곧바로 따르므로 진동이 화면보다 늦게까지 남지 않습니다(#244). 예전에는 0.6초 기다렸다 넘어가서 앞 종류의 진동이 화면보다 늦게 남았습니다. 라벨의 흔들림은 AI 후처리(두 번 연속 확인)가 이미 거릅니다.
  • 울리는 중에 그 종류의 진동이나 표시를 끄면 같은 틱에 멈추고, 방식이나 세기를 바꾸면 바로 따릅니다.
  • 기본값은 위협음만 중간·100%이고 환경음·사람 소리는 꺼짐입니다.

어떻게 울리나: 방식과 세기 (#242)

예전에는 한 번·두 번·길게와, 소리의 크기·박자·음높이를 따라가는 '소리 따라'가 있었습니다. 폰으로 느껴 보니 모든 방식에 이상한 패턴이 섞인 느낌이라, 고르기 쉽고 느낌이 분명한 다섯 가지로 바꿨습니다.

방식 모양
꺼짐 울리지 않음
느림 1초마다 0.4초 (중간의 절반 빠르기)
중간 0.5초마다 0.2초
빠름 0.25초마다 0.1초 (중간의 두 배 빠르기)
연속 끊김 없이
  • 세기는 종류마다 10100%(10% 단위)이고 진폭 40255에 옮깁니다(HapticTuning.amplitude). 어느 방식이든 한 세기로 켰다 끕니다. 세게 치거나 잦아드는 모양은 없습니다.
  • 느림·중간·빠름은 박자마다 울림 하나(한 칸 파형)를 보냅니다. 안드로이드는 vibrate()를 다시 부르면 앞 진동을 끝내고(HAL off) 새로 켜는데(HAL on), 모터가 쉬는 사이에만 보내므로 그 틈이 느껴지지 않습니다. 박자는 처음 울린 시각에서 세고, 틱이 늦으면 울림 시간 안이면 늦게라도 울리고 통째로 놓쳤으면 건너뜁니다(몰아서 울리면 두 울림이 붙습니다). 울리지 않아야 하면(소리가 끝났거나 꺼진 종류로 바뀌었으면) 울리던 울림도 바로 끊습니다(#244).
  • 연속은 30초짜리 울림을 보내고 0.2초 남으면 다시 보냅니다. 소리가 끝나면 바로 끊습니다(HapticPlayer.cancel).
    • 다시 보낼 때마다 모터가 다시 켜져 이음매가 느껴집니다. 2초였을 때는 1.8초마다 끊겼다 다시 울리는 것처럼 느껴져(#286), 이음매가 30초에 한 번만 생기게 늘렸습니다.
    • 대가가 있습니다. 알림이나 다른 앱의 진동이 오면 안드로이드가 우리 울림을 끊기도 합니다(Android 14 이상은 알림 진동이 접근성 진동보다 우선이고, 10~13 은 키보드·터치 진동에도 끊습니다). 앱은 진동 상태를 볼 수 없어 끊긴 줄 모르므로, 다시 울리기까지 최대 30초가 걸릴 수 있습니다. 사용자가 이 대가를 알고 "거의 끊김 없이"를 골랐습니다(#286). 그 전에는 끊김 복구를 앞세워 5초 → 2초로 줄였었습니다(#262).
    • 사용자가 화면을 끄면 안드로이드가 시스템 앱이 아닌 앱의 울리던 진동을 끊습니다(VibrationSettings.shouldCancelVibrationOnScreenOff, 자동 꺼짐은 예외). 화면이 꺼져도 계속 듣는 경우(외부 사운드 모드, 또는 '화면이 꺼지면 일시정지'를 끈 경우)에는 꺼지는 순간 보낸 울림을 끊긴 것으로 잊습니다(HapticNotifier.onScreenOff, #288). 연속이면 다음 틱에 다시 보내고, 느림·중간·빠름은 다음 박자부터 이어 갑니다. 외부 사운드 모드에는 상한 때문에 연속이 없으므로(#354) 다시 보내는 것은 '화면이 꺼지면 일시정지'를 끈 폰 안의 소리뿐입니다. 꺼진 뒤에 시작한 진동은 끊지 않습니다.
  • 반복 효과는 쓰지 않습니다. 같은 우선순위에서 반복 효과가 울리는 동안에는 새 진동이 무시되고, 끄는 쪽이 한 번 빠지면 끝없이 울립니다. 이음매도 없어지지 않습니다. Android 16 은 반복 진동도 5초마다 모터를 다시 켭니다. 끝이 있는 울림을 이어 보내므로 스레드가 멈춰도 30초 안에 저절로 멈춥니다.
  • Android 10 의 터치 진동 판정 (#302): Android 10 은 벨소리·알림이 아닌 진동 가운데 5초보다 짧은 것을, 넘긴 용도(접근성·알람)와 상관없이 터치 진동으로 봅니다(VibratorService.Vibration.isHapticFeedback). 그러면 폰 설정의 '터치 진동' 세기를 따라, 그것을 꺼 둔 사람에게는 박자 울림·미리보기·꺼짐 알림이 울리지 않습니다. 그래서 Android 10 에서만 보내는 파형 끝에 쉼을 붙여 5초를 넘깁니다(HapticPlan.withSilentTail). 다음 울림이 오면 시스템이 앞 진동을 끊고 새로 켜고(파형이라 '같은 세기의 더 긴 진동이면 새 것을 무시'하는 한 번 울림 규칙에 걸리지 않습니다), 소리가 끝나면 쉬는 꼬리만 남습니다. Android 11 부터는 용도로 분류하고 길이는 용도를 모를 때만 봅니다.
  • 미리보기: 설정에서 방식을 누르거나 세기를 바꾸면 2초 동안 실제와 같은 박자를 파형 하나에 적어 울립니다(쉬는 칸은 모터가 멈춰 있어 느낌이 같습니다). 시각화가 실행 중이면 실제 진동과 섞여 헷갈리므로 울리지 않고, 그 까닭을 진동 줄에 적습니다(#244). 누가 튼 미리보기든 먼저 멈추고 진동기를 가져옵니다. 쉬운 설정의 진동 세 칸(#408)도 누르면 같은 길로 2초 울리고, 실행 중이라 울리지 않는 까닭은 줄마다가 아니라 소리 종류 카드 맨 위에 한 번 적습니다. 미리보기의 주인 이름은 자세한 설정의 줄과 따로 두어(easy: + 라벨), 한쪽이 사라질 때 다른 쪽의 미리보기를 끊지 않습니다. 그동안 HapticPreviewGate.busyUntilMs가 실제 진동 알림을 막고, 막힌 동안 실제 진동 알림은 보낸 연속 울림을 끊긴 것으로 잊어(HapticDriver.onPreempted) 미리보기가 끝나자마자 다시 울립니다(#232). 꺼짐 알림 직전에는 미리보기를 멈춥니다.
  • 세기 조절이 없는 기기는 기본 세기로 켰다 끕니다. 세기 슬라이더는 비활성으로 보이고 그 까닭을 적습니다.
  • 예전 설정(켜기·패턴·약중강)은 처음 읽을 때 옮겨 적고 예전 키를 지웁니다(HapticSettings.fromLegacy): 꺼 두었으면 꺼짐, 소리 따라는 연속, 한 번·두 번·길게는 중간, 약·중·강은 30·60·100%.
  • 확인: 디버그 빌드는 logcat -s SvHaptic에 기기의 진동 능력, 보낸 울림 한 줄씩, 10초마다 틱·울림 수를 남깁니다. 외부 사운드 모드에서는 쉼마다 gap t=… deafPeak=… firstClean=… 한 줄도 남깁니다(버린 자기 진동의 크기와 쉼에서 처음 읽은 크기). adb shell dumpsys vibrator_manager로 보낸 진동과 그 상태를 볼 수 있습니다. 박자와 멈춤은 HapticDriverTest가 실제 알림처럼 깨어나며 흘려 확인합니다.

외부 사운드 모드: 자기 진동 (SelfVibrationGate, #290)

마이크는 폰의 진동을 그대로 듣습니다. 진동은 "소리가 이어지는 동안" 울리고 0.4초 쉬어야 멈추는데, 중간(쉼 0.3초)·빠름(쉼 0.15초)·연속은 그보다 짧게 쉬므로 자기 진동 소리만으로 끝없이 이어질 수 있었습니다(S25+ 에서 확인, 2026-10-02). 사용자 결정(2026-10-07)대로 울림 사이의 쉼에서만 소리를 봅니다. 그리고 사용자 결정(2026-10-08, #354)대로 종류마다 방식에 상한을 둡니다(환경음 꺼짐, 사람 소리 느림, 위협음 중간). 처음(#341)에는 쉼이 없는 '연속'만 '빠름'으로 울렸는데, 이 상한이 그 규칙을 대신합니다. 이 절은 진동 판단(크기)만 다룹니다. AI 가 받는 소리는 다음 절(#352)이 거르고, AI 코드와 화면 그림은 그대로입니다. 폰 안의 소리는 진동을 듣지 못하므로(CaptureSource.hearsOwnVibration) 지금까지와 같습니다.

  • 어느 값을 거르나: 진동 판단이 읽는 크기(AudioEngine.takeHapticPeak)에는 시각이 없고, 지난번에 읽은 뒤로 들어온 버퍼 전체의 최대값입니다. 그래서 읽은 구간(지난번에 읽은 시각, 지금]이 앱이 울린 진동과 그 꼬리가 끝난 뒤에 시작했으면 깨끗하고, 아니면 섞였을 수 있다고 봅니다. 꼬리가 끝날 때 한 번 깨어나 섞인 값을 버리므로, 그다음부터 다음 박자 직전까지 읽은 값은 쉼에서 들은 소리뿐입니다. 다음 울림은 크기를 읽은 뒤에 보내므로 섞이지 않습니다.
  • 꼬리 여유(HapticTuning.selfHearingGuardMs): 느림·중간 0.18초이고, 그 뒤에 듣는 틈이 느림 0.42초, 중간 0.12초 남습니다. 빠름은 쉼이 0.15초라 여유를 0.11초로 줄여도 듣는 틈이 0.04초뿐이어서, 아래 상한으로 외부 사운드 모드에서는 울리지 않습니다. 모터가 명령보다 늦게 멈추는 시간, 잦아들며 책상을 울리는 시간, 마이크 입력 지연, 버퍼 하나(10.7ms)를 덮어야 합니다. 재 본 값이 아닙니다. S25+ 에서 앱의 울림은 명령보다 16~55ms 늦게 끝났습니다(dumpsys vibrator_manager).
  • 섞인 값 대신: 묶음(사이에 깨끗한 값이 없는 울림들) 바로 전 쉼에서 소리가 들렸으면 그 크기를 돌려주고(이어 주기), 묶음 동안은 소리가 난다·멈췄다를 새로 정하지 않습니다(기다리기, HapticPolicy.onTick의 holding). 둘 다 묶음의 첫 울림이 끝난 때(꼬리 포함)까지만 합니다.
    • 이어 준 크기는 읽은 구간이 시작된 시각의 소리로 치되, 첫 울림의 꼬리가 끝나기 0.08초 전보다 늦게 치지 않습니다(SelfVibrationGate.levelAtMs, HapticTuning.SELF_HEARING_CREDIT_LEAD_MS). 꼬리 끝에 가깝게 치면 느림은 다음 박자까지 여유가 0.02초 남짓뿐이라, 소리가 끝난 뒤 0.4초짜리 울림이 한 번 더 나가 1.5~1.8초까지 이어졌습니다. 지금 들은 것으로 쳤을 때는 깨어나는 것이 조금만 늦어도 그랬고, 읽은 구간이 시작된 시각으로만 쳤을 때는 울림을 보내는 데 21ms 넘게 걸리는 폰에서 거의 늘 그랬습니다(둘 다 리뷰에서 찾음). 0.08초 전으로 막으면 여유가 0.1초가 되고, 박자에 맞춰 깨어날 때 마지막으로 이어 주는 틱이 바로 그 시각이라 평소 박자는 그대로입니다. 0.1초로 막으면 느림의 말소리 박자 어긋남이 12%→16%로 늘었습니다. AI 를 쓸 수 없는 실행의 큰 소리도 같은 방식으로 이어 줍니다.
    • 이어 주기가 없으면 말소리처럼 끊어지는 소리에서 듣지 않는 구간 뒤의 쉼을 잇지 못해 박자가 빠지고, 기다리기가 없으면 박자가 자주 어긋나며, 첫 울림에 맞추지 않으면 자기 진동 소리에 라벨이 오가는 동안 기다리기가 끝나지 않습니다.
    • 설정의 미리보기 진동도 마이크가 들으므로, 틱마다 크기를 거르기 전에 HapticPreviewGate.busyUntilMs를 알려 그 꼬리까지 듣지 않습니다. 미리보기를 일찍 멈추면 듣지 않는 구간도 줄어듭니다.
  • 종류마다 상한(HapticSettings.externalCap, #354): 외부 사운드 모드에서 고를 수 있는 가장 빠른 방식은 환경음 꺼짐(진동하지 않음), 사람 소리 느림, 위협음 중간입니다. 그래서 빠름과 연속은 어느 종류로도 울리지 않습니다.
    • 까닭: 듣는 틈이 빠름은 0.04초뿐이라 진동 꼬리가 긴 폰에서는 틈이 사라져 자기 진동으로 다시 이어질 수 있지만, 느림(0.42초)·중간(0.12초)은 넉넉합니다. 연속에는 틈이 없습니다. 환경음은 주변에서 늘 들리므로 진동하게 두면 그치지 않습니다. 느릴수록 울림 사이에 깨끗하게 듣는 조각이 길어, AI 가 진동 소리를 말소리로 듣는 문제(#352)를 풀 때 쓸 수 있는 소리도 길어집니다. 위협음의 상한은 기본값(중간)과 같아 기본 설정은 그대로 울립니다.
    • 틱이 쓰는 설정을 라벨마다 HapticSettings.inExternalSound(label)로 바꿉니다(HapticNotifier.tickConfig). 상한보다 빠른 방식만 상한으로 낮추고 세기는 그대로입니다. AI 를 쓸 수 없는 실행의 큰 소리도 위협음 라벨로 설정을 읽으므로 '중간'까지입니다. 저장된 설정은 그대로라 외부 사운드 모드를 끄면 정해 둔 대로 울립니다.
    • 설정 화면(HapticModeChoice)도 외부 사운드 모드가 켜져 있으면 상한보다 빠른 칸을 흐리게 하고, 상한보다 빠르게 저장된 줄은 상한으로 보여 주며, 미리보기도 그 방식으로 울립니다. 진동 줄마다 그 종류의 상한과 까닭을 적습니다. 상한보다 빠르게 저장된 줄에만 적으면 기본값(위협음 '중간')에서 더 빠른 방식을 고르려는 사람은 칸이 왜 흐린지 알 수 없습니다. 환경음 줄은 진동하지 않는다고 적고, 꺼짐 줄처럼 세기를 접습니다(바꿔도 느낄 수 없는 세기를 고르게 두지 않습니다). 표시를 끈 환경음 줄에도 '표시를 켜야 진동한다' 대신 이 안내를 적습니다. 켜도 진동하지 않기 때문입니다(HapticModeChoice.neverVibrates). 이 안내는 환경음을 이름으로 들어 말하므로 상한만이 아니라 환경음 줄인지도 봅니다. 상한만 보면 다른 종류의 상한을 꺼짐으로 바꿨을 때 그 줄에 환경음 안내가 붙습니다. 보이는 상한을 다시 눌러도 정해 둔 방식이 남고, 고를 수 있는 다른 방식을 고르면 그 방식이 저장되어 모드를 꺼도 그대로입니다. 도움말의 진동 항목에도 외부 사운드 모드일 때만 한 단락을 더 두고, 자주 묻는 질문 '진동이 오지 않아요'의 답도 따로 둡니다. 폰 안의 소리의 답은 화면이 꺼지면 쉰다고 하고 미디어 볼륨을 말하지만, 이 모드는 쉬지 않고(#260) 마이크가 막히거나 소리가 작아 울리지 않을 수 있기 때문입니다.
  • 확인(SelfHearingLoopTest): 실제 알림과 같은 순서로 판단·박자·문을 돌리고, 10.67ms 버퍼와 입력 지연, 늦게 멈추는 모터를 흉내 낸 마이크를 붙입니다.
    • 문과 상한이 없으면 중간·빠름·연속이 30초 뒤에도 울립니다(#290 재현).
    • 문이 있으면 꼬리가 여유 안에 드는 한 소리가 끝나고 1초 안에 멈춥니다. 소리가 끝나는 때를 1초 안에서 7ms 씩 옮겨 가며 본 가장 늦은 경우가 느림 0.84초, 중간 0.85초입니다(빠름·연속으로 정해 둔 사람 소리는 느림, 위협음은 중간으로 울리므로 같습니다). 깨어나는 시각과 울림을 보내는 시각을 400가지로 늦춰도 0.85초 안이고, 울림을 보내는 데 늘 0.08초씩 걸려도 0.85초 안입니다. 폰 안의 소리는 0.63초입니다. AI 를 쓸 수 없는 실행에서도 빠름·연속으로 정해 둔 위협음 설정은 중간으로만 울리고 0.85초 안에 멈춥니다.
    • 예외로, AI 가 자기 진동 소리를 다른 종류로 번갈아 들으면 더 늦게 멈춥니다. 방식이 바뀌거나 끊겼던 울림이 다시 날 때마다 새 울림이 바로 나가고 그 울림 동안은 기다리기 때문입니다. 상한 아래에서 서로 다르게 울리는 것은 위협음 중간과 사람 소리 느림, 그리고 진동하지 않는 종류(환경음, 꺼 둔 사람 소리)입니다. 바뀌는 간격(0.25~1초)을 바꿔 가며 본 가장 늦은 경우가 위협음 중간이 사람 소리 느림이나 진동하지 않는 종류와 오갈 때 1.22초입니다(위협음이 느림이면 0.92초 안). 도움말의 "보통 1초 안"은 이 드문 경우를 뺀 것입니다.
    • 박자는 그 종류의 상한까지의 방식 그대로이고, 말소리처럼 끊어지는 소리에서 박자가 폰 안의 소리보다 더 흔들리지 않습니다(어긋남 느림 25%→12%, 중간 11%→6%). 폰 안의 소리가 울린 박자 가운데 외부 사운드 모드가 울리지 않은 것은 느림 2.4%, 중간 1.4% 입니다. 짧은 소리가 울림 도중에만 났다 끝나면 듣지 못해 박자 하나가 빠질 수 있습니다(이어 주기를 빼면 중간 13% 가 빠집니다).
    • 외부 사운드 모드에서 환경음은 소리가 이어져도 울리지 않습니다.
    • 쉬운 설정의 진동 세 칸(#408, EasyVibration)도 같은 규칙입니다. 보이는 칸은 실제로 울릴 설정(HapticModeChoice.shown)으로 정하고, 꺼져 있던 종류를 켤 때의 방식(위협음 중간, 사람 소리·환경음 느림)은 상한 안입니다. 이미 켜진 종류는 정해 둔 방식을 두고 세기만 바꾸므로, 상한보다 빠르게 정해 둔 방식도 지워지지 않습니다. 진동하지 않는 환경음은 끔만 선택할 수 있고 짧은 안내(easy_vibration_off_external)를 답니다. 규칙은 EasyVibrationTest가, 칸의 배선(외부 사운드 모드 판단, 저장, 미리보기)은 EasySettingsSourceTest가 봅니다.
    • 루프 시뮬레이션은 알림의 배선을 따로 흉내 내므로, 알림 코드 자체의 배선(거른 크기·tickConfig·꼬리 여유·깨어날 시각)은 HapticSourceContractTest가, 설정 줄의 배선은 HapticSettingRowSourceTest가 소스에서 고정합니다. 종류와 방식마다 실제로 울릴 방식(상한 표)과 설정 칸의 규칙은 HapticExternalModeTest가 봅니다.
  • 한계: 꼬리가 여유보다 길면 걸러 내지 못합니다(느림·중간은 0.18초. 그런 기기에서는 진동이 다시 이어지므로 여유를 늘려야 합니다. 여유가 0.11초인 빠름은 상한 때문에 외부 사운드 모드에서 울리지 않습니다). 마이크 감도를 높여 조용한 방의 잡음이 0.01 을 넘으면 원인은 다르지만 같은 증상으로 이어집니다. 다른 앱의 진동은 모릅니다. 화면 그림은 짧아진 울림을 그대로 그립니다(AI 입력은 다음 절). 소리가 멈춘 뒤 진동이 잠시(보통 1초 안, 위의 예외에서는 1.2초쯤까지) 더 울릴 수 있고, 울림 도중에만 났다 끝난 짧은 소리로는 진동이 이어지지 않습니다(화면에는 그려집니다).

외부 사운드 모드: AI 입력 (AiFeedFilter, #352)

마이크는 폰의 진동을 그대로 듣고, AI(YAMNet)는 그 소리를 대개 말소리로 듣습니다. S25+ 의 개발자 기록(10/8, dec5cc3)에서 1순위가 Speech 였던 30 프레임 가운데 28 프레임은 그 판정이 듣던 1초 안에 우리 진동이 있었고, 진동이 없을 때는 75 프레임 가운데 1 프레임뿐이었습니다. 사람 소리 진동을 켜 두고 마이크 감도를 ×32 로 올리면 사무실 잡음만으로도 소리가 나는 것으로 보여 "진동 → Speech → 진동"이 이어졌습니다. 위 절은 진동 판단의 크기만 걸렀고 AI 가 받는 소리는 그대로였기 때문입니다. 사용자 결정(10/8)대로 우리 쪽에서 고칩니다. 세 설계와 그 변형을 시뮬레이션(실제 YAMNet 과 AI 결정 코드, #354 상한 포함, 설계마다 135번)으로 비교해, 깨끗한 AI 입력(설계 A 의 hold2)에 쉬는 AI 의 라벨 버리기, 진동 소리 크기의 바닥, 방식별 꼬리 여유를 더한 것을 골랐습니다. ai/ 코드는 바꾸지 않습니다. 폰 안의 소리는 필터를 만들지 않고 구간도 알리지 않으며 라벨 규칙도 그대로라 아무것도 바뀌지 않습니다.

  • 빼는 구간(SelfVibrationTimeline): 진동 알림이 울림을 보내기 전에 [보낸 시각 − 5ms, 멈추라고 한 시각 + 꼬리 여유) 를 알립니다. 보내기 전에 알려야 모터가 돌기 전에 캡처 스레드가 압니다. 보낸 뒤의 시각으로 끝을 고치고, 보내지 못하면 지우고, 끊으면 모터를 멈춘 뒤의 시각으로 꼬리 여유는 남긴 채 앞당깁니다. 설정 미리보기와 튜토리얼 진동도 알립니다(실행 중에는 울리지 않으므로 겹치는 것은 켜는 순간뿐입니다). 쓸 때마다 새 배열로 바꿔 끼우므로 캡처 스레드는 잠그지도 할당하지도 않고 버퍼마다 한 번 읽습니다. 최근 8개, 끝난 지 2초 안의 것만 둡니다.
  • 꼬리 여유(HapticTuning.aiFeedGuardMs, 캡처 시각): 느림 0.18초, 중간 0.11초. 진동 판단의 여유(selfHearingGuardMs)와 따로 둡니다. 외부 사운드 모드에서 사람 소리는 느림까지라 고리를 막는 쪽은 느림이고, 위협음은 중간까지라 중간을 줄여 박자 사이에 AI 가 깨끗하게 듣는 소리를 0.115초에서 0.185초로 늘립니다. 0.18초로 두면 이어 붙인 짧은 사이렌 조각을 AI 가 음악으로 들어 사이렌 동안 위협음 박자가 20개 가운데 5.7개 빠졌습니다(0.11초로 1.2개). S25+ 의 모터는 명령보다 16~55ms 늦게 멈췄고(#290), 모터가 0.13초 늦게 멈추는 모델에서도 고리는 생기지 않고 중간의 이득만 사라졌습니다.
  • 캡처 시각(CaptureClock): 구간은 진동을 보낸 시각(elapsedRealtime)이라, 프레임마다 마이크에 들어온 시각을 셉니다. 0.5초마다 오디오 서버의 시각 표시(AudioRecord.getTimestamp(…, TIMEBASE_BOOTTIME))를 묻고, 지연 없는 어림(읽기가 돌아온 시각 − 읽은 길이, 최근 2초의 가장 이른 값)보다 2ms 넘게 늦거나 0.3초 넘게 이르면 버립니다. 세 번 연달아 버리거나 받지 못하면, 또는 2초 동안 새 기준이 없으면 어림으로 돌아가고 그동안은 구간 끝을 60ms 늘립니다. 프레임 번호는 startRecording 뒤로 읽은 수라 캡처 스레드마다 새로 셉니다. 기준이 다른 기기는 모든 기준을 버리고 어림으로 돕니다. 캡처 스레드가 밀려 오디오 서버가 프레임을 버리면(오버런) 읽은 수가 실제 캡처보다 뒤처져, 최근 2초의 어림과 지난 기준이 버려진 만큼 이르게 남고 맞는 시각 표시까지 늦다고 버리게 됩니다(80ms 가 버려지면 2초쯤 80ms 이르게 셈). 그래서 최근 2초의 어림보다는 늦어도 그 읽기 하나로 센 어림과는 맞는 시각 표시가 오면, 어림을 그 읽기부터 다시 세고 그 시각 표시를 받아들입니다. 그 읽기보다도 늦은 시각 표시는 틀린 것이라 그대로 버립니다.
  • 채우기(AiFeedFilter, AI 에 넣기 직전, 캡처 스레드): 구간 안 프레임을 바로 앞 깨끗한 소리의 마지막 0.09~0.2초(테이프)로 바꿉니다. 테이프는 앞으로·뒤로·앞으로 읽어 이음매에서 튀지 않고, 앞 쉼이 0.09초보다 짧으면 그 앞의 깨끗한 소리를 5ms 겹쳐 이어 붙여 채웁니다. 구간 양쪽 바깥 5ms 는 원래 소리와 섞습니다. 들어온 만큼 나가므로 AI 의 링버퍼, 쉬는 판단(AiSilenceGate), 후처리의 시간은 그대로이고, 오버레이와 진동이 보는 크기는 그 전에 원래 버퍼에서 잽니다. 가까운 구간이 없으면 피크를 한 번 보고 깨끗한 소리를 적어 둘 뿐 버퍼를 한 비트도 바꾸지 않습니다. 버퍼마다 할당이 없습니다. 필터가 예외를 던지면 캡처 스레드를 멈추지 않고 한 번 기록(Log.e)한 뒤, 그 캡처가 끝날 때까지 AI 에 원래 소리를 넘깁니다(반쯤 고친 버퍼도 원래 소리로 다시 옮깁니다). 다음 캡처는 새 필터로 다시 거릅니다.
  • 큰 소리는 그대로: 구간에 걸친 버퍼의 피크가 진동 소리 크기의 4배를 넘으면 그 버퍼를 그대로 넘기고, 넘기는 동안은 2배까지 이어 넘깁니다. 진동 소리 크기는 0.05(원래 크기, AI 입력에는 감도가 걸리지 않음)를 바닥으로, 앞뒤 쉼보다 모두 12dB 넘게 큰 구간에서 배운 값입니다. 그래서 첫 울림부터 0.2 를 넘는 소리(폭발음, 가까운 경보)는 그대로 듣습니다. 배운 값은 바닥에서 시작하는 지수 평균(새 구간 30%)이고, 그대로 넘긴 버퍼가 든 구간에서는 배우지 않습니다. 그 구간의 피크는 진동이 아니라 넘긴 큰 소리이기 때문입니다. 첫 값을 그대로 쓰고 넘긴 구간에서도 배우던 때는 첫 구간의 0.6 폭발음 하나가 넘기는 기준을 2.5 로 올려, 그 뒤 몇 구간 동안 다음 폭발음까지 채웠습니다. 버퍼마다 넘길지를 먼저 정하고 배우므로, 구간이 끝나는 버퍼의 큰 소리도 스스로 기준을 올리지 못합니다. 이어지는 소리 속에서는 배울 쉼이 없어, 바닥이 없으면 말하는 중의 폭발음을 9번 가운데 3번 놓쳤습니다. 넘긴 소리는 깨끗한 소리로 쳐서 그 구간의 나머지는 그 소리를 되풀이합니다.
  • 안전판: 채우는 구간이 1초 넘게 이어지면 그 뒤는 그대로 넘기고 테이프로도 쓰지 않습니다. 0.09초 테이프가 몇 초씩 되풀이되며 AI 의 쉬는 판단을 붙잡지 않게 하려는 것입니다. 우리 울림은 상한 때문에 길어야 0.645초(느림, 어림일 때)라, 미리보기가 캡처와 겹치거나 시각이 틀렸을 때만 걸립니다.
  • 쉬는 AI 의 라벨(AiLabelFreshness): 외부 사운드 모드에서는 AI 가 1초 동안 새 결과를 내지 않으면 진동과 잠금 화면 알림이 라벨이 없는 것으로 봅니다. 오버레이 색은 그대로 마지막 라벨을 따릅니다. AI 는 원래 크기 0.01 을 넘은 소리가 창(0.975초)을 빠져나간 뒤 두 번 더 추론하고 쉬는데, 그 두 번이 모두 확신 있는 환경음이 아니면 라벨이 그대로 남습니다. 감도 ×32 의 진동은 사무실 잡음에도 소리가 난다고 보므로 그 라벨로 끝없이 울렸고, 우리 진동 소리가 없는 기준 실행에서도 그랬습니다. 새 결과는 결과 객체가 바뀐 것으로 압니다(결과의 시각은 벽시계라 쓰지 않습니다). 진동용(진동 스레드)과 알림용(확인 틱, 메인 스레드)을 따로 둡니다.
    • 대가: AI 가 듣지 않는 아주 작은 소리(원래 크기 0.01 아래)는 감도를 높여도 쉬기 직전 종류로 울리지 않습니다(화면에는 그려집니다). 쉬던 AI 가 깨면 새 결과 하나(0.3초 안팎)를 기다렸다 울립니다.
  • 시뮬레이션 결과(설계마다 135번, dec5cc3 과 6955dbf 의 결정 코드로 같음):
    • ×32 조용한 방에서 사람 소리 진동: 말이 끝나고 진동이 멈추기까지 중간값 0.9초, 가장 늦게 2.2초(상한만: 13.8초·14.4초, 33번 중 29번이 3초 넘게 이어짐). 위협음이 없는 장면의 잠금 화면 알림 0번(상한만 9번).
    • 사이렌 동안 빠진 위협음 박자(20·17·14개 가운데): 1.2·1.3·2.0개(상한만 3.0·4.2·5.5개). 첫 감지는 그대로입니다. 말하는 중의 폭발음은 9번 모두 찾았습니다(상한만 7번).
    • 대가: 말소리가 새소리로 바뀐 뒤 잘못 울린 사람 소리 박자가 0.8개쯤 늘고(구간 하나까지 말소리를 되풀이), 사이렌 뒤 말소리가 0.3초 늦게 보이며, 말소리에 섞인 사이렌은 더 일찍 찾지만 위협음으로 두는 시간은 줄었습니다(0.16 대 0.24).
  • 확인: AiFeedFilterTest(구간이 없으면 한 비트도 바꾸지 않음, 진동 소리를 30dB 넘게 줄임, 가장자리에서 튀지 않음, 테이프 길이와 앞뒤 읽기와 겹쳐 잇기, 큰 소리 넘기기, 배우기(앞뒤 쉼 모두, 바닥에서 시작하는 평균, 넘긴 구간과 구간 끝 버퍼의 폭발음은 배우지 않음), 안전판, 어림의 늘린 끝, 되풀이는 구간 하나까지, 44.1kHz), CaptureClockTest(오버런 뒤 다음 시각 표시에서 바로잡기 포함), SelfVibrationTimelineTest, AiLabelFreshnessTest. AiFeedLoopTest 는 진동 루프 전체를 AI 대역으로 돌립니다: 깨끗한 AI 입력이 없으면 20초 끝까지 진동하고, 있으면 말이 끝나고 2.5초 안에 멈추며, 라벨 규칙이 없으면 쉬는 AI 의 사람 소리 라벨이 남아 이어지고(우리 진동이 없는 기준 실행도 같음), 폰 안의 소리는 AI 입력이 그대로입니다. 배선(외부 사운드 모드에서만, AI 에 넣기 직전, 필터가 예외를 던지면 원래 소리, 보내기 전에 알림, 폰 안의 소리의 갈래는 그대로)은 AiFeedSourceContractTest 가 소스에서 고정합니다.
  • 폰에서 보기: 디버그 빌드는 logcat -s SvAiFeed 에 캡처 시각을 세는 방식(clock mode=ts|fb lat=… anchors=… rejects=… fails=…, 바뀔 때와 10초마다)과 울림마다 한 줄(win mode=… dur=… deafPk=… gapPk=… onset=…ms tail=…ms pass=… fill=…ms)을 남깁니다. onset 은 보낸 시각부터 모터 소리가 처음 구간 피크의 절반을 넘은 때, tail 은 멈추라고 한 시각부터 마지막으로 쉼 소리를 넘은 때이고, 원래 소리로 재므로 꼬리 여유와 상관없습니다. 시각이 맞으면 onset 이 -10~+40ms, 조용한 책상에서 tail 이 대개 90ms 안(중간 0.11초에 20ms 여유)이어야 합니다. 오디오 서버가 시각 표시를 주는지는 CaptureClockProbeInstrumentedTest(기본으로는 건너뜀, -e clockProbe true, 진동·소리 없음)로 따로 잴 수 있습니다.
  • 한계와 남은 위험: 진동하는 동안 느림은 시간의 58.5%, 중간은 63% 를 AI 가 직접 듣지 못합니다. 작은 소리는 다음 쉼에서 듣고, 구간 안에서만 났다 끝난 작은 소리는 듣지 못합니다(지금도 진동 소리에 섞여 말소리로 들리므로 나빠지지는 않습니다). 다른 앱의 진동은 구간을 알 수 없어 빼지 못하고, 그대로 넘긴 큰 소리에는 우리 진동 소리가 섞여 있습니다. 되풀이한 문소리·그릇 소리가 위협음으로 들리는지는 재 보지 않았습니다. 진동 소리는 녹음한 적이 없고, 0.11초 여유와 넘기는 기준(0.05, 4배, 2배)은 S25+ 와 시뮬레이션에만 근거합니다. 외부 사운드 모드에서 진동하는 동안 남긴 개발자 기록 CSV 와 #117 데이터셋 줄은 거른 소리를 판정한 것이라 데이터셋에서는 빼거나 표시해야 합니다. AI 가 쉬며 라벨을 남기는 일은 ai/ 에서 고칠 수도 있어(바뀐 판정만 세기, 감도 반영) 가람 님 판단에 맡깁니다(#359).

잠금 화면 위협음 알림 (DangerAlertPolicy, DangerAlert, #310)

폰이 잠겨 있거나 화면이 꺼진 동안에는 오버레이(TYPE_APPLICATION_OVERLAY)가 잠금 화면에 가려 보이지 않습니다. 그때 위협음이 들리면 잠금 화면에 알리고 꺼진 화면을 잠깐 켭니다.

  • 언제: 캡처 서비스의 500ms 확인 틱(blockedCheck)이 받을 수 없는 소리 확인에 이어 DangerAlertPolicy.onTick을 부릅니다. 크기는 그 확인이 방금 읽은 지난 틱 이후의 최대값(checkSample)을 그대로 씁니다. 피크는 읽는 쪽이 하나여야 하기 때문입니다.
    • 진동처럼 "위협음 라벨이면서 실제로 소리가 난다(0.01 초과)"일 때만 위협음으로 봅니다. AI 는 소리가 끝나도 마지막 라벨을 들고 있습니다. 외부 사운드 모드에서는 이 틱의 크기에도 마이크 감도가 걸려 감도를 높이면 사무실 잡음에도 소리가 난다고 보므로, AI 가 1초 넘게 새 결과를 내지 않으면 라벨을 쓰지 않습니다(AiLabelFreshness, 5장 "외부 사운드 모드: AI 입력", #352). 폰 안의 소리는 그대로입니다.
    • 위협음이 3초 동안 없으면 사건이 끝납니다. 한 사건에 한 번, 알린 뒤 30초 안에는 다시 알리지 않습니다.
    • 잠겼거나(KeyguardManager.isKeyguardLocked) 화면이 꺼졌는지(PowerManager.isInteractive)는 아직 알리지 않은 사건 동안에만 묻습니다. 그래서 위협음이 이어지는 중에 잠그면 그때 알립니다.
    • 위협음 표시를 껐거나 AI 가 동작하지 않으면(큰 소리 진동만 남는 경우) 알리지 않습니다.
    • 화면이 꺼져 쉬는 동안(ScreenOffPause, 2장)에는 이 틱이 돌지 않습니다. 그래서 화면이 꺼진 동안 알리는 것은 외부 사운드 모드와, '화면이 꺼지면 일시정지'를 끈 폰 안의 소리뿐입니다. 기본 설정의 폰 안의 소리는 화면을 켠 뒤 잠금을 풀기 전, 잠금 화면이 보이는 동안에만 알릴 수 있습니다. 알림 채널 설명도 이렇게 적습니다(#356).
    • 알릴지와 적을 이름은 AiClassification.latest()를 한 번 읽은 같은 결과에서 고릅니다. 따로 읽으면 그사이 다음 추론이 끼어 다른 결과의 이름을 적을 수 있습니다.
  • 알림: 새 채널(DangerSoundAlertChannel, 중요도 HIGH, 소리·진동 없음)에 알림 3번을 올립니다.
    • 카테고리는 ALARM 입니다. 기본 방해 금지 설정은 알람을 통과시키므로 그때도 보입니다.
    • 잠금 화면 공개(VISIBILITY_PUBLIC)는 채널 값을 시스템이 앱 설정으로 덮어쓰므로 알림에도 적습니다.
    • setSilent는 쓰지 않습니다. 쓰면 SystemUI 가 팝업과 화면 깨움을 막습니다.
    • 들린 소리 이름은 앱 언어로 적습니다(sound_names, DangerAlertPolicy.alertSoundIndex, #356).
      • AI 가 확정한 이름(AiClassificationResult.display)이 지금 위협음 종류면 그 이름을 씁니다. 알릴지는 히스테리시스를 거친 확정 라벨로 정하는데, 이 이름은 확신도 기준을 넘긴 위협음 프레임 가운데 가장 최근 것의 이름입니다(대개 1순위이고, 사이렌 같은 단서를 올렸거나 음악에 가린 위협음을 고른 프레임이면 그 소리). 상위 후보(top5)는 가장 최근 한 프레임뿐이라, 그것만 보던 때는 구급차 사이렌 녹음에 'Siren' 대신 'Train whistle'(0.06)을, 총소리 녹음에 'Gunshot, gunfire' 대신 'Smash, crash'(0.028)를 적었습니다.
      • 확정 이름이 위협음 종류가 아니면 상위 후보에서 위협음 종류이면서 확률이 0.05 이상인 첫 소리를 씁니다. 상위 3개의 투표로 위협음이 된 프레임은 1순위가 다른 종류여도 그 1순위가 확정 이름으로 남기 때문입니다. 0.05 는 AI 가 사이렌·경보 같은 강한 단서를 위협음으로 올리는 하한(YamnetSafetyCueDecision)과 같은 값입니다. 그런 소리도 없으면 이름 없이 알립니다(danger_alert_text).
      • 확정 이름은 문자열뿐이라 번호를 소리 목록에서 찾습니다(SoundCatalog.indexOf, YAMNet 이름은 서로 겹치지 않습니다). 목록은 SV-AiInit 스레드가 모델을 불러온 뒤, 파이프라인을 붙이기 전에 읽어 둡니다. 알림은 메인 스레드에서 이름을 고르기 때문입니다.
    • 종류는 분류 탭에서 바꾼 종류를 따릅니다(SettingsManager.soundType, #325). AI 가 추론마다 쓰는 정책과 같은 규칙이라, 위협음에서 뺀 소리의 이름으로 알리지 않고 위협음으로 새로 지정한 소리의 이름으로 알립니다. 예를 들어 사이렌을 환경음으로 바꿨는데 같은 소리에 함께 잡힌 경보(Alarm) 때문에 위협음이 되면 "경보"라고 적습니다.
    • 잠금을 풀면(ACTION_USER_PRESENT) 치우고, 5분이 지나도 사라집니다.
      • 이 방송은 시스템 서버가 아니라 시스템 UI 앱이 보냅니다. 그래서 화면 켜짐·꺼짐 수신기(RECEIVER_NOT_EXPORTED, 2장 "화면이 꺼지면 쉬기")에 함께 두면 오지 않습니다(Android 13 이상은 내보내지 않아서, 그 아래는 앱 서명 권한이 없어서). 수신기를 따로 두고 RECEIVER_EXPORTED로 받습니다. 보호된 방송이라 일반 앱은 보낼 수 없습니다(#385).
  • 화면 깨우기: 알림을 올린 뒤 화면이 꺼져 있으면 SCREEN_BRIGHT_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP | ON_AFTER_RELEASE를 3초 잡습니다.
    • API 33 부터 deprecated 지만 동작합니다. Android 14~17 은 TURN_SCREEN_ON 권한이 없어도 허용합니다(그 강제는 아직 켜지지 않았습니다).
    • 전체 화면 인텐트는 Android 14 부터 플레이 정책상 전화·알람 앱만 쓸 수 있어 쓰지 않습니다.
    • 알림을 올리지 못했으면(알림 권한·채널 꺼짐) 화면도 켜지 않습니다. 권한은 WAKE_LOCK(일반 권한) 하나가 늘었습니다.
  • 삼성: One UI 7 은 잠금 화면 알림을 기본으로 "아이콘만" 보여 줄 수 있습니다. 그때는 폰 설정에서 내용을 보이게 해야 글이 보입니다.
  • 확인: DangerAlertPolicyTest가 사건·쿨다운·잠금 시점·조용한 라벨과, 적을 이름(확정 이름 우선, 후보 하한, 바꾼 종류, 위 두 녹음의 실제 상위 후보)을 봅니다. 알림 자체는 adb shell dumpsys notification --noredact에서 importance=4, category=alarm, vis=PUBLIC 을 확인합니다.

6. 설정 저장 (SettingsManager)

  • 설정은 StateFlow로 들고 있고 SharedPreferences에 저장합니다. 설정 화면은 값을 바꾸고, 오버레이·진동은 매 프레임·매 틱마다 .value만 읽습니다.
  • 모드별 슬라이더 값은 드래그 중에는 메모리에만 반영하고, 손을 뗄 때와 화면을 벗어날 때(onPause) 저장합니다. 쉬운 설정의 크기 단계(#408)는 한 번 누르면 끝나는 조작이라 선택한 모드에 쓰고 바로 저장합니다(applyEasySize).
  • 모드 설정의 기본값은 ModeSettings 생성자 한 곳에만 둡니다. 저장값이 없는 항목은 loadMode가 그 기본값을 그대로 씁니다. 새 설치가 받는 기본값과 저장 키(putMode로 적고 loadMode로 읽기)는 ModeSettingsTest가 메모리 가짜 프리퍼런스로 기기 없이 확인합니다.
  • 표현 모드(VisualMode)는 순서(ordinal)로 저장하므로 enum 순서를 바꾸면 기존 사용자 설정이 어긋납니다. 진동 설정의 enum은 이름으로 저장합니다.
  • 실행 상태(isServiceRunning), AI 사용 가능 여부(aiAvailable), 화면 꺼짐으로 쉬는 중(isCapturePaused), 소리를 받을 수 없는 중(isCaptureBlocked)은 캡처 서비스가 알려주는 값이라 저장하지 않습니다.
  • 마지막으로 뜻하지 않게 꺼진 이유(lastUnexpectedStop)는 캡처 서비스가 알려주지만, 프로세스가 끝난 뒤 앱을 열어도 홈에서 보이도록 이름으로 저장합니다. 모르는 이름(항목을 바꾼 뒤)이면 안내가 없는 것으로 봅니다. 읽기·쓰기(loadLastUnexpectedStop·putLastUnexpectedStop)는 StopNoticeSettingsTest가 확인합니다.
  • 외부 사운드 모드(externalSoundMode, 키 external_sound_mode, 2장)의 기본값은 EXTERNAL_SOUND_MODE_DEFAULT(꺼짐) 한 곳에만 둡니다. 마이크로 주변 소리를 듣는 일이라 사용자가 골라야 하고, ExternalSoundModeSettingTest가 지킵니다. 사용자의 선택이라 기기 전용 값처럼 복원할 때 비우지 않습니다.
  • "화면이 꺼지면 일시정지"의 기본값은 PAUSE_WHEN_SCREEN_OFF_DEFAULT 한 곳에만 둡니다. 흐름의 초기값과 저장값이 없을 때의 값을 따로 적으면 한쪽만 바꿔도 모르고 지나갑니다(ScreenOffPauseTest).
  • 개발자 모드(developerMode, 4장)도 같은 방식으로 DEVELOPER_MODE_DEFAULT(꺼짐) 한 곳에만 둡니다. 켜 두고 잊으면 사용자에게 영어 글자판이 그대로 남으므로, 기본값이 꺼짐인 것을 DeveloperModeSettingTest가 지킵니다. 오버레이가 이 값을 구독하므로 켜고 끄면 실행 중에도 바로 나타나고 사라집니다. Qualcomm frontend는 모든 실행에서 고정이며 개발자 A/B 선택은 없습니다.
  • 기록 스위치(developerRecord, 4장)는 개발자 모드와 따로 저장합니다(DEVELOPER_RECORD_DEFAULT=꺼짐). 개발자 모드를 껐다 켜도 기록 설정은 그대로 남고, 오버레이가 이 값을 구독하므로 켜면 파일이 새로 열리고 끄면 닫힙니다.
  • 튜토리얼을 본 적이 있는지(tutorialSeen, 키 tutorial_seen): 아직이면 MainActivity가 탭 화면 대신 튜토리얼을 띄우고, 닫으면(건너뛰기·확인·첫 쪽에서 뒤로 가기) true로 저장합니다. 보는 도중에 앱을 끄면 다음에 다시 뜹니다.
    • 저장된 적이 없을 때의 판단: 다른 값이 하나라도 저장돼 있으면 이 기능 전부터 앱을 쓰던 사람으로 보고 본 것으로 칩니다. 거의 모든 기존 사용자는 이미 앱을 아니 저절로 띄우지 않습니다(설정을 한 번도 바꾸지 않은 1.5.0 이전 설치는 한 번 봅니다). 아무것도 없으면 새로 설치한 것이라 띄웁니다.
    • 이 판단은 load가 기기 표시를 적기 전에 하고 곧바로 저장합니다. 새로 설치한 앱도 첫 실행에 기기 표시(device_tag)가 적히므로, 저장해 두지 않으면 튜토리얼을 닫지 않고 나갔다 다시 열 때 "쓰던 사람"으로 잘못 칩니다. 처음 닿은 곳이 타일이어도(타일 서비스가 init) 같은 규칙으로 false가 저장됩니다.
    • 보는 사람에 대한 값이라 기기 전용 값(꺼짐 안내·타일 추가)처럼 복원할 때 비우지 않습니다. 새 폰으로 옮겨 가도 다시 뜨지 않습니다.
    • TutorialSettingTest가 판단·저장·복원을 봅니다. 기존 설정이 있는 폰에서 다시 보이게 하려면 앱을 멈춘 뒤 run-as로 tutorial_seen을 false로 바꿉니다. 키를 지우면 다른 값이 있어 다시 "본 것"으로 판단합니다.
  • "그래픽 덜 자주 그리기"(reducedFrameRate, 4장)의 기본값은 REDUCED_FRAME_RATE_DEFAULT(꺼짐) 한 곳에만 둡니다. 소리를 눈으로 보는 앱이라 부드러운 쪽이 기본이고, 오래 켜 두는 사람이 고릅니다. 저장·복원과 엔진의 프레임 수까지 이어지는 배선은 ReducedFrameRateTest가 봅니다.
  • 분류 탭에서 바꾼 소리 종류(soundTypes, 키 sound_types, #283): YAMNet 소리 이름(yamnet_class_map.csv의 display_name)을 키로, 고른 라벨을 값으로 한 JSON 객체 하나로 저장합니다. 기본 종류와 같은 값과 세 라벨이 아닌 값은 읽을 때 버리고, 읽을 수 없는 저장값은 바꾼 것이 없는 것으로 봅니다(SoundTypeSettingsTest).
    • 기본 종류는 AI의 매핑(YamnetThreeClassMapper.mapDisplayNameToCoarse)을 읽기만 합니다(defaultSoundType). 매핑 규칙이 바뀌면 기본 종류도 함께 바뀌고, 그 결과 사용자가 고른 것과 같아진 소리는 다음에 읽을 때 "바꾼 소리"에서 빠집니다.
    • AI 는 추론마다 이 값의 스냅샷 하나를 읽어 투표와 안전 단서에 반영합니다(#321, docs/ai/user-mapping.md). 위협음에서 뺀 소리는 그 이름의 안전 단서도 끄지만, 같은 소리가 다른 위협음 이름(사이렌이면 Alarm)으로 함께 들리면 그 이름 때문에 위협음이 될 수 있습니다. 탭의 작은 안내(classify_sibling_note)가 이 점을 알립니다. 거꾸로 새로 위협음으로 정한 소리는 정상 투표에만 들어가므로, 도움말과 README 는 그 소리가 날 때마다 알린다고 약속하지 않습니다.
    • 여러 소리를 한 번에 바꾸는 것은 setSoundTypes(names, label)입니다(#349, 분류 탭의 묶음). 새 맵 하나를 만들어 흐름에 한 번 싣고 한 번 저장하며, 바뀌는 것이 없으면 아무것도 하지 않습니다. setSoundType은 소리 하나로 이 함수를 부릅니다. AI 는 맵이 바뀌면 후처리와 마지막 결과를 비우고 다시 시작하므로(RealtimeAiPipeline), 묶음(음악은 140개)을 소리마다 바꾸면 반쯤 바뀐 맵을 읽어 거듭 다시 시작하고 저장도 소리 수만큼 합니다.
    • 앱이 소리 이름을 고를 때(잠금 화면 알림)는 같은 규칙의 soundType(displayName)(바꾼 종류, 없으면 기본 종류)을 씁니다(#325).
    • 분류 탭은 모든 사용자에게 보입니다(#328). 자리는 설정과 도움말 사이입니다(홈 0 · 설정 1 · 분류 2 · 도움말 3). 액티비티가 정해 주는 홈·설정의 번호(MainActivity.TAB_*)는 그대로입니다.
    • 소리 이름 521개는 res/values…/sound_names.xml에 따로 있습니다. 영어는 모델 이름 그대로이고, 나머지 17개 언어는 번역입니다(#296). 온톨로지의 클래스 이름을 옮긴 것이라 NOTICE 7절에 CC BY-SA 4.0 으로 적어 둡니다.
  • 분류 탭의 고급 모드(classifyAdvanced, 키 classify_advanced, #349): 켜면 소리를 하나씩 바꾸는 화면, 끄면 묶음째 고르는 기본 모드입니다(아래 "분류 탭의 묶음"). 기본값은 CLASSIFY_ADVANCED_DEFAULT(꺼짐) 한 곳에만 둡니다.
    • 저장된 적이 없을 때의 판단: 이미 바꿔 둔 소리가 있으면 고급 모드로 엽니다(loadClassifyAdvanced). 묶음이 생기기 전에 소리를 하나씩 바꾼 사람을 기본 모드로 열면, 묶음째 고를 때 하나씩 바꾼 것이 알림 없이 덮어써질 수 있기 때문입니다. 고급 모드로 열어 두면 기본 모드로 돌아갈 때 먼저 알립니다. 바꾼 소리가 없으면 기본 모드입니다.
    • 이 판단은 load가 튜토리얼 판단 뒤에 하고 곧바로 저장합니다. 저장하지 않으면 기본 모드에서 묶음을 바꾼 사람이 다음에 열 때 "바꾼 소리가 있다"는 이유로 고급 모드가 됩니다. 튜토리얼 판단보다 먼저 적으면 새로 설치한 앱을 "쓰던 사람"으로 봅니다.
    • 사용자의 선택이라 복원할 때 비우지 않고, 모두 되돌리기(resetSoundTypes)도 건드리지 않습니다(ClassifyAdvancedSettingTest).
  • 설정 탭의 쉬운 설정(easySettings, 키 easy_settings, #408): 켜면 설정 탭이 그림으로 선택하는 쉬운 설정으로, 끄면 지금까지의 자세한 설정으로 열립니다(아래 "쉬운 설정"). 기본값은 EASY_SETTINGS_DEFAULT(켜짐) 한 곳에만 둡니다.
    • 저장된 적이 없을 때의 판단: 네 모드의 설정이 모두 처음 값(ModeSettings())이면 쉬운 설정, 하나라도 바꿨으면 손에 익은 자세한 설정으로 엽니다(loadEasySettings). 키가 있는지가 아니라 값으로 견줍니다. 자세한 설정에서 스위치를 켰다 끄기만 해도 그 모드의 키 11개가 모두 적히기 때문입니다. 색·진동·표현 모드처럼 쉬운 설정에도 보이는 값은 따지지 않습니다.
    • 이 판단은 load가 튜토리얼 판단 뒤에, 네 모드를 읽은 다음 하고 곧바로 저장합니다. 저장하지 않으면 쉬운 설정에서 크기를 바꾼 사람이 다음에 열 때 "모드 값을 바꿨다"는 이유로 자세한 설정이 됩니다(분류 탭 고급 모드와 같은 함정).
    • 화면 설정이라 다른 값은 바꾸지 않습니다. 사용자의 선택이라 복원할 때 비우지 않습니다(EasySettingsSettingTest).
  • 테스트는 SettingsManager.load(SharedPreferences)에 메모리 가짜 프리퍼런스를 넣어 값을 세웁니다(init(context)는 "처음 한 번만" 규칙을 지키고 이 함수를 부릅니다). 이 객체는 싱글턴이라 값을 바꾼 테스트는 @After에서 기본값으로 되돌립니다.

쉬운 설정 (#408)

1차 실증(농학교, 2026-10-21)에서 "설정이 너무 세세하고, '세기' 같은 글과 슬라이더보다 그림이 낫다"는 의견이 나왔습니다. 그래서 설정 탭 맨 위에 쉬운 설정 | 자세한 설정 두 칸을 두고, 쉬운 설정은 그림과 세 단계로만 만들었습니다(EasySettingsView). 자세한 설정은 지금까지의 화면 그대로입니다.

  • 화면: 모양(모드 카드 넷, 4장 "쉬운 설정의 모양 카드") → 크기(작게·보통·크게) → 소리 종류(위협음 → 사람 소리 → 환경음, 가장 중요한 종류부터, 분류 탭과 같은 순서. 종류마다 화면 표시 스위치와 진동 끔·약하게·세게) → 빠른 설정 타일 카드입니다. 보이는 글은 그림 밑의 낱말과 짧은 제목뿐이고, 설명 문장은 할 일이나 까닭을 알려야 할 때(진동이 없는 기기, 표시를 켜야 진동함, 외부 사운드 모드의 환경음, AI 를 쓸 수 없음 등)만 둡니다.
  • 빠지는 것: 모드별 상세(속도·민감도·진하기·광원·공간 리플·크기 고정·반지름), 색 바꾸기, 진동 방식 다섯 칸과 세기 슬라이더, 배터리, 만든 사람들용. 값은 그대로 남아 계속 적용됩니다. 도움말과 튜토리얼은 이것들이 '자세한 설정'에 있다고 그 언어의 단추 글자 그대로 적습니다(EasySettingsStringsTest).
  • 크기(EasySize): 한 단계가 크기와 진하기를 함께 정합니다(작게 30·40, 보통 = 처음 값, 크게 75·70). 크기는 자세한 설정처럼 모드마다 따로입니다(사용자 결정 2026-10-22). 크기 칸은 선택한 모드의 크기를 보여 주고 그 모드에만 쓰며, 그림도 그 모드의 모양(파도의 띠, 외곽선의 선, 패드의 막대, 원형의 고리)으로 그립니다(SizeStepIcon). 화면 읽어주기에는 "크기, 원형"처럼 어느 모드의 크기인지 함께 읽힙니다. 크기 고정을 켜 둔 모드는 크기가 움직이지 않으므로 누르면 끕니다. 그 밖의 값은 건드리지 않습니다. 그 모드가 한 단계에 맞을 때만 그 칸을 선택해 둡니다. 단계 밑에는 가로로 긴 '자세한 설정' 칸이 있습니다(#427). 자세한 설정에서 맞춘 크기·진하기(또는 크기 고정)가 단계와 다르면 단계 대신 이 칸이 선택되어 보입니다(가장 가까운 단계로 보이면 실제 크기와 다르고, 그 칸을 누르면 오히려 크기가 바뀝니다, #424). 이 칸을 누르면 값은 그대로 두고 자세한 설정으로 넘어가, 그 모드의 카드(맨 위가 크기 고정·크기)를 화면 맨 위에 둡니다. 카드를 접어 두었으면 펼칩니다(SettingsExpander 의 expandNow). 단계를 누르면 그 모드만 바뀌고 모양 카드에 바로 보이므로 따로 묻지 않습니다.
  • 진동(EasyVibration): 세기만 고릅니다(약하게 40%, 세게 100%, 끔은 세기를 남김). 꺼져 있던 종류를 켤 때만 방식을 정하고(위협음 중간, 나머지 느림), 이미 켜진 종류는 자세한 설정에서 정해 둔 방식을 둡니다. 세기가 40%·100% 일 때만 그 칸을 선택해 둡니다(#424). 자세한 설정에서 다른 세기로 맞췄으면 크기처럼 그 줄의 '자세한 설정' 칸이 선택되어 보이고, 누르면 자세한 설정의 소리 종류 카드로 넘어가 그 종류의 표시·진동 묶음이 다 보이게 합니다(사용자 결정 2026-10-22, #427). 예전에는 가장 가까운 칸(50% 까지 약하게)으로 보였는데, 실제 세기와 다르고 그 칸을 눌러도 바뀌지 않았습니다. 처음 값(위협음 100%, 나머지 꺼짐)은 단계와 같아 새로 설치하면 '자세한 설정' 칸이 선택되지 않습니다. 외부 사운드 모드의 상한은 5장과 같은 규칙입니다.
  • 새 설정은 기본으로 자세한 설정에만 넣습니다. 쉬운 설정에는 대부분의 사람이 바꿔야 하는 것만 둡니다. 화면이 두 벌이라 둘을 맞추는 일이 늘기 때문입니다.
  • 두 칸은 카드 위에 둡니다. 고른 칸의 색(AccentFillColor)이 탭 배경 위에서는 그림 요소 대비(3:1)에 못 미치기 때문입니다(분류 탭의 고급 모드 스위치와 같습니다).
  • 테스트: 값 연결은 EasySizeTest·EasyVibrationTest·EasySettingsSettingTest(JVM), 배선은 EasySettingsSourceTest, 화면 읽어주기와 누름은 EasySettingsInstrumentedTest가, 종류 그림이 한 덩어리로 칠해지는지는 EasyIconsInstrumentedTest가 봅니다. 계측 테스트는 진동 칸을 누르지 않고 선택되어 보이는 칸만 봅니다(누르면 폰이 울립니다). '자세한 설정' 칸은 모두 같은 글자라 번호가 아니라 같은 묶음의 이웃 칸으로 찾습니다. 목록을 밀면 맨 위 두 칸이 목록에서 빠져 번호가 어긋납니다. 자세한 설정을 보는 기존 계측 테스트(AccessibilitySemanticsInstrumentedTest)는 시작할 때 자세한 설정으로 세우고 끝나면 되돌립니다.

분류 탭의 묶음 (SoundGroups, #349)

분류 탭은 소리 521개를 한 줄씩 늘어놓는 대신 비슷한 소리를 묶은 카드로 보여 줍니다. 저장하는 것은 위의 소리마다의 종류(sound_types)뿐이고, 묶음의 상태(모두 같음·일부 다름)는 저장하지 않고 그때그때 정합니다. AI 와의 약속(docs/ai/user-mapping.md)은 그대로이고 ai/는 바뀌지 않습니다.

  • 묶음 표: assets/sound_groups.tsv입니다. 머리글 뒤에 group_id<TAB>display_name을 한 줄에 소리 하나씩, 화면에 보이는 순서로 521줄 둡니다. AudioSet 온톨로지의 갈래를 읽고 사람이 정한 표라 CC BY-SA 4.0 머리글(SPDX, 출처, 바꾼 점)을 달아 assets/ai 밖에 따로 두고(NOTICE 7절), AI 는 이 표를 읽지 않습니다. 저장소에 표를 만드는 스크립트는 없습니다. 검토한 데이터가 원본이고, 지켜야 할 규칙은 SoundGroupTableTest가 막습니다. SoundCatalog.load가 클래스 목록과 함께 메인 스레드 밖에서 한 번 읽어 SoundEntry.group·order에 넣습니다.

  • 묶음과 이름: SoundGroup enum 37개가 묶음의 순서와 이름을 정합니다. 15개는 새로 지은 이름(R.string.sound_group_*)이고, 22개는 이름이 곧 묶음 이름인 소리(사이렌, 초인종 등)의 sound_names 번역을 그대로 써서 따로 번역하지 않습니다. 말소리 묶음(talking)은 소리 이름 'Speech'를 묶음 이름으로 쓰다가, 영어 등 6개 언어에서 구역 머리(사람 소리)와 같은 낱말로 읽혀 새로 지은 이름('말소리·속삭임')으로 바꿨습니다(#374). 묶음 이름은 구역 머리와 같지 않게 하고, 새로 지은 이름은 제 묶음에 든 소리의 이름과도 같지 않게 합니다(SoundGroupStringsTest). 코드와 새로 지은 이름은 Apache-2.0 이고, 소리가 어느 묶음인지(표)만 CC BY-SA 4.0 이라 둘을 나눠 둡니다.

  • 구역과 순서: 구역은 위협음 → 사람 소리 → 환경음입니다. 지금 매핑으로 위협음 11개(경보음, 사이렌, 경적·자동차 경보음, 차량 경고음, 유리 깨짐·충돌, 문 쾅 닫는 소리, 총소리, 폭발음, 불꽃놀이, 천둥, 전기톱), 사람 소리 7개(말소리·속삭임, 외침·비명·울음, 아기 울음소리, 군중, 웃음소리, 노랫소리, 그리고 #421 로 사람 소리가 된 기침·숨소리·재채기만 담은 ‘기침·숨소리·몸 소리’ 카드), 환경음 20개입니다. ‘기침·숨소리·몸 소리’ 묶음은 나머지 소리가 환경음이라 두 구역에 카드가 하나씩 보입니다. 위협음은 안전에 중요한 순서이고, 사람 소리는 아기 울음소리를 외침·비명·울음 바로 뒤에, 환경음은 주의를 끄는 소리(초인종, 전화, 알람·삐 소리, 불, 개)부터 둡니다. 경보음 묶음(경보음, 연기 감지기 경보음, 화재 경보음)과 사이렌 묶음(사이렌 5개)은 따로입니다.

  • 한 소리는 한 묶음에만(규칙 D): 온톨로지에서 부모가 여럿인 소리 36개는 아래 순서로 정해 고정해 두었습니다(SoundGroupTableTest의 고정 목록). 하나를 옮기려면 그 목록도 일부러 고칩니다.

    1. 이름이 곧 묶음 이름인 소리는 그 묶음입니다(초인종, 종소리).
    2. 그 소리의 기본 종류와 같은 종류의 묶음으로 이어지는 부모만 셉니다. 그래서 합창은 노랫소리, 왁자지껄한 소리는 군중 묶음입니다.
    3. 구체적인 소리 출처가 소리 모양 갈래(의성어, 짧은 소리, 부딪는 소리, 딸깍)보다 앞섭니다. 그래서 타닥타닥 소리는 불, 새 지저귐은 동물, 째깍 소리는 가전제품·시계 묶음입니다.
    4. 더 깊은 부모가 앞섭니다.
    5. 그래도 같으면 ontology.json(커밋 d417d32)에서 먼저 나오는 부모입니다.

    23개는 이 규칙대로이고, 13개는 뜻으로 정했습니다. 노크 소리·톡톡 두드리는 소리는 초인종(누가 문 앞에 있음), 자전거 벨은 차량 경고음, 트럭 경적은 경적·자동차 경보음, 덜거덕거리는 소리·쉬익 소리는 물건 소리·짧은 소리, 카우벨은 종소리, 마이크 바람 잡음은 TV·라디오·배경 소리 묶음입니다. #344 로 기본 종류가 바뀐 뒤 규칙이 차량 경고음을 가리키게 된 자동차 경적·자동차 경보음은 경적·자동차 경보음에, 경찰차·구급차·소방차 사이렌은 사이렌에 그대로 둡니다.

  • #344 반영: 차량 경고음 묶음(후진 경고음, 기차 경적 등 8개)은 위협음이 되어 경적 바로 뒤에 둡니다. 환경음으로 남은 짧은 경적(Toot)은 탈것 묶음 끝으로, 문 쾅 닫는 소리(Slam)는 문 묶음에서 나와 유리 깨짐·충돌 바로 뒤의 한 개짜리 위협음 묶음으로(이름은 그 소리 이름), 사람 소리이던 싱잉볼은 환경음이 되어 음악 묶음 끝으로 옮겼습니다.

  • #369 반영(#370): 사람 소리가 된 사람 발성 11개를 사람 소리 묶음으로 옮겼습니다. 웃음 넷(키득키득 웃음, 비웃음, 폭소, 조용한 웃음)은 웃음소리, 고함·우렁찬 고함·환호성·신음(Yell, Bellow, Whoop, Wail/moan)은 외침·비명·울음, 응원 함성(Cheering)은 군중 묶음 끝에 붙였고, 이미 있던 소리의 순서는 그대로라 미리보기가 바뀌지 않습니다. 아기 울음소리 묶음(아기 울음소리, 흐느낌)은 그대로 두고 자리만 외침·비명·울음 바로 뒤로 옮겼습니다. 개 낑낑거림(Whimper (dog))은 환경음이라 개 묶음에 남습니다. 목소리 묶음(voices)에는 박수·콧노래·휘파람 같은 환경음 9개만 남아 이름을 그에 맞게 바꿨습니다(18개 언어). 옛 이름('고함·웃음·환호')은 옮겨 간 소리를 가리켰고, 여러 언어에서 그 소리의 이름을 낱말로 그대로 썼기 때문입니다. 11개 모두 부모가 하나라 규칙 D 의 고정 목록과 결과는 그대로입니다.

  • 카드 = (묶음, 기본 종류)(규칙 B): 구역과 카드는 화면이 그때그때 나눕니다(SoundGroups.sections). 지금은 묶음마다 기본 종류가 하나라 카드 하나가 곧 묶음 하나입니다. AI 의 매핑이 한 소리의 기본 종류를 바꾸면 표를 고치지 않아도 그 소리는 새 종류의 구역에 같은 묶음 이름의 카드로 따로 보입니다. 그래서 한 카드 안의 기본 종류는 늘 하나이고, 카드에서 고르는 "(기본)" 이 모든 소리에 맞습니다. 묶음 테스트는 묶음마다의 종류나 개수를 보지 않아 매핑을 고치는 PR 이 걸리지 않습니다. 표에 없는 소리는 그 소리 하나로 구역 끝에 카드가 됩니다.

  • 기본 모드(처음 켜면 이것): 카드는 이름, 미리보기(이름인 소리를 뺀 세 개까지와 "+N"), 지금 종류 버튼입니다. 카드를 누르면 종류 목록이 열리고, 고르면 setSoundTypes가 찾는 말·거르기와 상관없이 카드의 소리를 모두 한 번에 바꿉니다. 기본 종류를 고르면 바꾼 것을 모두 지웁니다.

    • 묶음째 바꾼 카드는 제 구역(기본 종류)에 그대로 있고, 버튼과 "지금: … · 기본: …" 줄로 지금 종류를 눈에 띄게 보여 줍니다.
    • 소리마다 종류가 다른 카드는 버튼에 "일부 다름", 이름 밑에 "일부 소리를 하나씩 바꿈"이 보이고, 목록에서 고른 것이 없습니다. 이 카드에서는 하나씩 바꿔 둔 소리(기본 종류가 아닌 소리)의 종류가 실제로 바뀔 때만 먼저 묻습니다(SoundGroups.overwritesOneByOne, #374). 그 소리와 같은 종류를 고르면 그 소리는 그대로라 묻지 않고 바로 바꾸고, 기본 종류를 고르면 늘 묻습니다. 묻는 창의 수는 그대로 종류가 바뀌는 소리 수입니다(overwriteCount). 모두 같은 카드는 묻지 않습니다. 묶음째 X 로 바꾼 뒤 한 소리만 기본 종류로 되돌린 카드에서 X 를 고르면 묻지 않습니다. 저장값으로는 '되돌린 소리'와 '건드리지 않은 소리'를 가릴 수 없기 때문입니다.
  • 고급 모드: 같은 구역·묶음 순서로 묶음 머리(이름과 소리 수) 밑에 소리 줄을 폅니다(SoundGroups.advancedItems). 묶음째 바꾸는 칸은 없어서, 묶음 전체를 덮어쓰는 일은 기본 모드에서만 일어납니다. 켤 때는 묻지 않습니다. 끌 때 소리마다 종류가 다른 카드가 하나라도 있으면(찾는 말·거르기와 상관없이 저장값으로 봄) 기본 모드에서 그 묶음을 고르면 덮어써진다고 먼저 알립니다. 확인하면 모드만 저장하고, 취소하거나 뒤로 가면 고급 모드 그대로입니다. 스위치는 저장값을 그대로 보여 줄 뿐이라 확인 전에는 움직이지 않습니다. 스위치는 앱의 다른 스위치처럼 카드 위에 둡니다. 켜진 막대의 파랑(AccentFillColor)이 카드 위에서는 3:1 을 넘지만 탭의 배경 위에서는 2.7:1 이기 때문입니다(ColorContrastTest, 계측 테스트 advancedSwitchSitsOnACard).

  • 찾기와 거르기: 묶음 이름과 소리 이름을 보이는 이름과 영어 이름 어느 쪽으로든 찾습니다. 새로 지은 묶음 이름의 영어는 영어로 맞춘 리소스에서 꺼냅니다(SoundGroups.titleKeys). 띄어쓰기와 문장 부호는 가리지 않습니다(SoundCatalog.normalize). 새로 지은 이름은 낱말을 '·'나 쉼표로 이어서("부엌·요리"), 부호를 남겨 두면 "부엌 요리"처럼 띄어 친 두 낱말로는 찾지 못했습니다. 묶음 이름이 맞으면 묶음 전체가, 소리 이름이 맞으면 그 묶음이 남고 맞은 소리가 미리보기 앞에 옵니다. 거르기는 소리마다 지금 종류(또는 바꿨는지)로 보고, 맞는 소리가 하나라도 있으면 카드가 남습니다. 빈 구역은 숨깁니다. 고급 모드는 같은 결과에서 맞는 줄만 보입니다. 거르기를 켠 채로 종류를 바꾸면, 바꾼 소리가 거르기에서 빠져도 찾는 말이나 거르기를 바꿀 때까지 목록에 남깁니다(KeptSounds, 모델 이름으로, #374). 기본 모드는 카드가, 고급 모드는 그 줄이 그 자리에 남아 화면 읽어주기의 자리도 그대로입니다. 화면을 돌려도 남고(rememberSaveable), 모두 되돌리기를 하면 지웁니다. 찾는 말은 그대로 적용합니다.

  • 목록: 한 LazyColumn이고, 칸마다 키(s:종류, c:묶음:종류, g:묶음:종류, r:번호)와 모양(contentType)을 따로 둡니다. 고급 모드는 약 560칸(소리 521, 묶음 머리 37, 구역 머리 3)입니다. 구역 나누기는 아래 색인을 만들 때 한 번이고, 글자를 칠 때마다 521개를 다시 거릅니다.

  • 색인(SoundIndex, #368): 소리 목록, 앱 언어의 이름, 찾기 값(소리마다 두 이름을 다듬어 정규화 1,042번), 구역, 새로 지은 묶음 이름의 찾기 값을 지금 앱 언어로 한 번 Dispatchers.Default에서 만들어 프로세스에 둡니다(SoundCatalog.loadIndex). 두는 것은 앱 언어 하나의 것뿐이라, 언어가 바뀌면 새로 만들고 앞의 것은 버립니다(앞 언어로 돌아가면 다시 만듭니다). 탭은 페이저의 옆 쪽이라 홈에서 설정으로 넘기기만 해도 미리 만들어지는데, 예전에는 그때마다 메인 스레드에서 이것을 다시 만들었습니다. 시각화가 켜져 있으면 오버레이도 같은 메인 스레드를 씁니다.

    • 이름은 액티비티의 리소스에서 읽습니다. Android 12 이하에서는 앱 언어가 액티비티에만 입혀지고 applicationContext 는 폰 언어 그대로입니다. 키와 이름은 그 리소스의 설정을 한 번 떠 둔 것에서 읽습니다. Android 13 이상에서는 액티비티의 리소스가 언어 변경에 그 자리에서 바뀔 수 있어, 만드는 사이에 언어가 바뀌면 다른 언어의 이름이 그 키로 들어갈 수 있기 때문입니다.
    • 키는 그 리소스의 언어 태그(LocaleList.toLanguageTags)입니다. 탭은 들고 있는 색인의 언어가 키와 다르면 새로 만듭니다. 지금은 언어가 바뀌면 액티비티가 다시 만들어지지만(configChanges 없음) 그것에 기대지 않습니다.
    • 다 만든 색인은 만드는 함수 안에서 캐시에 넣습니다. 만드는 중에 탭이 버려져도(코루틴 취소) 만든 것이 남고, 곧 다시 만들어진 탭은 앞의 것이 끝나기를 기다려 그 색인을 씁니다.
    • 다 만들기 전에는 예전처럼 목록을 그리지 않습니다. 짧은 목록으로 먼저 재면 되살린 스크롤 자리가 잘립니다. 그래서 프로세스에서 처음 그릴 때와 언어가 바뀐 뒤 처음 그릴 때는 만드는 동안(에뮬레이터 release 빌드에서 약 0.2초) 탭이 비어 있습니다. 예전에는 소리 목록이 읽혀 있으면(캡처가 먼저 읽어 둔 경우 포함) 메인 스레드에서 바로 만들어 그렸습니다.
    • 정규화(normalize)는 그대로 둡니다. 앱의 정규식은 ICU 를, JVM 테스트는 JDK 를 거쳐서 손으로 다시 쓰면 한쪽에서만 찾는 결과가 달라질 수 있습니다. 같은 문구를 넣었을 때 색인(buildIndex)의 이름·찾기 값·구역·묶음 이름 값과 찾은 결과가 예전에 그릴 때마다 만들던 것과 같은지는 SoundIndexTest가 봅니다. 리소스를 읽는 loadIndex(액티비티 리소스, 영어로 맞춘 리소스)는 이 테스트 밖입니다.
  • "바꾼 소리"와 모두 되돌리기는 묶음이 아니라 소리를 셉니다. 영어 등 수만 적던 언어도 무엇을 세는지("Sounds changed") 적습니다(#374). 되돌리기는 sound_types만 비우고 모드는 그대로 둡니다.

  • 알려진 한계: 함께 들리는 이름이 다른 묶음에 있을 수 있습니다(사이렌과 경보음, 총소리와 폭발음). 탭의 작은 안내가 이 점을 알립니다. 위협음으로 바꾼 묶음은 AI 의 투표에만 들어가므로 안전 신호가 아닙니다. 기본 모드에서 고르면 묶음 전체가 바뀌는데, 큰 묶음(음악 140개)도 마찬가지입니다.


7. 스레드 구성

스레드 우선순위 하는 일
메인 기본 설정 화면, 오버레이 프레임 계산과 그리기, 화면 켜짐·꺼짐 수신과 캡처 쉬기·다시 켜기, 500ms 간격 받을 수 없는 소리 확인, 꺼짐 알림, 실행 중 알림 버튼 받기
SV-AudioCapture URGENT_AUDIO AudioRecord 읽기, 피크 전달, AI 링버퍼 복사
SV-AiInit BACKGROUND 모델 복사·세션 생성 (시작 시 한 번)
AI 코루틴 Dispatchers.Default 250ms 간격 분석
분류 탭 색인 Dispatchers.Default 분류 탭의 색인 만들기 (앱 언어가 바뀔 때마다 한 번, #368)
SV-Haptic DISPLAY 100ms 간격 진동 판단, 느림·중간·빠름의 울림은 제 시각에

스레드 사이에서 주고받는 값은 모두 락 없이 읽거나, 짧은 동기화 구간만 거칩니다.

값 쓰는 쪽 읽는 쪽 방식
좌우 피크 캡처 메인 (readPeaks) C++ std::atomic
지난 틱 이후 최대 소리 크기 캡처 SV-Haptic (takeHapticPeak) C++ std::atomic
AI 입력 소리 캡처 AI 코루틴 AiAudioBuffer (synchronized 링버퍼)
분류 결과 AI 코루틴 메인, SV-Haptic AtomicReference
분류 탭 색인 분류 탭 색인 메인 (분류 탭) @Volatile 참조, 만들 때만 synchronized
설정 메인 (설정 화면) 메인 (오버레이), SV-Haptic StateFlow.value
AI 사용 가능 여부 SV-AiInit 메인 (홈·설정 화면) StateFlow
캡처 쉬는 중 메인 (화면 꺼짐 수신) 메인 (오버레이) StateFlow
소리를 받지 못하는 중 메인 (확인 틱) 메인 (홈 화면, 실행 중 알림) StateFlow
AI 파이프라인·진동 알림 붙이기, 쉬는 중 여부 SV-AiInit, 메인 SV-AiInit, 메인 aiLock

8. 앱 언어 (language/)

문구

  • 화면의 문구는 모두 res/values*/strings.xml에 있습니다. 기본 폴더 values가 영어이고(res/resources.properties의 unqualifiedResLocale=en-US), 한국어는 values-ko, 그 밖의 언어는 values-xx입니다.
  • 폰 언어의 폴더가 없거나 문구 하나가 빠져 있으면 안드로이드가 기본 폴더의 영어를 씁니다. 그래서 영어·한국어는 빠짐없이 두고(StringResourcesTest), 다른 언어는 빠져도 Lint 경고(MissingTranslation)로만 둡니다. 다만 분류 탭의 문구(classify_*·cd_classify_*·sound_group_*, help_sound_classify_groups)는 모든 언어에 있어야 합니다. 빠지면 그 언어의 탭에 영어 안내가 섞이므로 SoundGroupStringsTest가 막습니다(#349). 이 테스트는 도움말의 초인종 예시가 모든 언어에서 "새로 위협음으로 정한 소리는 투표에만 들어간다"는 단서를 붙였는지도 봅니다(6장의 알려진 한계). 묶음 이름이 구역 머리와, 새로 지은 묶음 이름이 제 묶음의 소리 이름과 같지 않은지, "바꾼 소리" 수가 소리를 센다고 적는지도 봅니다(#374). 쉬운 설정의 문구(settings_view_*·easy_*, #408)도 글을 덜 읽게 하려고 만든 화면이라 모든 언어에 있어야 하고, '자세한 설정'을 가리키는 도움말·튜토리얼 문구는 그 언어의 단추 글자를 그대로 인용해야 합니다(EasySettingsStringsTest).
  • 브랜드 이름(app_name 등)은 values에만 translatable="false"로 둡니다.
  • 지원 언어 목록은 AppLanguages(태그, 폴더, 그 언어로 쓴 이름)입니다. 안드로이드 코드를 쓰지 않아 JVM 테스트가 values-xx 폴더와 목록을 맞춰 봅니다.

언어 적용 (AppLanguage)

사용자가 고른 언어가 없으면 폰 언어를 따릅니다. 홈 오른쪽 아래의 언어 버튼(LanguageButton, 지구본과 지금 언어 이름, #308)에서 고르면 다음처럼 적용됩니다. 앱을 열면 처음 보이는 곳이라, 읽지 못하는 언어로 바뀌어도 지구본과 그 언어 스스로의 이름으로 찾을 수 있습니다. 오른쪽에서 왼쪽으로 쓰는 언어(아랍어)에서는 줄이 뒤집혀 왼쪽 아래에 옵니다.

Android 13 이상 Android 10~12
저장 시스템의 앱별 언어 (LocaleManager.applicationLocales, 비어 있으면 폰 언어) 전용 SharedPreferences 파일(AppLanguagePrefs)의 태그
적용 시스템이 앱 프로세스 전체에 적용 글자를 보여주는 컴포넌트가 attachBaseContext에서 AppLanguage.wrap으로 createConfigurationContext 한 컨텍스트를 씀
바꾼 뒤 시스템이 액티비티를 다시 만듦 Activity.recreate()
폰 설정의 앱 언어 같은 값이라 어느 쪽에서 바꿔도 맞음 없음
  • Android 12 이하에서 wrap을 거는 곳: MainActivity, tile/StartVisualizerActivity, AudioCaptureService(알림), OverlayService, tile/VisualizerTileService(타일 이름). 이 버전에서는 applicationContext의 언어가 바뀌지 않으므로, 토스트 문구는 액티비티에서 꺼내 넘깁니다.
  • 언어를 바꾸면 실행 중 알림도 다시 만듭니다. AppLanguage.changes가 오르면 서비스가 문구용 컨텍스트를 새로 얻어 알림을 다시 올리고, 알림 채널도 같은 ID로 다시 만듭니다(채널 이름은 만들 때 정해져, 그러지 않으면 시스템 설정에 옛 언어가 남습니다). 처음에는 12 이하만 신호를 올렸는데(#187), 13 이상은 시스템이 액티비티만 다시 만들고 이미 올라간 알림은 그대로 두기 때문에 옛 언어가 남았습니다(#206). 13 이상에서는 서비스 컨텍스트의 설정이 언제 갱신되는지 보장되지 않아, 고른 언어로 컨텍스트를 직접 만드는 AppLanguage.localizedContext()를 씁니다(기본 로캘은 건드리지 않습니다 — 그 버전에서는 시스템이 관리합니다).
  • 이미 떠 있는 서비스는 attachBaseContext만으로 부족합니다(#187). wrap은 컴포넌트가 만들어질 때 한 번만 걸리므로, 시각화를 켜 둔 채 언어를 바꾸면 실행 중 알림과 꺼짐 알림이 떠난 언어로 남습니다. 그래서 12 이하에서 언어가 바뀌면 AppLanguage.changes로 알리고, AudioCaptureService가 문구용 컨텍스트(uiContext)를 다시 만들어 알림을 새로 올립니다. Android 10 에뮬레이터에서 바꾸기 전에는 영어로 남고 고친 뒤에는 바로 한국어로 바뀌는 것을 확인했습니다.
  • 같은 버전에서 기본 로캘(LocaleList.setDefault)도 고른 언어로 맞춥니다. Compose 글자가 기본 로캘로 글꼴(간체·번체·일본어 한자 모양)과 줄바꿈을 고르기 때문입니다.
  • 언어 설정 파일은 SettingsManager와 따로 둡니다. attachBaseContext가 SettingsManager.init보다 먼저 불리기 때문입니다. 폰을 13 이상으로 올리면 앱을 처음 열 때 이 값을 시스템 설정으로 옮깁니다(migrateLegacyChoice).
  • 언어를 바꿔 액티비티가 다시 만들어져도 MainActivity가 보던 탭을 저장해 두어 설정 탭에 남습니다.
  • 숫자 표시는 언어와 상관없이 Locale.US로 형식을 맞춥니다.

폰 설정에 뜨는 언어 목록

app/build.gradle.kts의 androidResources { generateLocaleConfig = true }로, 빌드할 때 values-xx 폴더를 모아 locale config(_generated_res_locale_config.xml)를 만들고 매니페스트의 android:localeConfig에 넣습니다. Android 13 이상의 폰 설정 앱 언어에 이 목록이 뜹니다. 폴더를 추가하면 목록도 따라 바뀌므로 따로 고칠 파일이 없습니다.

App Bundle로 배포하더라도 앱 안에서 고른 언어의 문구가 빠지지 않도록 언어별 분할은 꺼 두었습니다(bundle { language { enableSplit = false } }).

긴 번역

번역은 영어·한국어보다 길 수 있어서, 짧은 이름표 자리는 글자가 잘리지 않게 해 두었습니다.

  • 탭 줄은 넘치면 옆으로 밀립니다.
  • 홈의 실행·실행 종료 버튼, 모드 선택 칸, 진동 방식 선택지, 쉬운 설정의 두 칸과 세 단계 칸(#408)은 가운데 정렬로 줄을 바꾸고, 같은 줄의 칸 높이를 함께 맞춥니다. 쉬운 설정의 낱말은 그림 밑에 붙으므로 한두 낱말로 번역합니다.
  • 슬라이더는 이름·값 한 줄과 전체 폭 슬라이더 한 줄로 나눠, 긴 이름이 들어갈 자리를 넓게 둡니다. 그래도 넘치면 줄을 바꾸고, 긴 단어는 하이픈을 넣어 끊습니다(wrappingLabelStyle, 하이픈 규칙이 있는 언어만).

9. 창 테마

화면은 Compose가 그리지만, 앱을 켜서 첫 화면을 그리기 전과 액티비티가 다시 만들어지는 동안에는 창 테마(res/values/themes.xml의 Theme.SoundVisualizer)의 배경이 보입니다. 이 색이 앱 배경과 다르면 앱을 켤 때마다 다른 색이 번쩍입니다.

  • 부모는 어두운 android:Theme.Material.NoActionBar이고, 창 배경·상태 표시줄·내비게이션 바를 @color/app_background(#2A2C31)로 칠합니다. 바 색은 첫 화면이 뜨기 전의 시작 창에서만 쓰입니다(Android 14 이하). 첫 화면이 뜨면 아래처럼 바가 투명해집니다.
  • Android 12 이상에서 앱을 켤 때 뜨는 시스템 스플래시의 배경(windowSplashScreenBackground)도 같은 색입니다(res/values-v31/themes.xml).
  • 스플래시 아이콘은 런처 아이콘을 쓰고, 그 뒤에 흰 원(windowSplashScreenIconBackgroundColor = @color/splash_icon_background)을 깝니다.
    • 이 색이 없으면 스플래시는 아이콘의 전경만 꺼내 원으로 자릅니다. 전경 PNG에는 로고 뒤에 흰 사각 판이 있어서, 판의 네 모서리가 잘린 채 보였습니다(#275).
    • 원의 색은 런처 아이콘 배경 레이어(adaptive_icon_background.png)와 같은 흰색이라 판이 원에 묻힙니다. 두 색이 어긋나거나 이 값이 빠지면 AppWindowThemeTest가 실패합니다.
  • app_background는 Compose의 BgColor(UiColors.kt)와 같은 값이어야 합니다. 어긋나거나 밝은 테마로 돌아가면 AppWindowThemeTest가 실패합니다.
  • 화면은 시스템 바 밑까지 그립니다(edge-to-edge, #138).
    • MainActivity가 enableEdgeToEdge로 바를 투명하게 합니다.
    • LauncherApp은 탭과 내용을 safeDrawingPadding으로 상태 표시줄·내비게이션 바·카메라 구멍에서 비켜 놓습니다. 비켜 놓은 자리에는 바깥 Surface가 칠한 앱 배경색이 보입니다.
    • 탭은 화면 아무 데서나 좌우로 밀어도 넘어갑니다. 쪽 너비의 10%를 넘게 끌고 놓으면 다음 탭으로 넘어갑니다(PAGE_SNAP_THRESHOLD, 튜토리얼과 같은 값, #337). 페이저의 기본값(절반)도 30%(#335)도 짧게 끌고 놓을 때 앞 탭으로 튕겨 돌아갔습니다. 페이저는 가로로 먼저 움직일 때만 끌리므로 위아래로 밀거나 살짝 건드린 것으로는 넘어가지 않고, 슬라이더를 끄는 손동작은 슬라이더가 받습니다. TabSwipeInstrumentedTest가 20%를 1초에 걸쳐 끌어 봅니다.
    • 지금 탭은 페이저가 정말 멈춘 뒤에만 액티비티에 돌려줍니다(rememberRestingPage, #339). 페이저의 settledPage는 이름과 달리 손을 떼고 미끄러지기 시작하는 순간에도 그때 가장 가까운 쪽으로 바뀝니다(스크롤을 새로 시작할 때마다 적어 두는데, 손을 뗀 뒤의 미끄러짐도 새 스크롤입니다). 그 값을 돌려주면 탭을 맞추는 효과가 넘어가던 화면을 앞 탭으로 끌어올 수 있었습니다. 같은 테스트가 4분의 1을 0.08초에 두 번 튕겨 봅니다. 탭 화면은 고치기 전에도 이 테스트를 통과했습니다. 탭을 맞추는 효과는 보이는 탭이 돌려받은 탭과 다를 때만 움직여서, 손을 뗀 곳이 두 탭의 가운데 바로 앞일 때만 걸리는 드문 경우였습니다.
    • Android 15 이상은 targetSdk 35부터 이 방식을 강제하고, 36부터는 끌 수도 없습니다. 옛 버전(10~14)도 같은 모양이 되게 직접 켭니다.
    • 앱 화면은 폰의 라이트·다크 모드와 상관없이 늘 어두우므로, 바의 아이콘은 밝게 고정합니다(SystemBarStyle.dark).
    • Compose 테마(SoundVisualizerTheme)는 창을 건드리지 않습니다. 같은 테마를 쓰는 타일의 투명 화면에 앱 화면의 바 설정이 따라가면 안 되고, 창의 statusBarColor는 Android 15 이상에서 효과도 없기 때문입니다.
    • 탭 이름이 바에 가리지 않는지는 LauncherInsetsInstrumentedTest가 기기에서 확인합니다. 상태 표시줄이 24dp인 기기(Android 10 에뮬레이터 등)에서는 여백을 빼도 겹치지 않아 통과하므로, 최신 기기에서 돌려야 의미가 있습니다.
  • 빠른 설정 타일이 여는 tile/StartVisualizerActivity는 앱 화면 테마 대신 전용 투명 테마(Theme.SoundVisualizer.Translucent)를 씁니다. 보던 앱 위에 권한 창만 띄워야 하기 때문입니다.
    • 예전 테마의 문제: 예전에는 플랫폼의 Theme.Translucent.NoTitleBar를 썼습니다. 이 옛 테마는 시스템 바 배경을 그리지 않아, 권한 안내 창이 떠 있는 동안 위아래 바가 검게 칠해졌습니다.
    • 지금 테마: Material 테마에 바 색을 투명으로 둬서 보던 앱이 비칩니다(#142). 나머지 항목은 옛 투명 테마가 하던 일(투명 배경, 창 전환 애니메이션 없음)을 그대로 옮긴 것입니다.
    • backgroundDimEnabled를 두지 않는 이유: 안내 창(Compose 대화 상자)이 이 값을 물려받아, 끄면 창 뒤가 어두워지지 않습니다.
    • Android 10 에뮬레이터에서는 여전히 바가 검게 보입니다. 뒤를 어둡게 하는 떠 있는 창(대화 상자)이 맨 위에 있으면 시스템이 바를 칠하기 때문이고, 바꾸기 전과 같습니다.
    • 확인한 기기: API 37 에뮬레이터와 Galaxy S25+(Android 16)에서는 안내 창 뒤로 보던 화면이 어둡게 비칩니다. Android 11~14 는 확인하지 못했습니다.
    • 테스트: 매니페스트가 이 테마를 가리키는지, 그 테마가 Material 기반에 바까지 투명한지는 AppWindowThemeTest가 봅니다.

10. 화면 읽어주기와 글꼴 크기

이 앱은 소리를 못 듣는 사용자를 위한 앱이라 저시력·시각장애를 함께 가진 사용자도 씁니다. 그래서 설정 화면의 조작은 눈으로 보이는 것(색, 밑줄, 스위치 모양)만으로 뜻이 전해지지 않게 두었습니다.

  • 선택지 줄(탭, 모드 선택, 진동 방식, 쉬운 설정의 두 칸·모양 카드·크기·진동 세 칸): 줄에 selectableGroup, 각 칸에 Modifier.selectable(selected = …, role = …)을 답니다. 이것이 없으면 고른 칸이 색으로만 구분돼 "선택됨"이 읽히지 않습니다. 탭은 Role.Tab, 나머지는 Role.RadioButton입니다. 쉬운 설정의 칸은 그림과 낱말을 한 덩어리로 읽고 그림은 읽지 않습니다. 쉬운 설정에서 고른 칸은 색에 더해 체크 표시로도 보입니다. 모양 카드는 이름 앞에, 크기·진동 칸은 칸 모서리에 둡니다. 좁은 칸에서 낱말 앞에 두면 긴 번역어(360dp 폭의 "Desligada")가 줄을 바꾸기 때문입니다. 크기·진동 줄 밑의 '자세한 설정' 칸(#427)은 같은 선택지 묶음에 들어 있어 "4개 중 4번째"로 함께 세어지고, 가로로 길어 체크 표시를 낱말 뒤에 둡니다. 칸과 '자세한 설정' 칸은 한 레이아웃 아래 보이는 순서대로 놓습니다(EqualChoiceRows, #434). 화면 읽어주기는 묶음 안의 순번을 칸을 놓은 순서로 매기는데 레이아웃 부모마다 따로 세서, 줄마다 Row 를 두었을 때는 두 줄짜리 모양 카드가 1·3·1·3번째로 읽혔습니다. 시험은 시스템에 넘기는 접근성 노드를 직접 만들어 순번을 읽습니다(EasySettingsInstrumentedTest).
  • 스위치 줄: 스위치가 아니라 줄 전체에 Modifier.toggleable(role = Role.Switch)를 달고 스위치의 onCheckedChange는 null로 둡니다. 그래야 이름·설명·상태가 한 덩어리로 읽히고("환경음 표시, 스위치, 켜짐"), 손가락으로도 작은 스위치를 노리지 않아도 됩니다. 색상 버튼은 이 덩어리 밖에 둡니다. 안에 넣으면 묶여 버려 따로 고를 수 없습니다. 쉬운 설정의 소리 종류 줄은 화면에 종류 이름과 눈 그림뿐이라, 자세한 설정의 스위치 이름(ai_show_*, "위협음 표시")으로 읽히게 둡니다. 그 이름에 화면의 종류 이름이 들어 있지 않은 언어(pt-BR·tr·pl)는 종류 이름을 앞에 붙입니다(typeRowName, #434). 보이는 글자를 말해 누르는 음성 조작도 이 줄을 찾을 수 있어야 하기 때문입니다(WCAG 2.5.3). 종류마다 같은 "진동" 칸이 셋이라 진동 이름은 "위협음, 진동"처럼 종류 이름과 함께 읽힙니다(#312).
  • 이름 붙이기: 슬라이더와 색상 버튼은 그 자체로는 이름이 없어 "슬라이더, 50%", "버튼"으로만 읽힙니다. 슬라이더는 semantics { contentDescription = 이름 }, 소리 종류의 색상 버튼은 종류별 이름(cd_color_*)을 답니다.
  • 슬라이더 값: 슬라이더는 값을 범위 안의 위치(퍼센트)로 읽습니다. 범위가 0부터가 아니면 화면 숫자와 달라지므로(10~100 에서 "55" 가 "50퍼센트") stateDescription에 화면에 보이는 글자를 그대로 둡니다(ModernSlider, 진동 세기).
  • 제목: 설정의 구역 제목과 펼치기 카드의 제목(설정의 모드 카드, 도움말의 주제)은 heading()으로 표시해 화면 읽어주기에서 제목 단위로 건너뛸 수 있게 합니다. 펼치기 카드의 머리는 Role.Button입니다.
  • 상태 알림: 홈의 상태 줄은 liveRegion = Polite라 실행·실행 종료를 누르면 바뀐 상태를 읽어 줍니다. 켜고 끌 때만 바뀌는 글이라 자주 끼어들지 않습니다.
  • 목록: 도움말의 번호 목록은 번호와 글을 한 덩어리로 읽고, 점 목록의 "•" 는 읽지 않습니다.
  • 창의 긴 본문: 확인 창(AlertDialog)은 본문에 남는 높이만 주므로, 본문은 늘 스크롤되게 둡니다. 그러지 않으면 큰 글꼴에서 권한 안내의 뒷부분이 잘려 볼 수 없습니다. 분류 탭의 세 확인 창(모두 되돌리기, 묶음 전체 바꾸기, 기본 모드로 돌아가기)도 같습니다.
  • 분류 탭(#349): 구역 머리(종류)와 고급 모드의 묶음 머리("사이렌 묶음, 소리 5개")는 heading()이라 구역·묶음 단위로 건너뛸 수 있습니다. 묶음 카드와 소리 줄은 Role.DropdownList이고, 이름·지금 종류·(바꿨으면) 기본 종류·소리 수·미리보기를 한 덩어리(cd_classify_*)로 읽으며 48dp 이상입니다. 소리마다 종류가 다른 묶음은 종류를 "일부 다름"으로 읽고, 열린 목록에서 "선택됨"이 없습니다. 고급 모드 스위치는 다른 스위치 줄처럼 줄 전체가 Role.Switch입니다. 거르기 칸은 48dp 이상의 라디오 단추입니다(classifyFilterChipsAreRadioButtonsAtLeast48dp).
  • 터치 크기: 색상 동그라미(32dp)와 색상 선택 창의 프리셋(30dp)은 보이는 크기를 그대로 두고 누를 수 있는 칸만 48dp로 키웁니다. 프리셋 여덟 개는 창 너비에 48dp씩 들어가지 않아 네 개씩 두 줄로 나눕니다. 펼치기 카드의 머리도 카드 안쪽 여백 일부를 누르는 칸 안으로 옮겨, 보이는 자리는 그대로 두고 48dp 높이로 누릅니다. 분류 탭의 거르기 칸도 보이는 크기(글자 한 줄과 위아래 8dp)는 그대로 두고 누르는 칸만 48dp 이상으로 키웁니다(#374). 보이는 칸은 누르는 칸 가운데에 있고, 줄 사이 간격이던 위아래 4dp 를 누르는 칸 안으로 옮겼습니다. 그래서 큰 글꼴로 보이는 칸이 40dp 이상이면 간격과 붙어 있는 머리의 높이가 예전과 같고, 보통 글꼴에서는 보이는 칸이 40dp 보다 낮아 48dp 를 채우는 만큼 줄 사이와 머리가 예전보다 조금 높습니다. Compose 가 작은 누름 칸을 48dp 로 넓혀 판정하기는 하지만(touchBoundsInRoot) 이웃과 겹치지 않을 때만이라 레이아웃으로 둡니다.
  • 펼침 상태: 펼치기 카드(SettingsExpander)의 상태는 rememberSaveable로 들고 있습니다. remember로 두면 목록 밖으로 스크롤된 카드의 상태가 사라져, 돌아왔을 때 펼쳐 둔 카드가 접혀 있습니다.
  • 큰 글꼴의 선택지 칸: 진동 방식(다섯 칸)과 표현 모드(네 칸)는 같은 너비 칸을 한 줄에 둡니다. 쉬운 설정의 두 칸과 세 단계 칸도 같은 규칙입니다(#408, 세 칸은 2+1). 글꼴을 키웠을 때 어느 이름의 한 단어라도 칸에 들어가지 않으면 한 줄의 칸 수를 줄여 여러 줄로 둡니다(다섯 칸은 3+2, 그래도 안 되면 2+2+1, 네 칸은 2+2, choicesPerRow·EqualChoiceRows, #304). 그러지 않으면 "Medium" 이 "Med/ium", "느림" 이 "느/림" 처럼 글자 중간에서 끊깁니다. 보통 글꼴에서는 어느 언어든 예전처럼 한 줄이고, 긴 번역어는 하이픈으로 끊습니다.
  • 큰 글꼴·가로 화면: 홈 탭은 세로 스크롤을 열고 최소 높이를 화면 높이로 잡아, 짧을 때는 가운데 정렬로 두고 길어지면 밀어 볼 수 있게 합니다. 홈 제목처럼 큰 글자는 줄 간격(lineHeight)을 지정해 두 줄이 될 때 서로 붙지 않게 합니다.
    • 홈 맨 아래 한 줄(왼쪽 튜토리얼 보기, 오른쪽 언어 버튼)은 같은 칸 안에서 본 내용 위아래에 weight를 준 빈칸을 두어 바닥에 붙입니다. 스크롤 안이라 최대 높이는 끝이 없지만, Column은 그럴 때 최소 높이(화면 높이)까지 남는 자리를 weight에 나눠 줍니다. 내용이 화면보다 길면 빈칸이 0이 되고 버튼은 스크롤 끝에 옵니다.

튜토리얼 (tutorial/)

처음 여는 사람에게 뜨는 튜토리얼은 전체 화면을 바꿔 끼웁니다(MainActivity). 탭 화면을 뒤에 겹쳐 두지 않아 화면 읽어주기가 가려진 홈을 읽지 않습니다.

  • 창 이름: 튜토리얼 전체에 paneTitle("튜토리얼")을 붙여 열리고 닫힐 때 알립니다.
  • 넘기는 기준: 쪽 너비의 10%를 넘게 끌고 놓으면 다음 쪽으로 넘어갑니다(PAGE_SNAP_THRESHOLD, 탭 화면과 같은 값). 빠르게 튕기면(400dp/s 이상) 거리와 상관없이 튕긴 쪽으로 넘어갑니다. 페이저의 기본값(절반)도 30%(#335)도 짧게 끌고 놓을 때 앞 쪽으로 튕겨 돌아가는 일이 잦았습니다(#333, #337). 20%를 1초에 걸쳐 끌면 넘어가는지를 TutorialInstrumentedTest가 봅니다(30%에서는 실패하는 것을 확인했습니다).
  • 쪽이 바뀌면 제목으로 초점: 넘긴 뒤(멈춘 쪽) 그 쪽의 제목에 초점을 옮깁니다(focusRequester + focusable, 제목은 heading). 초점이 ‘다음’에 남으면 새 쪽을 들으려고 거꾸로 훑어야 하고, 마지막 쪽에서 ‘다음’이 ‘확인’으로 바뀐 것을 모른 채 두 번 눌러 닫게 됩니다. 쪽 표시 점은 "5쪽 중 2쪽" 한 마디로 읽고 따로 알리지 않습니다(초점이 제목을 읽을 때 겹치지 않게).
    • 초점은 페이저가 정말 멈춘 뒤에 옮깁니다(rememberRestingPage, #339). 초점을 받은 제목은 자기를 화면에 보이려고 페이저를 끌어옵니다. 예전에는 페이저의 settledPage를 따랐는데, 이 값은 손을 떼고 미끄러지기 시작하는 순간에도 그때 가장 가까운 쪽으로 바뀝니다. 그래서 앞 넘김이 끝나기 전에 다시 넘기면 지나가던 쪽 제목에 초점이 가고, 넘어가던 화면이 그 쪽으로 끌려 되돌아갔습니다. 사용자가 "넘기면 팅 되돌아간다"고 한 것이 이것이었고, 넘기는 기준(#333, #335, #337)과는 상관이 없었습니다. S25+ 에서 넘김을 기록해 보니 빠르게 튕긴 68번 가운데 24번이 튕긴 반대쪽으로 되돌아갔고, 되돌아간 곳은 손을 뗀 순간 가장 가까웠던 쪽이었습니다.
    • TutorialInstrumentedTest가 4분의 1을 0.08초에 튕기고 0.15초 뒤에 다시 튕겨, 두 쪽을 넘어가는지 봅니다(고치기 전에는 실패했습니다).
  • 이전·건너뛰기: 위 왼쪽에 ‘이전 쪽’ 화살표, 오른쪽에 ‘건너뛰기’를 둡니다. 밀어서 넘기는 것 말고도 한 번 눌러 돌아갈 수 있어야 합니다. 첫 쪽의 ‘이전’과 마지막 쪽의 ‘건너뛰기’는 자리는 두고 투명하게 숨기며, 눌리지도 초점을 받지도 않게 enabled = false로 둡니다. 시스템 뒤로 가기(예측 뒤로 가기 포함, BackHandler)도 앞 쪽으로 가고 첫 쪽에서는 닫습니다.
  • 닫을 때 두 번 누름: 튜토리얼이 사라지는 0.3초(Crossfade) 동안 탭 화면은 투명한 채 맨 위에서 누름을 받습니다. 그래서 튜토리얼이 화면에서 빠질 때까지(DisposableEffect의 onDispose) 탭 화면 위에 누름을 모두 삼키는 투명한 덮개를 둡니다(MainActivity, #264).
    • 처음에는 ‘튜토리얼 보기’만 막았습니다(#232). 막는 시간도 처음에는 실제 시계 0.5초였다가, 느린 기기에서 뚫려 전환 상태로 바꿨습니다(#249).
    • 그런데 가로 화면에서는 ‘확인’·‘건너뛰기’ 자리에 외부 사운드 모드 스위치가 있어, 둘째 번이 동의 없이 켜지는 마이크 모드로 바꿨습니다. 그래서 덮개를 둡니다.
    • 화면 읽어주기의 동작은 누름 이벤트가 아니라 덮개로 막히지 않으므로, ‘튜토리얼 보기’의 검사는 남겨 둡니다.
  • 그림 멈추기: 그림은 저절로 되풀이해 움직이므로 쪽 표시 옆의 버튼으로 멈추고 다시 움직일 수 있습니다(WCAG 2.2.2). 시스템에서 애니메이션을 꺼 두었으면 멈춘 채로 시작하고, 멈추면 그 쪽이 말하려는 한 장면을 보여 줍니다. 위협음의 빨간 번쩍임은 1초에 두 번을 넘지 않게 대본을 짰습니다.
  • 그림은 읽지 않음: 폰 그림은 옆 글이 말하는 것을 보여 주기만 하므로 통째로 건너뜁니다. 마지막 쪽의 홈 그림 속 실행 버튼도 누를 수 없는 그림입니다. 종류 쪽의 범례는 읽습니다. 색만으로 전하지 않게 종류 이름과 예(음악·빗소리, 사람 목소리, 총소리·폭발음·사이렌)를 함께 적고, 이름 아래에 예를 두어 큰 글꼴과 긴 번역에서도 예가 좁아지지 않게 하고, 지금 그림에 나오는 종류는 둥근 바탕으로 밝힙니다(나머지를 흐리게 하면 글자 대비가 모자라고, 굵기를 바꾸면 줄이 바뀌어 쪽이 흔들립니다). 이 표시는 화면 읽어주기에 알리지 않습니다. 알리면 2초마다 다시 읽습니다.
  • 큰 글꼴·가로 화면: 그림은 쪽 높이의 절반쯤이고, 글꼴을 크게 쓰거나(1.3배 이상) 글이 긴 마지막 쪽이면 3분의 1로 줄입니다. 글이 넘치면 쪽 안에서 밀어 볼 수 있고, 아래에 더 있으면 끝을 흐리게 덮어 알립니다. 쪽이 높이보다 넓으면(가로 화면) 그림을 글 옆에 둡니다. 따로, 높이가 480dp보다 낮고 폭이 600dp 이상이면 이전·건너뛰기·쪽 표시·멈춤·다음을 아래 한 줄에 모읍니다. 폭이 좁은데 모으면 긴 번역에서 ‘다음’이 짓눌리므로 그때는 위아래 두 줄로 둡니다.

11. 앱 안 제보 (help/ReportLink)

도움말 탭의 제보하기 카드는 버그와 소리 오분류를 겪은 사람이 그 자리에서 알리는 창구입니다.

  • 붙는 정보는 네 줄뿐입니다. 앱 버전, Android 버전(API), 기기 제조사와 모델, 화면 언어. 누르기 전에 카드에 그대로 보여 줍니다. 값은 ReportLink.environment의 인자로만 들어가 다른 정보가 섞일 수 없고, 오디오는 보내지 않습니다. 이름표(App version: 등)는 번역하지 않아 어떤 언어로 온 제보든 같은 모양으로 읽힙니다.
  • GitHub에 제보하기: 제목과 본문(사용자가 답할 질문 세 개 + 기기 정보)을 채운 새 이슈 주소를 브라우저로 엽니다. GitHub 계정이 필요하다는 것을 카드 설명에 적어 둡니다.
  • 메일로 제보하기: ACTION_SENDTO + mailto:로 메일 앱만 고릅니다. Gmail은 인텐트로 따로 넘긴 제목·본문을 무시하고 주소만 읽어서(기기에서 확인) 제목·본문을 주소에 넣고, 인텐트 쪽에도 담아 둡니다. mailto 쿼리에서는 +가 공백으로 풀리지 않으므로 공백을 %20으로 바꿉니다(ReportLink.mailtoUri). 받는 주소(help_report_email)는 번역하지 않습니다.
  • 기기 정보 복사: 브라우저나 메일 앱이 없을 때의 대안입니다. Android 13부터는 시스템이 복사 안내를 직접 띄우므로 그 아래 버전에서만 토스트를 띄웁니다.
  • 앱은 주소만 넘기고 여는 것은 브라우저·메일 앱이라 인터넷 권한이 없습니다. 열 앱이 없으면(ActivityNotFoundException) 죽지 않고 토스트로 안내합니다.
  • 주소 만들기(인코딩, 본문 합치기, 빈 값 처리)는 ReportLinkTest가 기기 없이 검사합니다.