Skip to content

feat: 안드로이드 판의 개선을 PC 판으로 옮긴다 (v2.0) - #5

Merged
amophi merged 73 commits into
mainfrom
feature/mobile-port
Oct 15, 2026
Merged

amophi merged 73 commits into
mainfrom
feature/mobile-port

Conversation

@amophi

@amophi amophi commented Oct 15, 2026

Copy link
Copy Markdown
Owner

안드로이드 판에서 다듬어진 것들을 PC 판으로 옮긴 v2.0 작업입니다. 진동과 마이크 모드처럼 PC에 맞지 않는 것은 빼고, F2/F3/F4 단축키·편집 모드·5.1/7.1 방향처럼 PC에서 쓰기 편한 점은 그대로 두었습니다. 앱 이름은 프로젝트 전체에서 SoundVisualizer 로 붙여 썼습니다.

바뀐 것

기반

  • 프로젝트를 SoundVisualizer(WPF 앱), SoundVisualizer.Core(화면에 의존하지 않는 계산), SoundVisualizer.Tests(xUnit)로 나눴습니다. GitHub Actions 에서 빌드와 시험을 돌립니다.
  • 설정 저장을 모아서 한 번에 합니다(슬라이더를 움직일 때마다 쓰지 않음). 전체 초기화가 단축키도 되돌립니다.
  • 결함 수정: 오버레이가 6개 언어에서 한국어로 뜨던 것, VB-CABLE 이 받은 길이를 무시하던 것, 7.1 채널 순서, 캡처 스레드와 화면 스레드가 크기 값을 함께 쓰던 것.
  • 화면 글꼴을 Pretendard 로 바꿨습니다(OFL, 라이선스 동봉).

AI

  • 안드로이드와 같은 방식으로 바꿨습니다.
    • 앨리어싱 방지 리샘플링, 모델과 같은 Log-mel
    • 모든 채널을 섞는 다운믹스
    • 37/33/451 분류
    • 위협 단서(상위 5개 안 5% 이상)
    • 흔들리지 않는 색
    • 250ms 주기, 무음 때 쉬기
  • 단계마다 안드로이드의 기준 출력과 비교하는 시험이 있습니다. 총소리 부스터와 쓰지 않던 3분류 헤드 모델은 뺐습니다.

화면

  • 안드로이드와 같은 색·카드·스위치·슬라이더 디자인, 18개 언어(아랍어는 오른쪽에서 왼쪽).
  • 분류 탭: 묶음마다(고급 모드는 소리마다) 종류를 선택하고, 찾기·거르기·모두 되돌리기를 할 수 있습니다. AI 가 바로 따릅니다.
  • 도움말 탭: 시작하기, 모드, 소리 종류, 단축키, 자주 묻는 질문, 제보, 앱 정보·오픈소스 라이선스.
  • 홈 안내(출력 장치 없음, AI 사용 불가, 멈춘 까닭), 트레이 아이콘, 처음 실행 튜토리얼.

오버레이

  • 최대 프레임과 상관없이 같은 빠르기로 움직이고, 보이는 것이 없으면 쉬었다가 소리가 나면 깨어납니다(쉬는 동안 CPU 약 1/3).
  • 늦게 온 위협음 판정도 그 소리의 크기로 그립니다. 2.0 은 Mid/Side 로 펼칩니다.
  • 결함 수정:
    • 16:9 화면에서 2.0 모드의 파도·외곽선이 아예 그려지지 않던 것
    • 편집 패널에서 모드 칸을 바꾸면 슬라이더가 이전 모드 값으로 남던 것
  • 개발자 모드: 왼쪽 위 AI HUD 와 결과 CSV 기록. 가운데 AI 라벨은 HUD 와 겹쳐서 없앴습니다.

접근성

  • Tab 키로 '더 알아보기' 탭 내용에만 닿던 것을 고쳐, 모든 탭 내용과 창에 키보드로 닿습니다. Ctrl+Tab·Esc 는 어디서나 통합니다.
  • 언어 콤보는 키보드로 둘러보는 동안 언어가 바뀌지 않고, 선택할 때만 바뀝니다.
  • 이름 없던 버튼과 요소에 화면 읽어주기용 이름을 붙였습니다(최소화·닫기, 단축키, 색 버튼, 색 선택 창의 영역·막대·자주 쓰는 색).
  • 상태·안내·바꾼 소리 수는 바뀌면 바로 읽어 줍니다. 제목은 단계로 알립니다.
  • 편집 화면에서 마우스로만 끌던 값도 패널 슬라이더로 바꿀 수 있습니다(원형 반지름 추가).
  • 자동화 트리로 잰 문제는 고치기 전 127건에서 고친 뒤 3건이 되었습니다. 남은 3건은 같은 일을 하는 버튼의 이름 중복입니다.

확인한 것

  • dotnet build -c Release 경고 0, dotnet test 1000개 모두 통과, CI 통과.
  • 각 단계마다 Release 실행 파일로 런처·분류 탭·도움말·튜토리얼·라이선스 창·트레이·안내·HUD 를 띄워 화면을 확인했습니다(한국어·영어·아랍어 등). 접근성은 UI 자동화로 키보드 이동과 읽기 이벤트를 확인했습니다.
  • 단일 파일 배포(FolderProfile)를 만들어 풀고 처음 실행하면 튜토리얼이 뜨는 것을 확인했습니다.
  • 마지막 두 커밋(이름 바꾸기, 편집 패널 수정)은 사용 중인 PC의 키보드·마우스를 빼앗지 않도록 앱을 띄우지 않고 빌드와 시험으로만 확인했습니다.

확인하지 못한 것

  • 작업한 PC에 출력 장치가 없어 실제 소리로 오버레이와 AI 가 반응하는 것은 보지 못했습니다. 합성 신호와 시험으로만 확인했습니다. 스피커나 헤드폰이 있는 PC에서 한 번 확인이 필요합니다(튜토리얼 소리 포함).
  • Narrator·NVDA 로 실제로 읽히는 소리는 듣지 못했습니다. 게임이 앞에 있을 때 F4 로 연 편집 패널이 키보드 초점을 받는지도 확인하지 못했습니다.
  • 16개 언어 번역은 원어민 검수를 받지 않았습니다.

참고

  • 중간 커밋 3e3c6cb 는 그 커밋만 빌드되지 않습니다. 지운 폴더가 다른 커밋에 섞였고, 바로 다음 2ca83ff 에서 고쳤습니다. 강제 푸시로 이력을 고치지 않았습니다.
  • 커밋마다 기능 단위로 설명이 달려 있어, 합치지 않고 머지 커밋으로 넣습니다.

amophi added 30 commits October 14, 2026 11:15
- SoundVisualizer.Core(net10.0): WPF 와 무관한 로직을 두는 곳. 이후 AI·분류 로직도 여기로 온다.
- SoundVisualizer.Tests(xUnit): Core 를 테스트한다.
- AppSettings 를 Core 로 옮겼다(네임스페이스는 그대로라 앱 코드는 바뀌지 않는다).
  - 기본값을 상수와 VisualModeSettings 초기값 한 곳에 둔다.
  - 전체 초기화용 ResetToDefaults 를 둔다. 실제로 쓰는 단축키 목록까지 되돌린다.
  - 저장은 400ms 모았다가 한 번 쓴다. 슬라이더를 끄는 동안 값이 바뀔 때마다 UI 스레드가 파일을 쓰던 것을 없앤다.
    앱을 닫을 때 남은 저장을 쓴다(App.OnExit → Flush).
  - 설정을 읽거나 쓰지 못하면 조용히 넘기지 않고 Trace 로 남긴다(AppLog).
- 커밋돼 있던 빌드 산출물(build_output.txt, SoundVisualizer.csproj.lscache)을 지우고 .lscache 를 무시한다.
- 실행 버튼으로 연 오버레이가 일본어·중국어·스페인어·프랑스어·독일어·러시아어에서 한국어로 뜨던 것을 고친다.
  런처는 언어 이름("日本語")을 저장하고 오버레이는 영어 키("Japanese")를 받는데, 실행 버튼만 바꾸지 않고 넘겼다.
  세 군데에 흩어져 있던 변환을 LegacyLanguageKeys 하나로 모았다.
- VB-CABLE 로 받은 소리를 스피커로 다시 내보낼 때 녹음된 바이트만 보낸다. NAudio 는 캡처 버퍼를 재사용하므로
  배열 전체를 보내면 지난 버퍼의 남은 소리가 섞였다. 7.1 → 2ch 다운믹스의 뒤·옆 채널 순서도 WASAPI 표준
  (FL FR FC LFE BL BR SL SR)으로 바로잡았다. 계산은 Core 의 ChannelDownmix 로 옮겨 테스트한다.
- 캡처 스레드와 렌더 스레드가 같은 레벨 값을 따로 고치던 경합을 없앤다. 캡처 스레드는 PeakAccumulator 에 넣고
  렌더 루프가 프레임마다 가져간다. 프레임 사이에 버퍼가 여럿이면 마지막 값이 아니라 가장 큰 값을 남겨,
  짧은 총성이 다음 버퍼의 조용한 값에 덮이지 않는다.
- 장치가 바뀌어 캡처를 다시 여는 스레드와 멈추는 UI 스레드가 엇갈리지 않게 장치 열기·닫기를 잠금으로 묶는다.
  채널 수 변경 알림은 UI 스레드를 기다리지 않고 넘긴다(서로 기다리며 멈출 수 있었다). 실패는 Trace 로 남긴다.
- 전체 초기화가 실제로 쓰는 단축키 목록을 되돌리지 않던 것을 고친다(AppSettings.ResetToDefaults).
- 읽히지 않던 채널 수 세기(CountActiveChannels)를 지운다. 0.5초마다 버퍼 전체를 훑기만 했다.
- Pretendard 1.3.9(SIL OFL 1.1)의 굵기 다섯 가지(Regular, SemiBold, Bold, ExtraBold, Black)를 고치지 않고
  실행 파일에 넣는다. 앱이 쓰는 굵기가 이 다섯 가지다. OFL 은 라이선스를 글꼴과 함께 배포하게 하므로
  Fonts/Pretendard-LICENSE.txt 를 출력 폴더에 두고, THIRD_PARTY.md 에도 적는다.
- 런처, 오버레이, 색상 선택 창이 모두 AppFontFamily 리소스를 쓴다. 이전에는 맑은 고딕이었다.
- Pretendard 에는 한글, 라틴(베트남어·터키어·폴란드어 포함), 키릴 문자, 일본어 가나가 있고 한자, 태국 문자,
  데바나가리, 아랍 문자는 없다. 없는 글자는 언어에 맞는 Windows 글꼴로 채운다(UiFontChain). 한자는 나라마다
  모양이 달라 중국어는 Microsoft YaHei UI, 일본어는 Yu Gothic UI 를 먼저 둔다. 언어를 바꾸면 열린 창도 바로 바뀐다.
Python 기준 구현에서 만든 기준 데이터(리샘플러, 로그멜, YAMNet 출력, 분류 규칙)를 Tests/Ai/Reference 에
두고, 앱이 쓰는 YAMNet 모델을 테스트 출력으로 복사한다. Core 는 ONNX Runtime 을 직접 쓴다.
- 모바일 판 QualcommSourceAudioPreprocessor.kt 를 Core/Ai 로 옮긴다. 16kHz 모노 15,600샘플을 YAMNet 입력
  [1, 1, 96, 64](time * 64 + mel)로 바꾼다. centered STFT(양쪽 256샘플 반사 패딩, 98프레임 중 앞 96개),
  400점 periodic Hann, 512점 FFT, 크기(magnitude), torchaudio HTK 멜 필터(125~7,500Hz, 정규화 없음), ln(x + 0.001).
- 계산 순서와 자료형(double, 0.001f 를 넓힌 LOG_EPS)까지 Kotlin 과 같게 두고, FFT 도 AudioPreprocessor.kt 의
  fftInPlace 를 그대로 옮긴다(Radix2Fft). 회전 인자는 같은 점화식으로 생성할 때 한 번만 표로 만든다.
- 출력 버퍼를 받는 쪽은 호출마다 메모리를 새로 잡지 않는다. 인스턴스 하나는 추론 스레드 하나가 쓴다.
- 기준 데이터의 네 경우(golden, silence, gunshot, alarm)를 Kotlin 과 같은 한도(최대 1e-5, 평균 1e-7)로 비교한다.
  경우마다 오차가 Kotlin 과 같다(golden 최대 4.8e-7). 입력 길이 맞추기와 버퍼 재사용도 시험한다.
Android 의 AiAudioBuffer, CaptureAudioMath, AiCaptureSampleRatePolicy, AiSilenceGate,
AiCaptureInferenceGate 를 Core/Ai 로 옮기고, 그 테스트(CaptureAudioPath, AiSilenceGate,
AiCaptureInferenceGate, AiCaptureSampleRatePolicy)도 옮긴다. 16·44.1·48kHz 는 Android 와 같은
float·double 계산이라 Python 기준 데이터와 최대 1.2e-7 로 맞는다(허용 1e-5). 후처리와 log-mel 이
필요한 확인은 그쪽을 옮길 때 붙인다.

PC 에서 더한 것:
- 5.1·7.1 은 ChannelDownmix 와 같은 ITU-R BS.775 계수로 스테레오를 만든 뒤 (L+R)/2 로 AI 에
  넣는다(LFE 는 빼고, ±1 로 자르지 않는다). 예전 PC 코드는 FL·FR·FC 만 넣어 뒤·옆 스피커의 소리를
  AI 가 듣지 못했다. 그 밖의 채널 수는 Android 처럼 평균이다.
- 88.2·96·176.4·192kHz 는 FIR 을 레이트에 비례해 192·384탭으로 늘려 전이 대역(Hz)을 44.1·48kHz 와
  같게 받는다. 96탭 그대로면 96kHz 에서 7kHz 가 -1.9dB, 9kHz 가 -20dB 에 그친다. 링도 2초를 담게
  262,144·524,288 로 늘린다. 그 밖의 레이트는 Android 처럼 AI 를 끄고 화면만 돈다.
- 무음 게이트는 채널 수와 상관없이 섞기 전 인터리브 PCM 의 최고값(0.01 초과)으로 연다.
- 임시 버퍼(65,536프레임)보다 긴 캡처 버퍼는 나눠 넣는다. Android 는 예외를 던져 캡처가 멈춘다.
모바일 ai/ 의 "YAMNet 확률 521개 → 확정 3분류" 경로를 Core/Ai 로 옮긴다(기준: SoundVisualizer-coord
e5f46aa, ai/ 마지막 변경 051cc77). 라벨 목록, 문턱, 동점 처리, 가드는 모바일과 같다.

- SoundType(Ambient·Speech·Danger)과 저장 문자열 "ambient"/"speech"/"danger". 모바일 설정(sound_types JSON)과
  글자까지 같아서, 사용자 지정을 "영어 클래스 이름 → 문자열" 맵 그대로 주고받을 수 있다.
- YamnetThreeClassMapper: 위협음 37, 대화음 33, 환경음 451. 옛 PC 판의 총소리 대리 라벨, 발소리, Gunshot Booster 는
  모바일처럼 없다.
- YamnetCoarseClassifier, YamnetSafetyCueDecision, YamnetMappingPolicy, AiPostProcessor, AiClassificationResult 는
  모바일과 같은 이름과 동작이다.
- YamnetClassMap 은 클래스 목록을 읽고 이름마다 기본 종류와 키워드 판정을 한 번 계산해 둔다. 추론 중에는 이름을
  소문자로 바꾸지 않는다(모바일은 효과음에 가려진 위협음을 찾을 때 521개를 매번 바꾼다).
- AiClassificationStage 는 RealtimeAiPipeline 의 모델 뒤쪽만 옮겼다. 매핑 갱신, 투표, 안전 단서, 후처리, 마지막 결과
  게시, 설정이 바뀌면 옛 결과를 바로 숨기기.
- 모바일 단위 테스트 다섯 파일과 AiLabelContractTest, 계측 테스트의 매핑 변경 사례를 옮기고 CP4 골든과
  CP6 골든(Booster 없는 8개 사례)으로 확인한다.
- YamnetInference: Android 와 같은 세션 설정(연산 스레드 1개, 순차, 그래프 최적화 전부)과 입출력 계약
  확인, Kotlin 과 같은 순서의 softmax. 입력·출력을 고정 힙 버퍼에 IoBinding 으로 묶어 추론마다 할당하지 않는다.
- ModelFileIntegrity: 실행 파일 옆의 yamnet.onnx·yamnet.data 를 불러오기 전에 Android 와 같은 크기·SHA-256
  으로 확인하고, 다르면 무엇이 다른지 담아 예외를 던진다(앱은 AI 없이 화면만 돈다).
- RealtimeAiPipeline: 캡처 스레드 입구, "SV-AiTick" 스레드의 250ms 시작 간격·최소 100ms 휴식(#379),
  앞 틱이 돌면 건너뛰기, 잠금 없는 결과 읽기, 무음 틱에도 매핑 반영. 틱 실패는 알리고 무음 게이트에 넘긴다.
  Stop 은 돌던 틱을 기다리고 결과를 숨기며, Start 는 앞 틱이 끝난 뒤 비운다. Dispose 는 최대 1초 기다리고,
  넘기면 그 추론이 끝나면서 세션을 닫는다(Android 는 버린다).
- 테스트: Python CP3 골든(로짓 1.5e-5, 확률 9.2e-7), 무결성, 주기, 틱 단위 실제 모델 경로(무음 e2e 골든 포함),
  닫기 경합을 옮겼다. yamnet_e2e_meta.json 의 알람·총소리 기대값은 없앤 Booster 와 옛 전처리 시절의 값이라
  확인하지 않는다.
총소리 부스터와 없는 설정(AI 감지 민감도) 설명을 빼고, 위협 단서·흔들리지 않는 색·모든 채널·250ms 파이프라인을 적는다.
바로 앞 커밋(3e3c6cb)에 옛 분류기·총소리 부스터·3분류 헤드 모델·학습 스크립트(tools/transfer_learning)와
결과(out_gunshot)를 지운 것이 README 변경과 함께 잘못 들어갔다. 그 커밋만으로는 빌드되지 않고, 이 커밋으로 메운다.

- 캡처 형식(샘플레이트·채널 수)이 정해지면 백그라운드에서 RealtimeAiPipeline 을 만든다. 장치가 바뀌어 형식이
  달라지면 옛 파이프라인을 버리고 새로 만든다. 지원하지 않는 형식이거나 모델 파일의 SHA-256 이 맞지 않으면
  AI 없이 그래픽만 돌고, 이유는 AppLog 에 남는다.
- 캡처 스레드는 형식이 맞는 파이프라인에만 소리를 넣는다(float32 만). 렌더 루프는 프레임마다 결과를 한 번 읽어
  표시 여부와 색을 SoundType 으로 고른다. 라벨 문자열에 "danger"·"speech" 가 들었는지 찾던 방식을 없앤다.
- 개발자 모드 라벨은 "소리 이름 | 종류 | 확신도" 를 적고, 결과가 바뀔 때만 문자열을 다시 만든다.
- 창을 닫을 때 캡처를 먼저 멈추고 AI 를 닫는다(도는 추론은 최대 1초 기다린다).
- 프로젝트에서 지운 두 모델 항목을 빼고, THIRD_PARTY.md 의 YAMNet·AudioSet 고지를 고친다.
- 개발용: 환경 변수 SOUNDVISUALIZER_TRACE 에 파일 경로를 주면 AppLog 기록을 그 파일에 남긴다. 시작, 캡처 형식,
  AI 시작을 기록한다.
모바일 AiDebugText·AiDebugCsv·AiDebugLogWriter·AiDebugRecorder 를 Core/Diagnostics 로 옮긴다
(기준: SoundVisualizer-coord e5f46aa). 오버레이에 붙이는 일은 다음에 한다.

- AiDebugText: HUD 의 줄(종류·이름·확신도, thr/age, cue, lvl/shown/소요시간, top-5)을 모바일과 같은 글자로
  만든다. top-5 이름은 26자에서 말줄임표로 줄이고, 100초가 넘은 나이는 123s·17m 처럼 짧게 써서 한 줄 58자를
  지킨다. 점의 색은 SoundType 으로 넘긴다.
- AiDebugCsv: 머리글, 칸 순서, 모든 칸 따옴표, 소문자 참거짓까지 모바일과 같다. capture_source 는 늘 Loopback 이다.
- 숫자는 Java String.format 처럼 가장 짧은 십진 표현을 5 에서 올려 적는다(JavaNumberFormat). .NET 의 "F" 서식은
  짝수 쪽으로 반올림해 0.25s 를 0.2s, 12.5ms 를 12ms 로 적어 두 판의 기록이 달라진다. 로캘은 늘 고정이다.
- AiDebugLogWriter: 머리글은 첫 줄과 함께 한 번, 같은 결과(TimestampMs)는 한 번, 20MB 에서 멈춘다. 줄 끝은 \n.
- AiDebugRecorder: 켤 때마다 큐(200줄, 넘치면 오래된 줄을 버림)와 쓰는 스레드를 따로 둔다. 디스크는 그 스레드만
  만지고, 파일은 첫 줄이 올 때 실행 파일 폴더의 ai-log/ai-yyyyMMdd-HHmmss.csv 에 만든다. 거기에 쓸 수 없으면
  %LOCALAPPDATA%/SoundVisualizer/ai-log/ 에 둔다. 끄면 받아 둔 줄을 모두 쓰고 닫으며, Dispose 는 그것을 1초까지
  기다린다. 모바일과 달리 같은 초에 다시 켜면 덮어쓰지 않고 -2 를 붙이고, 렌더 루프가 프레임마다 넘기는 같은
  결과를 큐에 거듭 넣지 않으며, 쓰기에 실패해도 회차를 닫는다.
- 테스트: 모바일 단위 테스트 세 파일과 계측 테스트(AiDebugRecorderInstrumentedTest)를 옮기고, 폴더 고르기,
  Loopback, 상한, 끌 때 비우기, 큐 넘침, 프레임마다 같은 결과, 같은 초, 닫기, 58자 한도, Java 반올림 경계를 더했다.
모바일 분류 탭의 데이터를 Core/Sounds/Data 에 리소스로 옮긴다(기준: SoundVisualizer-coord e5f46aa).
화면과 계산은 다음 커밋에서 옮긴다.

- SoundNames/{언어}.json: res/values*/sound_names.xml 의 소리 이름 521개를 YAMNet 번호 순서의 배열로 적었다.
  언어 18개는 PC 화면 언어 코드로 이름을 붙였다(values → en, values-in → id, values-zh-rCN·zh-rTW →
  zh-Hans·zh-Hant, values-pt-rBR → pt-BR). 안드로이드 이스케이프는 풀었다(\' 37곳: en·fr·it, & 2곳: ko·vi 의 R&B).
- GroupTitles/{언어}.json: strings.xml 의 sound_group_* 15개(새로 지은 묶음 이름)를 묶음 id → 이름으로 적었다.
- sound_groups.tsv: 글자 그대로 옮겼다(CC BY-SA 4.0 머리글 포함).
- NOTICE.txt: 모바일 NOTICE 7절(AudioSet 온톨로지)의 고지를 PC 경로에 맞춰 옮겼다. JSON 은 주석을 달 수 없어
  이 파일에 둔다. 코드와 새로 지은 묶음 이름은 이 저장소의 라이선스(AGPL v3)를 따른다고 적었다(모바일은 Apache-2.0).
- 리소스 이름은 Core csproj 의 ItemGroup 하나에서 LogicalName 으로 정한다.

옮기면서 빠진 이름, 빈 이름, 한 언어 안에서 겹치는 이름, 앞뒤 공백은 없었다. 영어 이름은 yamnet_class_map.csv 와
번호·글자까지 같다. 변환 스크립트는 저장소 밖에 두었다.
모바일 SoundCatalog.kt·SoundGroups.kt 와 묶음 화면의 판단(#349, #368, #374)을 Core/Sounds 로 옮긴다
(기준: SoundVisualizer-coord e5f46aa). 화면을 모르므로 WPF 탭은 나중에 이것을 불러 쓴다.

- SoundCatalog: 클래스 목록과 묶음 표로 소리 목록을 만들고(기본 종류는 AI 매핑), 찾기 값(보이는 이름 + 영어 이름,
  대소문자·띄어쓰기·문장 부호·결합 부호 무시, 한글은 낱자로 풀어 치는 중의 모양에서도 끊기지 않음)과 거르기,
  "바꾼 소리" 수(묶음이 아니라 소리를 셈)를 낸다.
- SoundGroups: 묶음 표 읽기, 구역(위협음 → 대화음 → 환경음)과 카드(= 묶음, 기본 종류) 나누기, 묶음 상태(모두 같음·
  일부 다름), 미리보기, 찾기·거르기(거르기에서 방금 바꾼 소리 남기기 KeptSounds 포함), 고급 모드 목록, 덮어쓰는 수와
  하나씩 바꾼 소리가 실제로 바뀔 때만 묻는 판단. 지금 매핑으로 37개 묶음이 위협음 11·대화음 6·환경음 20 으로 나뉜다.
- SoundIndex·SoundIndexCache: 화면 언어 하나의 이름·찾기 값·구역·묶음 이름 값을 스레드 풀에서 한 번 만들어 둔다.
  기다리기를 그만둬도 다 만든 색인은 남고, 다시 연 탭은 그것을 기다려 쓴다. 없는 언어와 빠진 이름은 영어다.
- SoundTypeOverrides: 묶음(또는 소리 하나)을 한 번에 바꾼 새 맵(기본 종류와 같은 값은 지움, 바뀌는 것이 없으면 같은
  맵), 저장값을 읽을 때의 거르기. 맵은 YamnetMappingPolicy.From 이 받는 것과 같다.
- 테스트: SoundCatalogTest, SoundGroupTableTest, SoundGroupsTest, SoundGroupSearchTest, SoundIndexTest,
  SoundNamesResourceTest 와 SoundGroupStringsTest 의 데이터 부분을 옮기고, 언어 파일·이스케이프·영어 채우기·
  맵 계산을 더 본다(85개).

모바일과 다른 점:
- 종류는 SoundType 으로 다룬다. 바꾼 종류의 맵은 저장하는 그대로(영어 이름 → "ambient"/"speech"/"danger")이고,
  세 라벨이 아닌 값은 AI 처럼 바꾸지 않은 것으로 본다.
- 색인 캐시는 클래스 목록마다 하나인 객체이고 키는 PC 언어 코드다. 짝 없는 서로게이트는 대체 문자로 바꿔 다듬는다.
- 목록을 든 레코드(카드, 구역, 미리보기, 찾은 결과)는 내용으로 같음을 본다.
- UiLanguage/UiLanguages: 안드로이드 판과 같은 18개 언어(코드, 그 언어로 쓴 이름, 아랍어만 오른쪽에서 왼쪽).
  "system" 설정은 Windows 화면 언어로 푼다(zh-TW/HK/MO 는 번체, 나머지 중국어는 간체, 포르투갈어는 모두 pt-BR,
  in/id 는 id, 그 밖에는 지원하면 그 언어, 아니면 영어). 예전 판의 설정값("KOR", "日本語" 등)을 코드로 옮기는 표도 둔다.
- StringTable/StringTables: Localization/Strings/{코드}.json 을 Core 에 넣고 처음 쓸 때 한 번 읽는다. 평평한 JSON 객체만
  받고, 값이 문자열이 아니거나 같은 키가 두 번 나오면 읽지 않는다. 읽은 표는 바뀌지 않는다.
- Localizer: 지금 언어 → 영어 → 키 순으로 찾고, {0} 자리를 InvariantCulture 로 채운다. 언어를 바꿀 때는
  (코드, 그 언어 표, 영어 표) 묶음을 참조 하나로 바꿔 끼워 다른 스레드가 섞인 결과를 보지 않게 한다.
- en.json, ko.json: 런처·오버레이 편집 패널·HUD·색 선택 창·대화 상자의 문자열 145개.
- 테스트: 언어 목록, 시스템 언어 풀이, 예전 값 옮기기, 표 파일 검사(키·자리표시자가 영어와 같은지, 빈 값,
  키 이름 꼴), Localizer 의 대체 순서와 언어 바꾸기.
- Loc(INotifyPropertyChanged, 인덱서) 와 {l:Loc 키} 마크업 확장을 둔다. 언어를 바꾸면 Item[] 을 알려 열린 창의 XAML
  글자가 한꺼번에 바뀌고, 코드가 넣는 글자(상태, 단축키 이름, 링크가 든 안내문, HUD, 가이드라인 라벨)는 각 창이
  LanguageChanged 를 받아 다시 넣는다.
- 런처·오버레이 편집 패널·HUD·색 선택 창·메시지 상자의 문자열을 모두 키로 바꾸고, 언어별 if/switch 를 지운다
  (런처 SetLanguage·SetExtraLanguageTexts·SetMainComboItems 1,388줄, 오버레이 ApplyLanguage·SetComboItems 320줄).
  한국어로만 있던 색 선택 창, 모드별 초기화 확인, "이미 실행 중" 알림, 키 없음 표시, 캡처 오류 문구도 키로 옮긴다.
- 언어 콤보(런처 제목줄, 편집 패널)는 시스템 기본값 + 18개 언어를 그 언어의 이름과 글꼴로 보이고 Tag(설정값)로 고른다.
  한쪽에서 바꾸면 저장하고 두 창과 글꼴(AppFonts)에 바로 적용한다. 긴 언어 이름이 들어가게 콤보 폭을 120 → 200 으로 늘린다.
- 아랍어면 런처 내용, 색 선택 창, 편집 패널 내용만 FlowDirection.RightToLeft 로 뒤집는다. 그래픽과 가이드라인은
  뒤집지 않는다. 16진 색 코드 입력칸은 왼쪽에서 오른쪽으로 둔다. 메시지 상자는 RtlReading 으로 띄운다.
- 설정은 언어 코드("system" 또는 "ko", "zh-Hans" 등)로 같은 Language 항목에 저장한다. 예전 값("KOR", "日本語" 등)은
  읽을 때 옮기고, 새로 설치하면 system 이다. LegacyLanguageKeys 를 지운다.
- UiFontChain 을 언어 코드로 바꾸고 번체 중국어(Microsoft JhengHei UI), 태국어(Leelawadee UI), 힌디어(Nirmala UI)
  대체 글꼴을 더한다. 모든 언어가 Pretendard 를 먼저 쓴다.
- FPS·% 같은 숫자 표시는 InvariantCulture 로 적는다. 쓰지 않던 다운믹스 표시 문자열을 지운다.
- 앱(.cs) 의 주석 밖 문자열 리터럴에 한글 등 비 ASCII 글자가 없는지 본다. 허용 목록은 AppLog.Info/Warn 의 인자
  (개발자용 기록) 하나뿐이고, 늘릴 때는 이유를 주석으로 적게 했다.
- XAML 은 주석 밖에 비 ASCII 가 없는지, Text/Content/Header/Title/ToolTip 에 영어 글자를 그대로 적지 않았는지 본다.
  앱 이름과 코드가 곧 덮어쓰는 처음 값만 허용 목록에 둔다.
- XAML 의 {l:Loc 키} 와 코드의 키 꼴 문자열이 모두 en.json 에 있는지, en.json 의 키를 모두 앱이 쓰는지 본다.
- 저장소 뿌리는 테스트 폴더에서 위로 올라가며 SoundVisualizer.slnx 로 찾는다. 문자열을 가려내는 작은 C# 어휘 분석기
  (주석, 축자·보간·원시 문자열, 보간 구멍 안의 문자열)와 그 테스트를 함께 둔다.
en.json 의 145개 키를 그대로 옮기고, 같은 개념은 안드로이드 판 values-ja·zh-rCN·zh-rTW·vi·th·in 의
용어(소리 종류, 표현 모드 이름, 크기·반지름·광원·민감도·속도, 시작/정지, 상태, 색 선택, 시스템 기본값)를 따른다.
"투명도" 슬라이더는 실제로 불투명도(값이 클수록 진하게)라서 안드로이드와 같은 말(濃さ, 不透明度, Độ đậm,
ความเข้ม, Opasitas)로 옮겼다.
en.json 의 145개 키를 모두 옮기고, 같은 개념은 안드로이드 판(values-ru/pl/tr/hi/ar)의 용어를 따른다.
PC 의 투명도 슬라이더는 100 - 불투명도를 보여 주므로 안드로이드의 '불투명도' 대신 '투명도'로 옮긴다.
- es, pt-BR, fr, it, de 표(키 145개, en.json 과 같은 순서와 묶음). 소리 종류, 표현 모드 이름, 크기 고정, 반지름,
  광원, 속도, 민감도, 시작/중지, 상태, 언어 "시스템 기본값", 색 선택 창은 안드로이드 판 번역을 그대로 쓴다.
  말투도 안드로이드를 따른다(es·it 는 tú/tu, pt-BR 은 você, fr 는 vous, de 는 Sie).
- PC 의 투명도 슬라이더는 100 - 불투명도라 값이 클수록 더 투명해진다. 그래서 안드로이드의 "불투명도"(Opacity) 대신
  en/ko 처럼 "투명도"로 옮긴다.
- 런처의 슬라이더 이름 칸(170px)과 오버레이 편집 패널(380px)에 들어가는지 Pretendard 글꼴로 재서 길이를 맞춘다.
  편집 패널의 "...표시" 체크박스는 es·it·de 에서 색 버튼과 겹쳐, 안드로이드의 소리 종류 이름(동사 없이)만 쓴다.
  fr 는 편집 패널 제목이 넘쳐 "overlay" 를 쓴다.
- fr 는 안드로이드처럼 : ? ! 앞과 « » 안에, es·fr·de 는 % 앞에 줄바꿈 없는 공백(U+00A0)을 둔다.
- UiColors: 안드로이드 판 UiColors.kt 의 여덟 색(바탕 #2A2C31, 카드 #1E2024, 본 글자 #F2F4F6, 보조 글자 #8B95A1,
  강조 글자 #5B9BFF, 강조 바탕 #2563EB, 멈춤 #D32F2F, 안내 #FFB74D)과 PC 에만 있는 조작 요소 바탕, 마우스 올림·누름,
  비활성 스위치·슬라이더 색을 0xRRGGBB 상수로 둔다. 어디에 쓰는지와 대비를 주석에 적는다.
- Themes/Palette.xaml: 같은 값을 "이름 + Brush" 단색 붓으로 둔다. 화면은 이 붓만 쓴다.
- UiColorsContrastTests: 안드로이드 ColorContrastTest(#312)를 옮기고, 마우스 올림·누름 바탕 위 흰 글자, 조작 요소 바탕 위
  본 글자, 꺼진 스위치 손잡이, 키보드 초점 테두리(그림 요소 3:1)를 더한다.
- ThemePaletteXamlTests: Palette.xaml 을 XML 로 읽어 붓 값이 Core 상수와 같은지, 빠진 색이 없는지, 단색 붓만 있는지 본다.
- Themes/Theme.xaml 을 App.xaml 에서 합친다. 키를 붙인 서식만 두어 아직 옮기지 않은 창의 모양은 바뀌지 않는다.
- 안드로이드 UiControls.kt 의 모양을 옮긴다: 카드(모서리 20, 안쪽 24, 사이 16), 펼치기 카드(머리 18 굵게, 위 여백까지 머리
  전체를 누름), 구역 제목 22, 글자 13/15/16/18/36, 강조·보조·멈춤 버튼(높이 48, 홈은 56), 스위치 줄(줄 전체가 켜고 끔,
  이름 16 굵게 + 설명 13), 슬라이더 줄(이름과 값을 위 한 줄에 두고 설명 13, 막대를 누르면 그 자리로), 선택지 칸(고른 칸은
  강조 바탕에 흰 글자, 48px 이상), 언어 콤보, 색 코드 입력칸, 탭, 색 버튼, 미리 담긴 색, 창 제목줄 버튼.
  모두 키보드 초점 테두리(2px 강조색)와 쓸 수 없을 때의 모양을 가진다. 글자는 줄을 바꿔 긴 번역도 잘리지 않는다.
- SettingRow: 슬라이더·체크박스에 이름, 설명, 값 꼴을 붙이는 붙임 속성. 값은 ValueFormatConverter 가 InvariantCulture 로 적는다.
  슬라이더 이름은 화면 읽어주기 이름도 된다.
- EqualChoicePanel + ChoiceRows(Core): 같은 너비 칸을 줄지어 두고, 이름이 한 줄로 들어가지 않으면 칸 수를 줄여 줄을 나눈다.
  마지막 줄에 한 칸만 남는 나눔은 건너뛴다(안드로이드 choicesPerRow 와 같은 규칙). ChoiceRowsTests 가 규칙을 본다.
- ChoiceChipList: Ctrl+클릭(Ctrl+스페이스)으로 고른 칸이 풀리면 다시 고르는 ListBox. 모드 번호가 -1 이 되지 않게 한다.
- 채도 x 밝기 사각형(높이 180, 흰 고리 바깥에 검은 고리를 겹친 손잡이), 가로 색상 막대(손잡이는 양 끝에서 반지름만큼 안으로),
  고른 색 미리 보기와 #RRGGBB, 미리 담긴 색 8개(흰·빨강·주황·노랑·초록·청록·파랑·자홍, 네 개씩 두 줄, 동그라미 30px·누르는
  칸 48px)로 고른다. R/G/B 슬라이더는 뺐다.
- 색 코드는 입력칸으로 남겨 키보드로 적을 수 있다("#" 은 없어도 되고 투명도는 버린다). 적는 동안은 입력칸을 고쳐 쓰지 않고,
  칸을 떠날 때 #RRGGBB 로 맞춘다. 저장하는 꼴은 예전처럼 #FFRRGGBB 다.
- 미리 담긴 색과 적은 코드는 HSV 를 거치지 않고 그대로 쓴다(반올림으로 한 칸씩 어긋나지 않게). 회색·검정에서 끌어도 색상
  막대 위치를 잃지 않는다.
- 미리 담긴 색은 라디오 버튼이라 화면 읽어주기가 "선택됨"을 읽고, 화면 읽어주기의 "선택"으로도 색이 바뀐다(이름은 16진 코드).
  사각형과 막대는 초점을 받아 화살표 키로도 옮긴다(Shift 는 크게). Esc 는 취소, Enter 는 적용이다.
- 닫기 버튼이 다시 보인다(내용이 비어 있던 것을 아이콘 글자로). 아랍어에서는 창 전체가 뒤집히고 색 코드만 왼쪽에서 오른쪽이다.
- 런처: 창에 따로 두던 서식을 지우고 Theme.xaml 을 쓴다. 홈은 실행(강조 바탕)·실행 종료(멈춤 바탕) 버튼과 상태 점(실행 중이면
  강조색)이다. 설정은 안드로이드 설정 탭과 같은 순서·모양이다(모드 카드 → 모드별 펼치기 카드 → 소리 분류 → 단축키 → 고급).
  표현 모드·사운드 모드는 콤보 대신 선택지 칸이고, 모드 카드 안 순서도 안드로이드와 같다(원형의 반지름은 크기 다음).
  탭 머리 모양은 그대로이고, 탭 사이 간격을 탭 밖으로 옮겨 초점 테두리가 옆 탭에 닿지 않는다.
- 편집 패널(F4): 자리와 폭(왼쪽 380px), 가이드라인 끌기와 색, HUD 는 그대로 두고 패널만 같은 서식으로 다시 그린다.
  좁은 패널에서는 선택지 칸이 이름이 들어가는 만큼 줄을 나누고, 소리 종류 색은 동그라미 열 위에 "색상"을 한 번 둔다.
- 크기 고정·광원에 딸린 슬라이더는 흐리게 남기지 않고 접는다(안드로이드 DependentSettings). 런처의 네 모드 카드는 표
  하나(ModeControls)로 읽고 쓴다. 슬라이더 값 글자는 서식이 값에서 만든다.
- 진하기: 슬라이더가 투명도(100 - 저장값)를 보이던 것을 저장값(불투명도 %) 그대로 보인다. 설정 파일의 값과 뜻은 그대로다.
  "최대 투명도"는 사실 소리가 가장 클 때의 투명도(가장 작은 투명도)여서 헷갈렸고, 값이 클수록 옅어져 다른 슬라이더와 방향이
  거꾸로였다. en/ko 의 settings.opacity.title, settings.opacity.desc.wave·pad·circle·outline, settings.max_opacity.title·desc
  값을 안드로이드의 진하기 표현으로 바꾼다.
- 쓰지 않게 된 common.on("켜기") 키를 지운다. 스위치 줄은 스위치가 켜짐·꺼짐을 보인다.
- 슬라이더는 화살표 키로 1, PageUp·PageDown 으로 10 씩 움직이고(예전 0.1·1), 막대를 누르면 그 자리로 간다.
- AppSettings.SoundTypes: 모바일 sound_types 와 같은 맵(모델의 영어 소리 이름 → "ambient"/"speech"/"danger").
  오버레이의 AI 가 틱마다 다른 스레드에서 읽으므로 넣을 때 얼린 맵으로 바꿔 끼운다. 설정 파일에는 이름 순서의
  JSON 객체로 적는다.
- 읽을 때 SoundTypeOverrides.Sanitize 로 거른다(세 라벨이 아닌 값, 지금 기본 종류와 같은 값, 문자열이 아닌 값은 버리고
  모르는 이름은 남긴다). 값이 객체가 아니면 바꾼 것이 없는 것으로 보고, 다른 설정은 그대로 읽는다.
- AppSettings.ClassifyAdvanced: 저장한 적이 없으면 바꾼 소리가 있을 때만 고급 모드로 연다(모바일 loadClassifyAdvanced).
  두 항목이 없는 예전 설정 파일은 바꾼 소리 없이 기본 모드로 읽는다.
- 전체 초기화는 두 값을 건드리지 않는다. 모바일에서 바꾼 종류를 지우는 것은 소리 탭의 모두 되돌리기뿐이고, 고급 모드는
  어느 되돌리기도 건드리지 않는 화면 설정이다.
RealtimeAiPipeline 에 AppSettings.SoundTypes 를 넘긴다. 파이프라인은 틱마다 이 맵을 읽고, 바뀌면 판정을 새로
시작하며 앞의 결과를 버린다(모바일 #291·#321).
- SoundList.Build: 모바일 ClassifyTab 의 LazyColumn 항목과 같은 차례·키로 줄을 만든다(소개, 바꾼 소리 수,
  맞는 것이 없으면 빈 결과 안내, 기본 모드는 구역 머리와 카드, 고급 모드는 구역·묶음 머리와 소리 줄, 끝 여백).
  카드는 지금 종류와 미리보기를, 소리 줄은 지금 종류를 함께 든다. PC 는 찾기·거르기를 목록 위에 붙박아 두므로
  붙는 머리는 목록에 없다.
- SoundList.ShouldScrollToResults: 찾는 말이 있으면 맨 위에서도, 거르기만 바꾸면 내려가 있을 때만 결과를 찾기·거르기
  바로 밑으로 올린다(모바일 #283).
- SoundRowText: 화면 읽어주기 글과 이름 밑 밝은 글씨의 갈래(모바일 GroupCard·SoundTypeRow), 미리보기 기호(" • ",
  읽을 때는 ", ")와 "+N".
- KeyedList.Sync: 화면 목록의 줄을 새 항목에 맞추되 키가 같은 줄은 고쳐 쓴다. 종류를 고른 카드와 고급 모드
  스위치가 키보드 초점을 잃지 않게 한다.
- ConfirmWindow.Ask: 안드로이드 ConfirmDialog 를 런처 서식으로 옮긴 확인·취소 창. 카드 바탕에 굵은 제목, 보조 글자색
  본문(길면 스크롤), 보조 버튼(취소)과 강조 버튼(확인)이다. 버튼 글자도 앱 언어로 받는다. 메시지 상자의 예/아니요는
  Windows 언어를 따라 앱 언어와 어긋났다.
- 부르는 화면의 창을 주인으로 두어 그 가운데에 뜨고 주인 창을 막는다. Esc·취소는 취소, Enter 는 확인이고 처음 초점은
  확인 버튼이다. 아랍어에서는 창 내용 전체가 뒤집힌다. 창 제목은 화면 읽어주기가 창을 열 때 읽는다.
amophi added 29 commits October 14, 2026 16:57
- PC 에서도 맞는 문장은 안드로이드 번역을 그대로 쓰고, 진동·기기 이야기를 뺀 문장과 환경 정보·AGPL-3.0 으로
  바꾼 문장은 그 번역을 고쳐 쓴다. 힌디어 소리 종류 설명의 "폰에서 재생 중인 소리"는 "들리는 소리"로 고친다.
- PC 에만 있는 문장(시작하기, PC 설정과 단축키, 방향, 기본 출력 장치, 앱 볼륨, 자주 묻는 질문의 답,
  출력 장치 없음, 트레이 메뉴)은 새로 옮긴다. 아랍어는 «» 따옴표와 오른쪽에서 왼쪽 배치를 따른다.
- PC 에서도 맞는 문장은 안드로이드 번역을 그대로 쓰고, 진동·기기 이야기를 뺀 문장과 환경 정보·AGPL-3.0 으로
  바꾼 문장은 그 번역을 고쳐 쓴다. 안드로이드의 "누르기(tap)"는 PC 에 맞게 "클릭"으로 적는다.
- PC 에만 있는 문장은 새로 옮긴다. 독일어는 홈의 실행 종료가 이미 „Beenden“ 이라 트레이의 종료를
  „Programm beenden“ 으로 적어 메뉴에서 둘이 겹치지 않게 한다.
작업 브랜치의 사운드 모드 2.0 은 이제 안드로이드처럼 좌우를 Mid/Side 로 펼친다(좌우에 함께 있는 몫은 위에,
한쪽이 더 큰 몫은 그쪽 옆에, 뒤쪽은 조금 늦은 좌우로). 그래서 "그래픽이 왼쪽과 오른쪽에만 나타난다"는 안내가
맞지 않게 된다. 이 동작을 적은 안드로이드 help_note_direction 문장과 그 번역 앞에 2채널 출력이라는 조건을 붙여
18개 언어 모두 바꾼다. 5.1·7.1 출력의 안내는 그대로다.
사운드 모드 2.0 이 Mid/Side 로 펼쳐진 뒤로는 스테레오에서도 위·아래에 그래픽이 나온다. 앞뒤를 구분하려면 5.1·7.1 출력이 필요하다는 것과, 사운드 모드가 실행할 때와 출력 장치가 바뀔 때 장치의 채널 수에 맞춰진다는 것(OnChannelsChanged)을 18개 언어 모두에 적는다.
목차의 없는 '팀원' 항목을 지우고, 8개 언어를 18개로 고친다. 시각화는 FFT 가 아니라 채널별 최고 크기를 쓰므로 그렇게 적고, 60 FPS 기준 정규화와 쉬는 오버레이, 2.0 의 Mid/Side 와 출력 장치에 맞춰지는 사운드 모드를 적는다. 설치는 Windows 10/11 64비트와 .NET 설치가 필요 없다는 것, 설정이 exe 옆 settings.json 에 남는다는 것을 적고, 빌드는 .NET 10 SDK 명령과 세 프로젝트 구성으로 바꾼다(.NET 10 은 Visual Studio 2022 로 열 수 없다).
모바일 AudioEngine.currentLevel 을 옮긴 CaptureLevel. HUD 와 결과 기록의 lvl 칸이 이 값이다.

- 가장 최근 버퍼 하나의 채널별 최고값 가운데 가장 큰 값이다. PeakAccumulator 와 달리 읽어도 비우지 않고 버퍼끼리 모으지 않는다.
- 캡처한 채널을 모두 본다(모바일은 좌우 둘뿐). AI 무음 게이트가 버퍼의 모든 채널을 보므로, 이 값이 그 문턱 아래면
  "AI 가 추론을 건너뛰었다" 로 읽을 수 있다. 그리는 쪽의 Loudness 처럼 LFE 나 2.0 의 뒤 채널을 빼지 않는다.
- PC 에서 더한 것: WASAPI 루프백은 재생하는 것이 없으면 버퍼를 보내지 않아 마지막 값이 남는다. 마지막 버퍼가
  250ms 넘게 오래되면 0 으로 읽는다.
- NaN 채널은 건너뛴다. 캡처 스레드가 넣고 UI 스레드가 읽으며 할당은 없다.
모바일 AiDebugOverlay 의 produceState(100ms 폴링)와 DisposableEffect(기록 켜고 끄기)를 AiDebugHud 로 옮긴다.
화면과 타이머는 오버레이가 맡고, 여기서는 무엇을 할 차례인지만 정해 기기 없이 검사한다.

- 오버레이가 열려 있고 개발자 모드가 켜져 있으면 읽는다(IsPolling). 기록 스위치까지 켜져 있으면 기록한다(IsRecording).
  셋 가운데 하나가 바뀌면 그 자리에서 기록기를 켜고 끈다. 개발자 모드를 꺼도 기록 스위치 값은 남는다(모바일과 같다).
- 오버레이가 프레임마다 지금 설정을 알려도 같은 값이면 아무것도 하지 않는다. 바뀐 경우에만 HUD 를 보이고 숨기라고 알린다.
- 읽을 때마다 결과와 그 순간의 소리 크기·표시 여부를 기록기에 넘기고, AiDebugText 의 글자를 돌려준다. 같은 결과는
  기록기가 한 줄만 남긴다. 결과가 없으면 넘기지 않는다.
- 오버레이를 닫으면 기록기를 닫고, 그 뒤에 온 설정이나 읽기로는 다시 켜지 않는다.
- AiDebugRecorder 가 테스트용 IAiDebugRecorder 를 구현한다. 부르는 곳이 렌더 루프가 아니라 HUD 가 되었으니 주석을 맞춘다.
- 테스트: 켜고 끄는 순서, 같은 설정 반복, 기록 스위치만 켠 경우, 닫은 뒤, 그리고 실제 기록기로 파일이 생기고
  개발자 모드를 끄면 닫히는지.
모바일 developer_record(#193)를 옮긴다. 기본은 꺼짐이고 settings.json 의 DeveloperRecord 항목에 저장한다.

- 개발자 모드와 따로 저장해, 개발자 모드를 꺼도 값이 남는다(모바일과 같다). 기록은 오버레이가 떠 있고 개발자 모드가
  켜져 있을 때만 돈다(AiDebugHud).
- 항목이 없는 예전 설정 파일은 꺼진 채로 읽는다.
- 전체 초기화는 이 값도 기본값(꺼짐)으로 되돌린다. 모바일에는 전체 초기화가 없고, PC 의 전체 초기화는 언어와 소리 탭
  값만 남기고 개발자 모드를 포함한 모든 설정을 되돌린다.
모바일 AiDebugOverlay 를 오버레이에 붙인다. 글자는 Core 의 AiDebugText, 언제 읽고 기록할지는 AiDebugHud 가 정한다.

- 왼쪽 위(오른쪽 위 FPS 와 같은 높이)에 불투명한 판(#101216)을 두고, 종류·점·이름·확신도 / thr·age / cue /
  lvl·shown·소요시간 / top-5 를 모바일과 같은 줄과 색(본문 #E8EAED, top-5 #B0B4BA)으로 보인다. 점은 그 종류에
  사용자가 고른 색이다. 고정폭 글꼴(MonoFontFamily) 13 이고, 누름은 받지 않는다.
- 100ms 타이머로 읽는다. 렌더 루프와 따로 돌아 오버레이가 쉬는 동안에도 멈추지 않고, 개발자 모드가 켜져 있을 때만
  돈다. 설정은 프레임마다(쉬는 동안에는 확인할 때마다) 따르므로 런처·편집 패널·초기화 어디서 바꿔도 바로 켜고 끈다.
- lvl 은 가장 최근 캡처 버퍼의 피크(CaptureLevel)다. 캡처 스레드가 버퍼마다 넣고, 쉬는 오버레이 깨우기와 같은
  스택 배열을 쓴다. shown 은 그 결과의 종류를 표시하도록 켜 두었는지, aiAvailable 은 런처에 AiAvailabilityChanged 로
  알린 값과 같다(따로 판단하지 않는다).
- 결과 기록 스위치(AppSettings.DeveloperRecord)가 켜져 있으면 읽을 때마다 결과를 기록기에 넘긴다. 창을 닫으면
  기록 파일을 닫는다.
- 화면 가운데의 "이름 | 종류 | 확신도" 라벨을 없앤다. HUD 가 같은 정보를 모두 보이고, 같은 결과를 두 곳에서 다른
  꼴로 보이면 어느 쪽을 믿을지 헷갈린다. 게임 화면 한가운데를 가리지도 않는다. FPS 표시는 그대로 둔다.
- 개발자 모드 설명(설정·편집 패널)을 18개 언어에서 새 HUD 에 맞게 고친다.
모바일 설정 탭의 개발자 구역(개발자 모드 아래 딸린 기록 스위치)을 옮긴다.

- 런처 설정 → 고급 설정 카드와 오버레이 편집 패널의 고급 카드에서 개발자 모드 스위치 바로 아래에 들여 놓는다.
  개발자 모드를 끄면 접고(값은 남는다), 켜면 다시 보인다(안드로이드 DependentSettings). 어디서 바꿔도 오버레이가
  다음 프레임에 따라 기록을 켜고 끈다.
- 설명에 파일이 생기는 곳을 적는다: SoundVisualizer.exe 옆 ai-log 폴더의 새 CSV 파일, 거기에 쓸 수 없으면
  %LOCALAPPDATA%\SoundVisualizer\ai-log(AiDebugRecorder 와 같다). 편집 패널도 같은 설명을 쓴다.
- 도움말의 개발자 모드 글을 새 HUD 와 기록에 맞게 고친다. 기록 스위치 이름은 그 화면의 글자를 끼워 넣어 번역이
  바뀌어도 어긋나지 않게 한다.
- 문자열 settings.developer_record.title·desc 를 settings.developer_mode.desc 바로 뒤에 18개 언어로 넣고,
  help.developer.body 를 18개 언어에서 고친다.
안드로이드 TutorialScript·TutorialSounds 를 옮긴다. 쪽(소리, 방향, 종류)마다 좌우 피크와 AI 라벨을
시각만 받는 순수 함수로 내고, 소리를 켰을 때 낼 녹음 조각의 박자도 대본에서 만든다. 진동 쪽은 PC 에
진동이 없어 옮기지 않았다.

대본의 좌우는 사운드 모드 2.0 의 앞 좌우(FL, FR)로 넣어, PC 엔진의 Mid/Side 펼치기가 실제 스테레오와
똑같이 그린다. TutorialDemo 는 오버레이와 같은 엔진을 16:9 기준 화면 크기로 돌리고, 쪽마다 그 쪽이
말하려는 장면까지 미리 돌려 둔다. 크기·진하기·반응은 그림 전용 설정으로, 사용자가 꺼 둔 종류도 그린다.
안드로이드 tutorial_seen 처럼 처음 여는 사람에게만 튜토리얼을 저절로 띄울 수 있게
AppSettings.TutorialSeen 을 둔다. 설정 파일이 없으면(새로 설치) 아직 보지 않은 것이고,
파일은 있는데 이 항목이 없거나 읽을 수 없으면 이 기능 전부터 쓰던 사람이라 본 것으로 친다.

PC 는 저장할 때마다 모든 항목을 쓰므로, 튜토리얼을 닫기 전에 다른 설정을 먼저
저장해도 정한 값이 함께 남아 "쓰던 사람" 으로 잘못 치지 않는다. 보는 사람에 대한
값이라 전체 초기화는 건드리지 않는다.
안드로이드 튜토리얼의 녹음 조각 넷(새소리, 새 울음, 인사말 대화, 자동차 경적)을 한 번 풀어
PCM WAV(모노 16비트, 원래의 44.1kHz)로 넣는다. 앱이 Ogg 를 풀려면 디코더 라이브러리가 더
필요해, 저장소 밖의 일회용 변환기(NVorbis)로 풀어 둔 파일을 실행 파일의 리소스로 넣는다.
길이는 원본 그대로다(2.2초, 0.45초, 2.2초, 0.5초, 합쳐 약 470KB).

THIRD_PARTY.md 에 원본, 자른 구간, 라이선스(CC0 1.0, 미국 정부 저작물 퍼블릭 도메인)와
PC 판은 안드로이드 Ogg 를 WAV 로 푼 것임을 적는다. 라이선스 창이 이 파일을 그대로 보인다.
안드로이드 TutorialScreen 을 런처와 같은 모양(어두운 둥근 창, Pretendard)으로 옮긴다. 쪽은 소리 →
방향 → 종류 → 켜고 끄는 방법이다. 앞의 세 쪽은 16:9 화면 안의 게임 장면 위에 오버레이와 같은 엔진과
사용자의 표현 모드·색으로 대본의 소리를 그린다. 기준 화면(1920×1080)으로 계산해 Viewbox 로 줄여
실제 화면을 줄인 모양이 되고, 종류 쪽은 사용자 색으로 범례를 밝힌다. 마지막 쪽은 홈을 줄인 그림과
도움말의 시작하기 단계에 지금 단축키(F2·F3·F4)를 끼운 단계를 보인다.

- 넘기기: 이전·다음 버튼, ←/→(아랍어는 반대), PageUp/PageDown. Enter 는 다음, Esc·건너뛰기·Alt+F4 는
  닫기다. 쪽이 바뀌면 키보드 초점을 새 쪽의 제목으로 옮긴다.
- 그림은 창이 보이고 멈추지 않았을 때만 움직이고, 최소화(런처째 포함)하거나 닫으면 멈춘다. Windows 에서
  애니메이션을 꺼 두었으면 멈춘 채로 시작한다(WCAG 2.2.2).
- 소리는 열 때마다 꺼진 채로 시작하고, 켜면 녹음 조각을 그림과 같은 프레임에 좌우 크기대로 낸다.
  출력 장치가 없으면 AppLog 에 남기고 소리 없이 그대로 돈다. 오버레이가 도는 동안은 루프백 캡처가
  들으므로 내지 않는다.
- 아랍어는 창을 뒤집되 소리 그림은 뒤집지 않는다. 왼쪽 스피커의 소리는 화면 왼쪽에 그려진다.

PC 에 없는 진동 쪽과 마이크(외부 사운드 모드)·권한 문구는 옮기지 않았다. 소리 쪽은 "이 PC 에서
재생되는 소리", 방향 쪽은 2채널은 좌우, 5.1·7.1 은 앞뒤도 구분한다고 새로 써서 18개 언어로 옮긴다.
단축키 이름은 설정 탭과 같게 런처의 GetKeysName 을 함께 쓴다.
안드로이드 MainActivity·HomeTab 처럼 튜토리얼을 본 적이 없으면 런처가 다 그려진 뒤 런처 가운데에
튜토리얼을 띄운다. 사용자가 닫으면(확인, 건너뛰기, Esc, Alt+F4) 본 것으로 저장하고, 닫지 않은 채
앱을 끝내면(트레이의 종료 등) 다음에 다시 띄운다. 홈의 실행·실행 종료 아래에 "튜토리얼 보기" 글자
버튼을 두어 언제든 다시 연다.

런처 쪽 코드는 LauncherWindow.Tutorial.cs 에 따로 두고, 런처 XAML 에는 버튼 하나만 더한다.
소리 탭 제목, 기본 모드 경고, 도움말의 시작하기·소리 종류·언어 답에서 '고르다' 일곱 곳을 '선택하다'로 맞춘다.
탭 줄을 WrapPanel 로 바꾸면서 TabPanel 의 키보드 동작이 빠져, Tab 이 탭 머리마다 멈췄다.
탭 머리는 초점을 받으면 그 탭이 골라지므로 Tab 만으로는 마지막 탭(더 알아보기)의 내용에만
닿고 홈·설정·소리·도움말의 버튼과 설정 칸에는 닿지 못했다. 탭 줄을 TabPanel 처럼
Tab 은 한 번(지금 탭)만 멈추고 탭끼리는 화살표로 옮기게 한다.

- 제목줄(언어 콤보, 최소화·닫기)에 초점이 있을 때도 Ctrl+Tab·Ctrl+Shift+Tab 이 탭을 옮긴다.
  탭 안에서는 TabControl 이 이미 옮기고, 제목줄에서는 Tab 처럼 다음 칸으로만 갔다.
- 오버레이 편집 패널은 Esc 로도 단축키(기본 F4)처럼 저장하고 닫는다. 언어 목록이 열려 있거나
  단축키를 받는 중이면 그쪽이 Esc 를 먼저 쓴다.
- 오픈소스 라이선스 창의 고지 글칸에 키보드 초점이 보이도록 색 코드 입력칸처럼 강조색 테두리를 둔다.
WPF 콤보는 닫혀 있을 때 화살표·Home/End·글자 키로 바로 다음 항목을 고른다. 언어 콤보는
고르는 즉시 앱 전체의 글꼴·쓰는 방향·글자가 바뀌어, 키보드나 화면 읽어주기로 언어 이름을
둘러보면 키를 누를 때마다 앱이 다른 언어로 바뀌었다(런처 제목줄, 오버레이 편집 패널 모두).

두 콤보를 ui:CommitOnCloseComboBox 로 바꾼다. 닫혀 있을 때 이동 키·Enter·스페이스·글자는
값을 바꾸지 않고 목록을 열고, 열린 목록에서는 화살표가 항목을 옮겨 보이기만 하다가
Enter·Alt+↑·F4·마우스 클릭으로 고를 때 언어를 바꾼다. Esc 와 Tab 은 고르지 않고 닫으며,
Tab 은 다음 칸으로 간다(WPF 는 열린 목록 안에서만 돌았다). 마우스는 예전처럼 한 번 눌러
고르고, 닫힌 콤보 위의 휠도 예전처럼 값을 바꾸지 않는다(창마다 있던 처리기를 콤보로 옮긴다).

키 규칙은 Core 의 ComboListKeys 에 두고 시험한다.
UI Automation 으로 창마다 조작 요소를 훑어 이름이 비었거나 뜻이 없는 것을 고친다.

- 런처 제목줄의 최소화·닫기는 아이콘 글자(U+E921, U+E8BB)를 이름으로 읽혔다. 이름(최소화,
  닫기)을 주고, 아이콘은 서식 안의 TextBlock 으로 그려 이름 밑에 아이콘 글자가 따로 읽히지
  않게 한다. 색 선택 창의 X 도 "취소" 대신 "닫기"로 읽는다(아래 취소 버튼과 겹쳤다).
- 단축키 버튼(설정 탭, 편집 패널)은 "F3" 만 읽혔다. Button.Hotkey 서식이 무엇의 단축키인지와
  지금 키를 함께 읽힌다("표현 모드 전환, 단축키: F3"). 키를 받는 중에는 안내 글이, 언어를 바꾸면
  새 언어가 이름에 따라 들어간다. 설명은 도움말로 읽힌다.
- 소리 종류 색 버튼 셋은 "색상 설정"(편집 패널은 "색상")으로 똑같이 읽혔다. 이름에 종류를 넣는다
  ("색상 설정: 환경음").
- 소리 탭의 거르기 칸과 목록에 이름을 주고, 목록의 줄(데이터 항목)이 형식 이름
  ("SoundVisualizer.Ui.TypeRow")으로 읽히던 것을 줄의 글로 바꾼다. 아직 그리지 않은 줄은 이
  이름이 전부다. 종류 목록(메뉴)은 카드·소리의 이름으로 읽힌다.

새 문자열: common.minimize, common.close, settings.set_color_for, sounds.filter.title,
hotkey.button_name(18개 언어).
- 채도·밝기 사각형과 색상 막대는 키보드 초점을 받아 화살표로 움직이는데, Border 라 화면
  읽어주기에 보이지 않아 초점이 와도 아무것도 읽히지 않았다. ui:ColorArea 로 바꿔 슬라이더로
  알리고, 이름(채도와 밝기, 색조)과 지금 값("채도 50%, 밝기 80%", "200°")을 읽히며, 화살표로
  바꿀 때마다 새 값을 다시 읽게 한다. 도움말은 화살표와 Shift 로 바꾸는 방법이다.
- 미리 담긴 색 여덟은 "#FF0000" 처럼 16진 코드로만 읽혔다. 안드로이드와 같은 색 이름(cd_preset_*)
  으로 읽고 16진 코드는 도움말로 덧붙인다. 이름은 툴팁으로도 보여 색을 가려 보기 어려운 사람도
  무슨 색인지 안다.
- 미리 담긴 색 위에 "자주 쓰는 색" 이름표를 두고, 칸들을 그 이름의 묶음(ui:GroupUniformGrid)으로
  알린다. HEX 코드 칸은 이미 이름표로 이름이 붙어 있다.

새 문자열: color_picker.area, color_picker.area_value, color_picker.hue, color_picker.keys_help,
color_picker.presets, color_picker.preset.white~magenta(18개 언어, 색 이름은 안드로이드 번역).
- 소리 탭에서 종류를 고르거나 모두 되돌리면 "바꾼 소리: N" 이 바뀌는데 화면 읽어주기는 알 수
  없었다. 이 글을 살아 있는 영역(Polite)으로 두고 수가 바뀔 때 읽힌다. 목록을 멀리 내려가 이
  줄을 아직 그리지 않았으면 탭이 같은 글을 알림(UIA Notification)으로 읽힌다. 처음 그릴 때,
  언어·거르기만 바뀐 때, 다른 탭을 보는 동안(설정 탭의 전체 초기화)에는 알리지 않는다.
- 홈의 "꺼졌습니다" 안내는 사용자가 끄지 않았는데 꺼진 것이라 Polite 대신 Assertive 로 두어
  읽던 것을 끊고 바로 읽는다. 상태 줄과 AI 안내는 그대로 Polite 다.
화면 읽어주기는 제목으로 건너뛰며 화면을 훑는데, 소리 탭과 튜토리얼 말고는 제목이 없어
설정 탭의 구역, 도움말 카드, 창 제목을 글줄 하나하나 지나야 했다.

- 제목 서식에 단계를 둔다: Text.Title·Text.Section(창·탭의 맨 위 제목)은 1단계,
  Text.CardTitle(카드 제목)은 2단계. 설정 탭 구역, 색 선택·라이선스 창 제목, 편집 패널 제목과
  카드 제목이 따라온다. 확인 창 제목과 더 알아보기 카드 제목은 그 창·탭의 맨 위라 1단계로 적는다.
- 펼치기 카드는 머리 버튼을 제목으로 알린다. 카드에 바로 적으면 카드 묶음과 버튼이 둘 다 제목이
  되어 ui:CardHeading.Level 로 넘긴다. 설정의 모드 카드는 2단계, 도움말 카드는 1단계다.
- 도움말 카드 안의 항목 제목(모드 이름, 자주 묻는 질문)은 2단계다. 같은 서식을 쓰는 버전 글은
  제목이 아니라 뺀다.
오버레이 편집 화면의 가이드라인은 마우스로 끈다. 사각형 모서리 손잡이 넷(Thumb)은 키보드로
움직일 수 없는데 화면 읽어주기에는 이름 없는 손잡이로 보였고, 원형 모드에서 가운데 점선 고리를
끌어 바꾸는 반지름은 편집 패널에 같은 슬라이더가 없어 키보드로는 바꿀 수 없었다.

- 편집 패널에 반지름 슬라이더를 더한다. 원형 모드에서만 보이고(런처의 원형 모드 카드와 같은
  10~100), 고리를 끌면 슬라이더도 따라 움직인다. 패널에서 모드를 바꾸면 그 모드의 값을 넣는다.
- 모서리 손잡이는 ui:MouseOnlyThumb 으로 바꿔 화면 읽어주기에서 숨긴다. 같은 값을 패널의 크기
  (크기 고정이면 고정 크기) 슬라이더가 바꾸므로 키보드와 화면 읽어주기는 슬라이더를 쓴다.
탭 줄의 화살표와 Ctrl+Tab, 대화 창·편집 패널의 Esc, 언어 목록을 둘러보고 Enter 로 고르는 것,
화면 읽어주기가 읽는 이름과 바로 읽히는 상태, 편집 화면의 마우스 끌기를 슬라이더로도 바꿀 수
있다는 것을 도움말·안내 항목 밑에 한 줄로 적는다.
목록은 줄의 칸을 다시 쓴다(Recycling). 화면 밖으로 나간 소개 줄의 칸이 다른 줄(카드, 소리)에
쓰일 때 새 줄이 먼저 칸의 DataContext 로 들어가고 서식은 그 뒤에 바뀌는데, 그 사이 남아 있던
고급 모드 스위치의 바인딩이 IsAdvanced 를 찾지 못해 꺼짐이 되었다. 처리기는 이것을 사용자가
끈 것으로 보아, 고급 모드에서 내려가 종류를 바꾸거나 거르기를 바꾼 뒤 스크롤하면 고급 모드가
저절로 꺼졌다(하나씩 바꾼 묶음이 있으면 "기본 모드로 바꿀까요?" 창이 저절로 떴다). 목록을 그리는
도중에 목록을 다시 만들게 되어 VirtualizingStackPanel 이 예외를 내고 앱이 끝난 적도 있다.

스위치의 DataContext 가 소개 줄(IntroRow)일 때만 사용자가 바꾼 것으로 받는다.
UI Automation 으로 고급 모드를 켜고 내려가 종류 바꾸기·거르기·스크롤을 되풀이해 보면 고치기 전
앱은 고급 모드가 꺼졌고, 고친 뒤에는 세 번 모두 켜진 채였다.
README, 18개 언어의 런처 창 제목·도움말·더 알아보기·꺼짐 안내 제목, 홈 제목, 오버레이 창 제목과 편집 패널 머리, 튜토리얼의 홈 그림, 트레이 아이콘 이름을 바꾸고, 앱 이름을 XAML 글자로 허용하는 시험의 목록도 맞춘다.
크기·진하기·속도 같은 값은 모드마다 따로 저장되는데, 모드 칸으로 바꾸면 슬라이더가 이전 모드의 값을 그대로 보였다. 그 상태로 움직이면 새 모드에 엉뚱한 값이 들어갔다. F3 단축키 경로처럼 LoadSettingsToEditPanel 로 다시 채운다. 처리기를 읽는 시험을 남기고, 고치기 전 코드에서 실패하는 것을 확인했다.
@amophi
amophi merged commit 149fe5f into main Oct 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant